Skip to content

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:

  1. The RGS reveals the old serverSeed through GET /v1/rounds/:id/proof for every round that used it.
  2. 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 in GET /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:

  1. A bug in the verifier: check that serverSeed is the hex string exactly as returned (no leading 0x, no whitespace), that nonce is an integer, that the weights match the operator's current config.
  2. 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.
  3. 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/config returns lowWeight, 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 /proof for 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.