DOCS/RNG — PROTOCOL WHITEPAPER (DEVELOPER FORM)
sdk-v2/docs/RNG.md · commit 96368332 · GLI CSR §2.2: RNG description (protocol) · ← package index · raw on GitHub ↗RAIN RNG — the whole chapter
Provable fairness is the headline. This document is the complete specification of the RAIN random-number ceremony: what it is, why it is built this way, how it is anchored on Arbitrum, how anyone verifies any round with nothing but a keccak tool, what it does not guarantee, and the licence terms for using it under the "Powered by RAIN RNG" badge.
Package: @rain/rng-core (zero dependencies, ESM + CJS, browser/Node/worker). Reference implementations: ChannelManagerAA 0x60743006c2a5Dd5b9907CA37e7381854ff860959, EngineV2RulesVerifier 0x5d6048AB261e6151AB44fE6053e7AEb35Cb5C1B1, GameMuxVerifier8 0xb72B38123CF3E5F2605f2EED99c53c6D8a331b25 on Arbitrum One (chain id 42161), and the engines running on playmarkets.bet and maycasino.xyz.
0. In one paragraph
Two parties — a house and a player — each commit in advance to a long list of secrets (a hash chain, 4,096 elements on-chain, 65,536 with rotation in @rain/rng-session 2.1) and to one session seed half. Both commitments are written on-chain when the session opens. Every round k consumes the next secret from both chains; the round's randomness is
r_k = keccak256(abi.encode(pRev_k, hRev_k, sessionSeed, channelId, k))
Because the house's secret for round k was fixed before the session started and the player's secret is unknown to the house until the player has already committed the bet, neither party can choose, predict or bias the outcome, and no third party is involved at all — no oracle, no VRF, no server seed you have to trust. Every field is public after the round, so anyone can recompute it.
1. Why two-party commit-reveal beats a VRF or an oracle
| Property | Server-seed "provably fair" (industry norm) | VRF / oracle (Chainlink, Pyth Entropy, drand) | RAIN two-party commit-reveal |
|---|---|---|---|
| Who can know the outcome before the bet is placed? | The server — it holds the seed and sees the client seed the moment you send it | Nobody, if the oracle is honest; the oracle, if not | Nobody — outcome needs the player's unrevealed secret |
| Can the house selectively abort a losing round? | Yes (drop the request) | Yes (don't request / don't fulfil) | No: the bet is dual-signed and escrowed before the house reveals; a stall is a house fault paid from the house bond |
| External dependency | none | oracle liveness, fees, callback latency (seconds) | none — sub-100 ms rounds off-chain |
| Cost per round | 0 | oracle fee + gas per request | 0 (only the channel open/close touch the chain) |
| Verifiable from public data | only if the server publishes the seed and you trust it did not rotate it | yes | yes, from on-chain anchors + the two reveals |
| Bias by the party that submits the reveal | trivial (grind the server seed) | none | none: reveal is pinned by the hash chain (keccak(rev) == anchor), one element per round, order enforced |
| Liveness game | n/a | oracle can grief | symmetric: refusing to reveal is a fault for whichever side refuses (demandReveal → 60 s → forceClose) |
The key insight: randomness for money must be decided by the two parties whose money it is, and locked before either of them can act on it. A VRF gives you unpredictable numbers, but it puts a third party in the loop and does nothing about when the number is requested relative to the bet. Server seeds give you post-hoc auditing of a number the server already knew. RAIN combines a per-session seed (two-party commit-reveal, anchored on-chain) with per-round two-party hash-chain reveals, and — crucially — with an escrow whose money rules are enforced by the same contract, so "fair randomness" and "fair payment" are one system, not two.
2. The ceremony, step by step
2.1 Session open (once per login; on-chain)
House commits: picks a random
houseSeed(32 bytes) and publisheshouseSeedCommit = keccak256(abi.encode(houseSeed))together with the session terms. Picks a chain secret and computeshouseChainRoot = hashChain(secret, 4096)[0].Player commits: derives
playerSeedand a chain secret from wallet material (§2.4), computesplayerChainRoot. Signs the EIP-712OpenChannel(...)struct, which binds bothplayerSeedandhouseSeedCommit— so the house cannot swap its commitment after seeing the player's seed.On-chain (
ChannelManagerAA.openChannel, submitted by the house, gas paid by the house): the contract checkskeccak256(abi.encode(houseSeed)) == houseSeedCommit, verifies the player's signature (EOA or ERC-1271 smart account), deriveschannelId = keccak256(abi.encode(address(manager), player, operator, uint64 openNonce, uint256 chainId)) sessionSeed = keccak256(abi.encode(houseSeed, playerSeed, channelId))stores
sessionSeed,playerChainRoot,houseChainRoot, and emitsSessionSeedCommitReveal(channelId, houseSeed, playerSeed, sessionSeed). The channel is Active in the same transaction.
The player's client independently checks the commit and recomputes sessionSeed before accepting the open (crash-live-client.umd.js open()), and verifySessionSeed() in @rain/rng-core does the same from the event.
2.2 A round (every bet; off-chain, ~100 ms)
Cursor k is global per channel and shared by every game played inside it (dice, slots, Engine V2, craps…): reveals are never reused.
- Bet: the player signs the successor channel state that moves
stakefromplayerBalinto escrow (grossAtRisk) and pins both parties' current anchors (pAnchor,hAnchor= the previous reveals, or the chain roots atk = 0). The player sends this signed BET state together with its own revealpRev_{k+1}. The player has now committed money and secret without knowinghRev. - House: verifies the player's signature and
keccak256(abi.encode(pRev)) == pAnchor; persists the BET state (fsync) and countersigns it → the bet is dual-signed. Only then revealshRev_{k+1}, computes the settle state, signs it, replies withhRev+ both countersignatures. - Player: checks
keccak256(abi.encode(hRev)) == hAnchor(the house used the one and only allowed secret), recomputes the outcome locally, checks the house's SETTLE state equals its own, verifies the countersignatures, adopts the state, and acknowledges in the background. If anything mismatches, the client refuses and holds a dual-signed BET state →forceClose→ HouseFault.
r_k = keccak256(abi.encode(pRev_k, hRev_k, sessionSeed, channelId, uint256 k)) // canonical (quick games, craps)
seed = keccak256(abi.encode(pRev_k, hRev_k, sessionSeed, channelId, uint256 k, keccak256(bytes(gameId)))) // Engine V2 per-game domain
tick = keccak256(abi.encode(pRev_k, hRev_k, sessionSeed, channelId, uint32 roundId, uint32 k)) // Live Crash (per-round chains)
Reductions for the on-chain quick games are plain uint256(r) % N (dice N = 100, roulette N = 37), exactly as the Solidity verifiers do; modulo bias is < N / 2^256. New games use the v2.1 draw layer (§2.5).
2.5 The draw layer (v2.1): one RAIN seed per spin → DRBG → counter-derived draws
A slot spin needs many random values (reel stops, cascades, bonus picks). Instead of hashing per draw or truncating r to a 32-bit seed for a non-cryptographic generator, v2.1 derives all of a round's draws from r with a standard deterministic random bit generator — the layer a test lab certifies once for every game:
key = HMAC-SHA-256(key = r, msg = "RAIN-RNG-v2.1|" + gameId) // domain separation per game
hmac-drbg: draw(i) = HMAC_DRBG_SHA-256.Instantiate(entropy = key).Generate(32 bytes, additional_input = uint64be(i)) // SP 800-90A §10.1.2 (default)
chacha20: draw(i) = ChaCha20Block(key, counter = i, nonce = 0^96)[0..32) // RFC 8439 §2.3 (selectable)
uint32(i) = draw(i)[0..4) BE uint53(i) = draw(i)[0..7) >> 3 float(i) = uint53(i) / 2^53
intBelow(i, n): v = uint32(i+j) for j = 0,1,…; accept the first v < 2^32 − (2^32 mod n); return v mod n // unbiased (rejection)
shuffle(i, arr): Fisher–Yates using intBelow // reels, decks
draw(i) is a pure function of (r, gameId, i, mechanism) — replayable by the player, the operator and the lab, in TypeScript (@rain/rng-core drbg()) or Python (rain_rng.drbg()), byte-identical (shared vectors). Known-answer tests: NIST CAVP HMAC_DRBG (32 cases), FIPS 180-4, RFC 4231, RFC 8439. Cost: ≈55 µs per HMAC-DRBG draw, ≈4.5 µs per ChaCha draw; a 3-reel spin ≈ 0.2 ms.
Reseed per round, never per session: every round k has a fresh 256-bit r_k; the DRBG state never spans rounds. Deprecated: seed32Compat(r) (r & 0xFFFFFFFF → xoshiro & co) is development-only and warns — 32 bits are not production-secure.
2.6 Latency, pipelining, rotation (@rain/rng-session 2.1)
- Pipelining: the client commits round
k+1while roundk's animation plays (RainSpinClient.prefetch();maxInFlightdefault 2 on both sides). The house persists in-flight commits concurrently; eachpRevmust chain to the previous accepted one. RNG work on the/spincritical path measured at p95 0.5 ms with a remote node at 40 ms RTT (examples/slot-spin-endpoint/loadtest.mjs). - Chain rotation: default length 65,536; at 90 % consumed both sides run a fresh commit + reveal pair (
rotateTerms → rotate → rotated), no chain transaction unless an anchoring adapter is wired in; rotation records are kept for audit and old proofs keep verifying. spinSeed(k): the whole ceremony for one round in one call →{ r, k, proof, latencyMs, stream(gameId) }, for integrators who only want the seed. Transports: in-process, WebSocket, HTTP long-poll.
See docs/INTEGRATION-OPERATOR.md (builder guide) and docs/LAB-READINESS.md (GLI-19 §3 mapping).
2.3 Hash chains
c[n] = keccak256(secret) // raw 32 bytes
c[i-1] = keccak256(abi.encode(c[i])) // 32-byte word
root = c[0] (committed on-chain); k-th reveal = c[k]; keccak256(abi.encode(c[k])) == c[k-1]
ChannelManagerAA.submitReveals enforces the same rule on-chain (BadProof otherwise) and advances cursorP / cursorH, so a party can be forced to publish its reveals: demandReveal(channelId, onPlayer, target) starts a 60 s window; silence is a fault. A 4096-chain gives 4095 rounds per channel; the bankroll layer rolls the channel (close + reopen) near exhaustion.
2.4 Player material — one wallet signature, ever
base = keccak256(personal_sign("RAIN session key v1 for <manager> chainId <id>"))
sessionWalletPk = keccak256(base ‖ "session") // burner key that signs channel states, cannot move funds
chainSecret_n = keccak256(base ‖ "chain:<openNonce>") // per channel
playerSeed_n = keccak256(base ‖ "seed:<openNonce>") // per channel, public half of the session seed
Deterministic (RFC-6979 signatures), so the same wallet always re-derives the same secrets on any device — the player never has to back anything up, and the house never learns base. Payouts go only to the wallet (playerOwner); the session key can only sign game states.
3. Threat model
| Adversary | Attack | Why it fails |
|---|---|---|
| House | Pick a favourable hRev |
Impossible: hRev_k is pinned by keccak(hRev_k) == hAnchor and the anchor chain ends at houseChainRoot committed at open. |
| House | Swap houseSeed after seeing playerSeed |
The player's OpenChannel signature covers houseSeedCommit; the contract checks the reveal against it. |
| House | Grind houseSeed at open to bias the session |
sessionSeed alone predicts nothing: every outcome also needs pRev_k, unknown to the house at commit time. The only freedom is not opening — observable, and gains nothing. |
| House | See the outcome, then refuse a losing settle | The BET state is dual-signed and persisted before hRev leaves the node. Stalling ⇒ player forceCloses with the BET state ⇒ HouseFault: stake voided back plus compensation max(2 × grossAtRisk, 5 units) from HouseBond, and an equal penalty burned. |
| House | Misreport the result of a hash-committed game (Engine V2) | Client re-runs the public engine; mismatch ⇒ refuse ⇒ HouseFault. Anyone can re-run afterwards from (seed, gameId, capFp, winFp, resultHash). |
| House | Move player funds | Contract pays only playerOwner / operator; the session keys have no such power. |
| Player | Pick a favourable pRev |
Same chain pinning, symmetric. |
| Player | See hRev, then refuse to sign a losing settle |
The house holds the dual-signed BET state with grossAtRisk = stake ⇒ after PLAYER_GRACE (180 s) ⇒ forceClose ⇒ PlayerForfeit (stake to house). The house needs no signature from the player to settle a loss. |
| Player | Replay an old winning state | Nonce floor (checkpointNonce), challenge() with any newer dual-signed state during the window; fee/balances conservation checked on-chain. |
| Player | Forge a smart-account signature | ERC-1271 validated by the account contract itself; the RAIN session key is installed as a signature-only validation entity (cannot send UserOps). |
| Both collude | Extract more than deposit + allocation | Conservation invariant playerBal + houseBal + feeAccrued + grossAtRisk == deposit + allocation is enforced on every on-chain state; the fee waterfall is executed by the contract. Collusion between the two only parties whose money is at stake is not an attack on anyone else. |
| Third party / griefer | Front-run, censor, or influence outcomes | Rounds are off-chain and need both secrets — no mempool exposure. The only on-chain surfaces are open/close/dispute, all replayable by either party with a newer signed state. reclaimStale (30 days of total silence) recovers escrow if both vanish, and is strictly dominated by forceClose for anyone holding a newer state. |
| Watchtower failure | Party offline during a bogus forceClose |
Challenge windows: 10 min (channels ≤ 100k units) or 24 h; anyone (not just the parties) may challenge with a newer dual-signed state. |
4. On-chain anchors (what to look at on Arbiscan)
| Anchor | Where | Meaning |
|---|---|---|
SessionSeedCommitReveal(channelId, houseSeed, playerSeed, sessionSeed) |
event, openChannel tx |
the two seed halves and the derived session seed; verifySessionSeed() recomputes |
channels(channelId).playerChainRoot / houseChainRoot |
storage (view) | root of each party's 4096-chain |
channels(channelId).lastRevealP / cursorP / lastRevealH / cursorH |
storage | reveals posted via submitReveals (only during disputes; off-chain reveals normally never touch the chain) |
Checkpointed(channelId, nonce, stateHash) |
event | dual-signed state anchored as a dispute floor |
ForceCloseStarted / Challenged / ForceClosed(… fault, compensation) |
events | dispute path and who was at fault |
FraudProven(channelId, cheater, kind) |
event | a bad-transition proof succeeded (verifier said the transition was illegal) |
EngineV2RulesVerifier.seedOf(...) |
pure view | the per-game seed formula, callable by anyone |
EngineV2RulesVerifier.engineHash(gameIdHash) |
view | keccak256(engineBundle ‖ config) the house committed to for that game |
5. Verify any round yourself (no RAIN software)
You need the fairness-panel fields channelId, sessionSeed, k, pRev, hRev (and gameId for Engine V2), plus any keccak256 tool.
- Session seed: open the channel's
openChanneltx on Arbiscan → Logs →SessionSeedCommitReveal. Checkkeccak256(abi.encode(houseSeed, playerSeed, channelId)) == sessionSeed. (cast keccak $(cast abi-encode "f(bytes32,bytes32,bytes32)" $H $P $C)) - House did not swap seeds: the
houseSeedCommitshown in the house's terms (and in your signed OpenChannel struct) must equalkeccak256(abi.encode(houseSeed)). - House reveal is the committed one:
keccak256(abi.encode(hRev_k)) == hAnchorwherehAnchoris thehRev_{k-1}from the previous round (orhouseChainRootfromchannels(channelId)atk = 1). Or hashhRev_kktimes and compare withhouseChainRoot. - Outcome:
r = keccak256(abi.encode(pRev, hRev, sessionSeed, channelId, k))(five 32-byte words;kis uint256). Dice:roll = r % 100 + 1. Roulette:n = r % 37. Engine V2: appendkeccak256("foundry")(etc.) as a sixth word and run the published engine. - Compare with what you were shown. Any discrepancy is a provable misreport.
With the SDK this is one call:
import { verifyRound, verifySessionSeed } from "@rain/rng-core";
verifyRound({ pRev, hRev, sessionSeed, channelId, k, N: 100, expected: roll - 1, hAnchor }).ok; // true ⇢ VERIFIED
6. Using RAIN RNG in your own product
- You run the house (
@rain/rng-session):RainRngHousekeeps the chain and enforces persist-before-reveal;RainRngClientkeeps the player chain and enforces decision-before-reveal. Transport is yours (WebSocket, HTTP, in-process, Nostr…). OptionalRngAnchorcontract to publish commitments on any EVM chain. - You only need numbers (e.g. an AI agent or a Stake-style casino):
@rain/rng-corealone — 40 lines to a provably fair dice game (examples/agent-dice). - You run a slot server (operator = party A, RAIN node = party B):
RainSpinClient.spinSeed()→drbg(r, gameId)→ your math —examples/slot-spin-endpoint,docs/INTEGRATION-OPERATOR.md. Python servers:packages/rng-py. - You want the whole casino protocol (escrow, fault compensation, fraud proofs):
@rain/channels+@rain/gameson the liveChannelManagerAA.
7. Limits — honest, pre-audit
- No external smart-contract audit yet. Internal review only (
rain-risk-markets/AUDIT.md). Do not run real money at scale before one. - Fairness is per round, not statistical. The system guarantees no party could bias any single round; it does not prove that the published RTP is what you experienced — that is a property of the (public, verifiable) payout tables and the law of large numbers, not of the RNG.
- Hybrid games (Engine V2 slots) are hash-committed, not re-executed on-chain. Misreports are detectable by anyone with the open engine bundle and are punished via the client's refusal path, but a player using a malicious client that signs whatever the house says has no on-chain protection for that step.
- Liveness ≠ safety. A house that goes offline cannot steal, but you must submit
forceCloseand wait a challenge window (10 min / 24 h) to exit. - Selective abort at open is possible for either party (just don't open); it gains nothing because the session seed predicts no outcome, but it is a UX nuisance a house could inflict.
- Admin keys.
ChannelManagerAA.setVerifier / setOperator / setPausedare owner-controlled on the live deployment (needed for pre-audit iteration). They cannot alter an open channel's seed or roots, but a malicious owner could pause opens or point disputes at a broken verifier. Timelock/multisig before real money. - The house node is not yet a packaged operator binary.
@rain/rng-sessiongives you the ceremony; the full live house node (WebSocket protocol, WAL, bond management) is Playmarket-internal for now — seeREADME.md§ Gaps. - Modulo reduction, not rejection sampling, for the on-chain quick games — by design (byte-identity with the verifiers). Bias is
< N / 2^256, irrelevant for anyN ≤ 2^64. The v2.1 draw layer (§2.5) uses rejection sampling. - Not lab-certified. The draw layer uses standard mechanisms with KATs, but no test lab has evaluated it —
docs/LAB-READINESS.mdlists what a lab still has to do.
8. Attribution licence
@rain/rng-core, @rain/rng-session and the RNG chapter of this SDK are licensed MIT with an attribution condition (see /LICENSE). In plain words:
Any product, service, game or agent that is exposed to third parties and uses this RNG (in whole or in part, modified or not) must display the text "Powered by RAIN RNG" with a hyperlink to https://www.playmarkets.bet/fairness in a place its users can see (fairness panel, footer, about page, README of a public repo, or the model card / tool description of an AI agent).
badge()in@rain/rng-corereturns ready-made HTML, Markdown and SVG.
This is a licence term, not a request: use without the attribution is use outside the licence. Internal, private, or evaluation use needs no badge. You may not imply RAIN endorses your product, and you may not present the RNG as your own invention.
Reference: ChannelManagerAA.sol (V4 seed ceremony header comment), EngineV2RulesVerifier.sol, docs/whitelabel/PROVABLE-FAIRNESS.md, docs/CHANNEL-GAMES.md §1–§8, rain-risk-markets/AUDIT.md. Test vectors in packages/rng-core/test/vectors.json are generated from the live FE engines by scripts/gen-vectors.mjs; npm run test:live re-checks them against the deployed verifier and live channels on Arbitrum.
← Back to the package index · Rendered 2026-09-14 09:34 UTC from the repository copy; the markdown in the zip / repo is the document of record.