Provably Fair¶
Yantra uses a commit-reveal scheme backed by HMAC-SHA256 so that every round can be independently verified by the player, the operator, or an auditor. No outcome is ever generated after seeing the bet. No seed is ever revealed before its hash commitment is published.
This document describes the scheme, when each piece of state is committed or revealed, how to verify a round offline, and what to do if verification fails.
Reference implementation: shared commit-reveal primitives in
packages/rng-core/src/index.ts; per-game outcome mapping in
games/<code>/src/outcome.ts (e.g. games/ketapola-dice/src/outcome.ts).
Any change to either path invalidates the conceptual RNG certification for
that scope; CI blocks edits without a matching per-scope attestation
(CERT-ATTEST-CORE: or CERT-ATTEST-<GAMECODE>:).
The scheme¶
A round outcome is derived from three inputs:
| Input | Source | Secrecy |
|---|---|---|
serverSeed |
32 random bytes generated on the RGS at session start | Secret until revealed (see below) |
clientSeed |
Provided by the operator or player at session start, or auto-generated by the RGS | Public, fixed for the session |
nonce |
Session-scoped monotonic integer, starts at 0, increments per round | Public |
The construction:
hmac = HMAC_SHA256( serverSeed, clientSeed + ":" + nonce ) // 64 hex chars = 32 bytes
sideValue = parseInt(hmac[0..8], 16) // first 4 bytes (32 bits)
threshold = (lowWeight / (lowWeight + highWeight)) * 0xFFFFFFFF
side = sideValue < threshold ? "LOW" : "HIGH"
faceValue = parseInt(hmac[8..16], 16) // next 4 bytes (32 bits)
faces = side === "LOW" ? [3, 6, 9] : [12, 15, 18]
outcomeSum = faces[faceValue % faces.length]
Two calls with identical inputs always produce identical outputs. This is the
determinism property. The RGS cannot substitute a different outcome after the fact
without also substituting a different serverSeed: but the serverSeed is
hash-committed before play, so any substitution is detectable by the player.
What gets committed when¶
At session creation¶
The RGS generates a fresh (serverSeed, serverSeedHash) pair:
const serverSeed = crypto.randomBytes(32).toString('hex');
const serverSeedHash = crypto.createHash('sha256').update(serverSeed).digest('hex');
serverSeedHash is returned in the POST /v1/session response and displayed to the
player in the game UI. The raw serverSeed is stored encrypted on the RGS and is
not revealed at this point.
During play¶
Every round snapshots the current (serverSeed, serverSeedHash, clientSeed, nonce)
onto the Round row at creation. The player sees the outcome and may record
(clientSeed, nonce, outcomeSum, outcomeSide) for later verification.
On seed rotation or session end¶
A seed is considered "exhausted" when the player requests rotation (via the game UI) or the session terminates. At that moment:
- The RGS reveals the old
serverSeedthroughGET /v1/rounds/:id/prooffor every round that used it. - The RGS generates a new
serverSeed, commits its hash, and the cycle repeats.
The ProofService enforces this: serverSeed is returned only when the round's
state is SETTLED or VOIDED. See apps/rgs-server/src/services/ProofService.ts.
After a round¶
The player (or their tooling) now has everything needed to verify:
serverSeed: revealed via/v1/rounds/:id/proof.serverSeedHash: committed in the session response, stored on the round.clientSeed,nonce: public throughout.lowWeight,highWeight: published per-operator inGET /v1/config.- Claimed outcome:
outcomeSum,outcomeSide.
Deriving the outcome¶
Full derivation, step by step, for a concrete example:
serverSeed = "a1b2c3d4e5f6...7890" (64 hex chars = 32 bytes)
clientSeed = "deadbeef"
nonce = 7
lowWeight = 48
highWeight = 48
message = "deadbeef:7"
hmac = HMAC_SHA256(serverSeed, message)
= "9c4f1a2b8e5d6a7f1c3d2e5f4a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b"
sideValue = parseInt("9c4f1a2b", 16) = 2622230571
threshold = (48 / 96) * 0xFFFFFFFF = 2147483647
side = 2622230571 < 2147483647 ? LOW : HIGH = HIGH
faceValue = parseInt("8e5d6a7f", 16) = 2388049535
faceIndex = 2388049535 % 3 = 2
faces = [12, 15, 18]
outcomeSum = faces[2] = 18
Result: side=HIGH, outcomeSum=18. If the RGS published a different outcome for this
round with these inputs, the player has cryptographic proof of cheating.
Verification: JavaScript¶
Copy-paste runnable. Save as verify.mjs, run with node verify.mjs.
import crypto from 'node:crypto';
function verifyRound({ serverSeed, serverSeedHash, clientSeed, nonce, lowWeight, highWeight, claimed }) {
// 1. Confirm the revealed serverSeed matches the pre-committed hash.
const hashCheck = crypto.createHash('sha256').update(serverSeed).digest('hex');
if (hashCheck !== serverSeedHash) {
return { ok: false, reason: 'serverSeed does not match serverSeedHash' };
}
// 2. Recompute the outcome.
const hmac = crypto.createHmac('sha256', serverSeed)
.update(`${clientSeed}:${nonce}`)
.digest('hex');
const sideValue = parseInt(hmac.slice(0, 8), 16);
const threshold = (lowWeight / (lowWeight + highWeight)) * 0xFFFFFFFF;
const side = sideValue < threshold ? 'LOW' : 'HIGH';
const faceValue = parseInt(hmac.slice(8, 16), 16);
const faces = side === 'LOW' ? [3, 6, 9] : [12, 15, 18];
const outcomeSum = faces[faceValue % faces.length];
// 3. Compare with what the RGS published.
if (side !== claimed.side || outcomeSum !== claimed.outcomeSum) {
return { ok: false, reason: `mismatch: recomputed side=${side} sum=${outcomeSum}`, recomputed: { side, outcomeSum } };
}
return { ok: true, recomputed: { side, outcomeSum } };
}
// Example usage with proof payload from GET /v1/rounds/:id/proof
const result = verifyRound({
serverSeed: 'a1b2...',
serverSeedHash: 'f1d9...',
clientSeed: 'deadbeef',
nonce: 7,
lowWeight: 48,
highWeight: 48,
claimed: { side: 'HIGH', outcomeSum: 18 },
});
console.log(result);
Verification: Python¶
import hashlib
import hmac
def verify_round(server_seed, server_seed_hash, client_seed, nonce, low_weight, high_weight, claimed):
# 1. Confirm the revealed serverSeed matches the pre-committed hash.
hash_check = hashlib.sha256(server_seed.encode('utf-8')).hexdigest()
if hash_check != server_seed_hash:
return {"ok": False, "reason": "serverSeed does not match serverSeedHash"}
# 2. Recompute the outcome.
message = f"{client_seed}:{nonce}".encode('utf-8')
digest = hmac.new(server_seed.encode('utf-8'), message, hashlib.sha256).hexdigest()
side_value = int(digest[0:8], 16)
threshold = (low_weight / (low_weight + high_weight)) * 0xFFFFFFFF
side = "LOW" if side_value < threshold else "HIGH"
face_value = int(digest[8:16], 16)
faces = [3, 6, 9] if side == "LOW" else [12, 15, 18]
outcome_sum = faces[face_value % len(faces)]
# 3. Compare.
if side != claimed["side"] or outcome_sum != claimed["outcomeSum"]:
return {"ok": False, "reason": f"mismatch: recomputed side={side} sum={outcome_sum}",
"recomputed": {"side": side, "outcomeSum": outcome_sum}}
return {"ok": True, "recomputed": {"side": side, "outcomeSum": outcome_sum}}
if __name__ == "__main__":
result = verify_round(
server_seed="a1b2...",
server_seed_hash="f1d9...",
client_seed="deadbeef",
nonce=7,
low_weight=48,
high_weight=48,
claimed={"side": "HIGH", "outcomeSum": 18},
)
print(result)
Fetching a proof¶
GET /v1/rounds/:id/proof: HMAC-signed request, operator-scoped.
Response schema (from ProofService):
interface RoundProof {
roundId: string;
state: 'PENDING' | 'BETTING_OPEN' | 'ROLLING' | 'RESULT' | 'SETTLED' | 'VOIDED';
nonce: number;
clientSeed: string;
serverSeedHash: string;
serverSeed: string | null; // revealed only when state === SETTLED or VOIDED
outcome: {
diceValues: number[];
outcomeSum: number | null;
outcomeSide: 'LOW' | 'HIGH' | null;
};
}
If serverSeed is null, the round has not yet settled and the seed is still in use
for subsequent rounds. Poll after settlement to receive the revealed seed.
What to do if verification fails¶
A verification failure is one of three things:
- A bug in the verifier: check that
serverSeedis the hex string exactly as returned (no leading0x, no whitespace), thatnonceis an integer, that the weights match the operator's current config. - A bug in the RGS or in seed rotation: the revealed seed does not match the committed hash, or the recomputed outcome differs from the claimed outcome. This is a serious defect and must be reported.
- Cheating: the RGS claimed an outcome not derivable from its own committed inputs. This is cryptographic proof; take it seriously.
In all three cases, the reporting path is:
- Capture the full proof response from
/v1/rounds/:id/proof. - Capture the session response (contains
serverSeedHash). - Capture the operator config at time of the round (
GET /v1/configreturnslowWeight,highWeight,commissionMicro, etc.). - File a ticket to the operator's compliance contact or raise it through the responsible disclosure channel (see security.md).
Auditors reviewing historical rounds can consume the same proof endpoint. A
20-line loop around the verifier in this document walks a date range, fetches
/v1/rounds/:id/proof for each round, and asserts recomputed outcomes match
claimed ones, that script ships per-licensee and is left out of this repo
because signing credentials and operator scope are deployment-specific.
Design notes¶
- The scheme follows the industry-standard commit-reveal pattern used by Stake, NSoft, BC.Game, Rollbit, and Roobet. Nothing is invented here.
- We use only the first 16 hex characters of the HMAC (8 bytes of entropy). That is more than sufficient for a single-die outcome; additional bytes in the HMAC are wasted but preserved for forward compatibility if faces-per-side ever grows.
- Seed rotation is player-initiated. If the player suspects a run of bad outcomes is
suspicious, rotating the seed forces the RGS to commit to a new secret. The old
seed is then revealed via
/prooffor every round that used it, and the player can verify them all. - The commit-reveal property does not guarantee the game is fair, it guarantees the outcome was not chosen after the bet. Fairness (expected RTP, weight distribution) is a separate concern addressed in each game's PAR sheet (see games/ketapola-dice/docs/par-sheet.md or games/crash-minimal/docs/par-sheet.md).
Commit-reveal vs VRF: what we're not doing¶
Crypto-native operators sometimes ask why Yantra doesn't use a Verifiable Random Function (VRF): the scheme used on-chain by Chainlink VRF (GLI-19 certified via BMM) and by some Web3-native casinos.
| Property | Commit-reveal (Yantra) | VRF (Chainlink) |
|---|---|---|
| Primitive | HMAC-SHA256 over serverSeed + clientSeed + ":" + nonce |
Elliptic-curve proof: an RNG output paired with a proof that a specific private key produced it |
| Trust assumption | Player trusts the seed commit (hash published before the bet); the seed itself is revealed on rotation. Manipulation detectable post-hoc. | Player trusts the VRF public key; every output carries an on-chain-verifiable proof. Manipulation impossible, not just detectable. |
| Where it shines | Off-chain / Web2 operators. Cheap to run (one HMAC per round). | On-chain games where the RNG must be verifiable by anyone at any time without waiting for a reveal. |
| Latency | Zero, outcome is computable the instant the bet is placed. | Depends on chain finality (seconds to minutes). |
| Auditability | Post-hoc: reveal + verify. | Real-time: proof verifies on-chain. |
| Cost | Effectively zero. | Per-request LINK fee + gas. |
VRF is a fundamentally different scheme, not a "better commit-reveal". For an off-chain, operator-owned-wallet architecture, the shape Yantra targets, the added trust-minimisation of VRF is not free (latency, cost, vendor lock-in), and cert labs accept commit-reveal as the equivalent primitive for Web2 play. On-chain games that need chain-level trust minimisation are the right home for VRF.
Entropy source + health monitoring¶
The serverSeed is 32 bytes from crypto.randomBytes(32), which backs into
the OS CSPRNG, /dev/urandom on Linux, which itself is seeded from hardware
entropy sources (RDRAND, HPET jitter, interrupt timing, keyboard/mouse events
in interactive machines) and continuously reseeded. On server-class Linux with
getrandom(2) in non-blocking mode, this is non-blocking post-boot and
considered a cryptographic-quality source by NIST SP 800-90B.
For GLI-11 submissions and SOC 2 evidence, the RGS additionally monitors entropy health and exposes it as Prometheus metrics:
| Metric | Source | Alert |
|---|---|---|
rng_randombytes_latency_ms |
Histogram of crypto.randomBytes(32) latency |
p99 > 1 ms, could indicate entropy-pool depletion under VM hypervisor |
rng_entropy_avail_bytes |
Read from /proc/sys/kernel/random/entropy_avail (Linux only) at 10s intervals |
sustained < 256 bytes, scrape rngd / haveged status |
rng_determinism_check_total{result} |
Counter, incremented per round by the engine recomputing HMAC(serverSeed, clientSeed:nonce) and asserting it matches the outcome stored on the Round row |
Any result="fail" increment is a pageable incident |
The third metric is the strongest signal, it's the same check verifyOutcome
runs on /v1/rounds/:id/proof, run continuously in-process. A failure means
either the serialisation of serverSeed/clientSeed drifted or a row was
tampered with; both are incident-response-grade signals.
Hardware entropy (RDRAND, Intel TRNG, Raspberry Pi HRNG add-in) is optional and stirred into the pool by the kernel; the RGS never consumes it directly and never needs to, a single CSPRNG source, properly monitored, is the cert-lab expectation.