v2.8 · RNG PRE-SUBMISSION PACKAGE v1.2
RAIN RNG · PRE-SUBMISSION PACKAGE v1.2 · DOCUMENT

DOCS/HOUSE-NODE — RUNNING THE COUNTERPARTY

Source sdk-v2/docs/HOUSE-NODE.md · commit 96368332 · GLI CSR §2.2: Hosting · ← package index · raw on GitHub ↗

Running your own RAIN house node

"The house node is not operator-runnable from this SDK" was gap #2 in the README. This document closes the RNG half of it and draws the honest line around the other half.

The answer to "we can't depend on a Foundation-run node without HA" is: you run the node. @rain/rng-node is party B of the RAIN commit-reveal ceremony as a standalone, operator-run service. HA is yours (it's just N stateless workers over your Postgres). The protocol, the verification maths and the attribution stay RAIN's — every proof the node serves says operatorId: you, poweredBy: RAIN RNG.


1. What this node is — and is not

rain-rng-node (this package) the channel house node (NOT in this SDK yet)
Role in the protocol Party B of the RNG: commits houseSeedCommit + houseChainRoot, binds the client's Open, then for every round persists the client's commitment, then reveals hRev_k The counterparty that holds and settles money: opens ChannelManagerAA channels on-chain, signs EIP-712 ChannelStates, runs the rules engines, posts the house bond, sponsors AA gas, closes/force-closes
Holds funds? No. Nothing of value passes through it. Its only secrets are per-session houseSeed / chainSecret (in the WAL) and the operator signing key Yes — the escrow's house side, the bond
Who talks to it any RainRngClient (@rain/rng-session): your game server, a browser, an AI agent, a bot ChannelSession (@rain/channels) over the live wire protocol
Where it exists today this package — packages/rng-node Playmarket-internal production node; packages/channels/test/mock-house.mjs speaks the wire protocol for tests only
Persistence rule it enforces persist-before-reveal (WAL / Postgres, fsync before the reveal is even computed) the same, plus dual-signed state, bond accounting, on-chain open/close

If you only need "RAIN-grade randomness with a public proof link for every round" — a casino replacing its server seed, a game studio, an agent marketplace, a lottery — this node is the whole story. You keep your own wallets, payouts and rules; each of your rounds gets a GET /verify/:sessionId/:k proof anyone can recompute with @rain/rng-core.

If you need trust-minimised money (escrow, bond-backed misreport punishment, on-chain force-close) — you also need the channel house. Path to it: §8.

2. Architecture

 client (RainRngClient, unmodified)           ┌────────────────────────── your infra ──────────────────────────┐
   Terms ◄──────────────────────────────────   │  nginx / ALB (round-robin, retry on next upstream)             │
   Open  ──────────────────────────────────►   │      │                         │                              │
   Opened ◄────────────────────────────────    │  rain-rng-node #1           rain-rng-node #2   … #N          │
   Commit_k (decision, pRev_k) ────────────►   │      │  StatelessHouse             │  (identical, no local state)│
   Reveal_k (hRev_k) ◄─────────────────────    │      └──────────┬──────────────────┘                              │
   settle(): r_k = keccak(pRev,hRev,seed,sid,k)│           shared store = Postgres (HA)  or  file WAL (single)    │
                                               │           optional: RngAnchor.sol publisher (ethers)             │
 auditor / player: GET /verify/:sid/:k ────►   └────────────────────────────────────────────────────────────────┘
  • StatelessHouse — the ceremony rules over an RngStore; nothing session-scoped lives in process memory except a bounded cache of recomputed hash chains (deterministic from chainSecret, so any worker recomputes the same chain).
  • RngStore — FileStore (append-only JSONL, write+fsync per record, torn-tail tolerant), PgStore (pg optional dep; ordering + uniqueness enforced by a FOR UPDATE transaction and PRIMARY KEY (session_id, k)), MemoryStore (tests).
  • Transports — HTTP JSON and WebSocket envelopes, one handler. /verify, /healthz, /metrics always HTTP.
  • Why not RainRngHouse directly: it is an in-process state machine that cannot be rehydrated at cursor k from a store, which HA requires. StatelessHouse re-implements the same six checks on the same @rain/rng-core primitives and reuses sessionIdOf + wire types, so the client is byte-for-byte the same. (Upstream ask in docs/HANDOFF.md.)

3. The rule, made crash-safe

reveal(Commit c):
  1. session exists; k == cursor+1; k ≤ chainLen; keccak(c.pRev) == previous pRev (or playerChainRoot)
  2. store.appendCommit(sid, k, decision, pRev)        ← returns only after fsync / COMMIT       [PERSIST]
  3. hRev = hashChain(chainSecret)[k]                  ← deterministic                             [REVEAL]
  4. store.appendReveal(sid, k, hRev)                  ← bookkeeping, best-effort, idempotent
crash… what's in the store what happens on the client's retry (same Commit) can a second, different reveal for k ever exist?
before 2 completes (fsync not done) nothing for k (or a torn tail line, discarded on replay) fresh round: validated, persisted, revealed no — nothing was revealed
after 2, before the reply left commit k, no reveal record DuplicateCommitError → identical (decision, pRev) → same hRev_k served, x-rain-replayed: 1 no — hRev_k is a pure function of chainSecret; a different decision/pRev for the same k gets 409 conflict
after the reply commit + reveal same replay path no

Proved by test/crash.test.mjs: the node runs as a child process with RAIN_CRASH_AT=after-persist:5, is SIGKILLed mid-round 5, restarted on the same WAL, and the unmodified RainRngClient settles round 5 from the replayed reveal; then before-persist:9 shows a clean retry; then a hand-torn tail line is appended and ignored. The node refuses to start with RAIN_CRASH_AT set when NODE_ENV=production.

Two workers racing on the same commit (LB retry while worker A is still alive): Postgres PRIMARY KEY (session_id,k) + row lock → exactly one INSERT; the loser reads the winner's row and serves the identical hRev.

4. Threat model — read this honestly

What the node guarantees on its own (the RAIN properties from docs/RNG.md §1 that don't need a chain):

  • The house commitment (houseSeedCommit, houseChainRoot) is fixed before the client reveals anything → the house cannot pick or grind a seed after seeing the player's.
  • The reveal for round k is pinned by the hash chain → the house cannot choose which number to reveal.
  • The outcome depends on pRev_k, unknown to the house until the client has committed the decision → the house cannot see the outcome before the bet.
  • The client derives r_k itself; it never adopts a house-computed result.

What it cannot guarantee — when the same operator runs A (the game/money) and B (this node):

The remaining lever for a house is selective abort: receive Commit_k, compute r_k privately (it has hRev_k and now pRev_k), and if the outcome loses, "crash" and never reveal. Persist-before-reveal in the WAL means the operator's own log records that a commit was received — but the player has no independent way to see that log, and an operator can lose a disk. In the on-chain channel protocol this is a house fault paid from the bond (demandReveal → forceClose). Off-chain, with one party running both sides, the WAL is the operator's word.

Two remedies, pick one (or both):

  1. Anchoring (RAIN_ANCHOR=1) — recommended for any operator who runs its own node. The node publishes commit / bindPlayer / revealSeed to a deployed RngAnchor.sol at session open and batches submitReveals every RAIN_ANCHOR_EVERY rounds (both chains). A player can then check: my session's commitments were on-chain before I played; the reveals for rounds 1..k were published; and a session whose reveal cursor stops advancing while I hold an unanswered commit is publicly visible as a stall. That doesn't refund the player automatically (that needs the bond = the channel protocol), but it turns "the operator's word" into an on-chain record a regulator, an auditor or a dispute process can read. Anchoring is off the hot path — a reveal never waits for a transaction.
  2. Split A and B. Run the game (A), and let a RAIN Foundation node (or any third-party operator) be B — then the party who holds the money never sees hRev_k before the player's commitment is persisted by someone else. This is the "Foundation-run node" configuration; the same package runs it, the Foundation's HA is the Foundation's problem, and your only dependency is the public /verify proof.

What is not a remedy: trusting the /verify endpoint of the operator being audited. /verify is convenience for players (recompute in one click) and the licence's proof link; an auditor should recompute from the anchors + the client's own transcript (RainRngClient.fairness() gives every field).

Other items: the operator ed25519 key signs Terms and /verify proofs so a player can pin an operator and detect an impostor endpoint — it is not part of the randomness. Session secrets sit in the store in plain text; use disk encryption / Postgres TDE and restrict access (a leaked chainSecret lets the operator's staff predict hRev, which alone still cannot bias r_k without pRev_k, but it removes defence in depth). The attribution check is a courtesy, not DRM.

5. Deploy

Single node, file WAL

cd sdk-v2 && npm install && npm run build
RAIN_OPERATOR_ID=acme RAIN_DATA_DIR=/var/lib/rain RAIN_OPERATOR_KEY_FILE=/etc/rain/operator.key node packages/rng-node/bin/rain-rng-node.mjs
# or: docker compose -f packages/rng-node/docker-compose.yml up --build          (put a 32-byte hex seed in packages/rng-node/docker/operator.key)

HA: Postgres + 2 workers + nginx

npm i pg                                                                  # optional dep; the Dockerfile installs it
docker compose -f packages/rng-node/docker-compose.ha.yml up --build      # LB on :8080, node1/node2 behind it
bash packages/rng-node/scripts/ha-test.sh 300                             # kills node1 mid-run; asserts the client finished on node2

Any worker serves any session. Add workers by copying the node2 service. Postgres is the single point of truth — run it the way you run any production DB (replica + PITR; synchronous_commit=on is required, the compose file sets it).

Docker was not available in the environment where this package was written, so ha-test.sh has NOT been executed here. What was executed instead: the same HA semantics in-process — two startNode() workers with different nodeIds sharing one store instance, a client alternating between them per round, and /verify on worker A for rounds played on worker B (test/e2e.node.test.mjs, third test); plus the Postgres store's SQL and transaction logic reviewed but not run against a live server. First thing to do on a machine with Docker: run ha-test.sh, and report.

Config — JSON via --config overlaid by env (env wins). rain-rng-node --help lists every variable. Key ones: RAIN_OPERATOR_ID, RAIN_PORT, RAIN_HTTP/RAIN_WS, RAIN_DATA_DIR | RAIN_PG_DSN, RAIN_CHAIN_LEN (rounds per session; 4096 default, chain generation is ~ms), RAIN_OPERATOR_KEY(_FILE|_KMS), RAIN_ANCHOR*, RAIN_ATTRIBUTION_URL(_STRICT), RAIN_MIN_CHAIN_HEADROOM, RAIN_TIME_URL.

Endpoints

  • POST /v2/terms {meta?} → { termsId, terms, operatorId, operatorPubKey, sig } · POST /v2/open/:termsId (Open) → Opened · POST /v2/reveal (Commit) → Reveal (x-rain-replayed) · GET /v2/reveal/:sid/:k recovery
  • WS /v2/ws — {id, type: terms|open|reveal|verify, …}
  • GET /verify/:sid/:k — public, CORS *: session commitments, the commit, the reveal, recomputed.r, both chain checks, verifyRound, anchoring state, operator signature. This is the "Powered by RAIN RNG" proof link — put it next to every result.
  • GET /healthz — 503 if the store doesn't answer, min chain headroom over served sessions < threshold, clock skew vs RAIN_TIME_URL > max, or (strict) attribution missing.
  • GET /metrics — Prometheus: rain_rng_rounds_total, reveals_total, reveals_replayed_total, duplicate_commits_total, sessions_opened_total, errors_total, inflight_commits, round_latency_p50_ms/p95_ms, chain_remaining_min, store_up, store_latency_ms, clock_skew_ms.

Client side: new NodeClient({ baseUrl, transport: "http"|"ws", operatorPubKey? }) wraps an unmodified RainRngClient; open(meta) → round(decision, N). Or speak the JSON yourself with RainRngClient from @rain/rng-session.

6. Key management

  • Operator key (ed25519 seed): RAIN_OPERATOR_KEY (env) or RAIN_OPERATOR_KEY_FILE (Docker/K8s secret, chmod 600). Signs Terms and proofs. Publish the public key (GET /) on your fairness page so clients can pin it.
  • KMS hook: registerKms("awskms", async (uri) => signer) before startNode(); RAIN_OPERATOR_KMS=awskms:arn:… then resolves through your KmsSigner (publicKey(), sign(bytes)). Interface only — no vendor implementation ships here. The private key never enters the process. Suitable backends: AWS KMS (ed25519 not supported → use an ECDSA variant and change verifyEd25519 accordingly, or Vault Transit / YubiHSM which do ed25519).
  • Anchoring key (RAIN_ANCHOR_KEY): a plain EVM hot wallet with gas only. Separate from the operator key. Rotate freely — anchors are attributed by contract events, not by signer identity.
  • Session secrets: generated per session with randomBytes32() (Web Crypto), stored in the WAL/DB. Not rotated — a session is its commitment. Chain exhaustion (chain_exhausted, or /healthz headroom) means: open a new session.

7. Ops runbook

Backup the WAL — wal.jsonl is append-only: rsync --append / object-storage sync on a schedule is enough; take a copy after every deploy. Restoring from a backup that is older than the live file is the one dangerous operation: the node would have lost commits it already revealed against → a client retrying k gets order/bad_prev and must reopen; never run two nodes on two divergent copies of the same file WAL. With Postgres: standard PITR; rng_commit is the table that matters.

Rotate the operator key — start new workers with the new key, publish both public keys on the fairness page for the overlap, retire the old. Old /verify proofs signed with the old key remain valid against the old public key (keep it published, marked retired). Anchoring is unaffected.

Upgrade — workers are stateless: rolling restart behind the LB. The WAL format is versioned by record type (t); adding record types is backward compatible. Downgrading a worker to a version that does not know a record type will refuse to replay — keep the previous image handy. Postgres schema changes ship as CREATE … IF NOT EXISTS in PG_SCHEMA.

Alerts — rain_rng_store_up == 0 (page), increase(rain_rng_duplicate_commits_total[5m]) > 0 (a client or LB is retrying with mutated commits — investigate, could be an attack or a buggy client), rain_rng_round_latency_p95_ms > 50 (store fsync latency), rain_rng_chain_remaining_min < 64 (sessions about to exhaust; clients should reopen), rain_rng_reveals_replayed_total rising steadily (workers crashing or LB flapping).

Incident: a persisted commit with no reveal record after a crash — logged at startup (N persisted commit(s) without a reveal record). Nothing to do: the reveal is servable on request (GET /v2/reveal/:sid/:k); the client will retry. If the client abandoned the round, k is burned on the client side (RainRngClient.abort()) and that session is desynced → the client reopens. Expected, safe.

8. Path to the full channel house node (the money half)

Not in this pass. What exists: packages/channels/test/mock-house.mjs speaks the real wire protocol (terms / open_request / hello / quick_bet / ev2_bet / step_switch / close) with the real vendored rules engines, enforces persist-before-reveal and is exercised e2e by ChannelSession.act(). What it lacks to be operator-runnable — in the order they should be built, each one reusing this package's pieces:

  1. Store + HA skeleton — reuse RngStore/FileStore/PgStore, add channel / state record types (dual-signed ChannelState at every nonce is the WAL: the last dual-signed state IS the settlement claim).
  2. On-chain open — house submits openChannel on ChannelManagerAA (AA sponsorship for smart-account players), waits for inclusion, then answers open_result (mock-house fakes openTx: "0xmock").
  3. Bond + allocation accounting — HouseBond deposit, per-channel allocation ≤ free bond, maxUnitStake policy; refuse opens beyond the bond.
  4. Closes — cooperative close (closeChannel with both sigs), watchtower for forceClose challenges (the @rain/channels fraud helpers already produce the proofs), demandReveal responder.
  5. Multi-tenant brands / operators — operator per deployment (registerDeployment()), one node serving several.
  6. Everything in §4 becomes a bond-enforced guarantee instead of an on-chain record: selective abort = house fault = paid from bond.

Until then, the honest configuration for real money is: players on the live RAIN houses (playmarkets.bet / maycasino.xyz) via @rain/channels, and this node for anyone who wants RAIN randomness in their own product with their own payouts.


Licence: MIT + attribution — the node stamps poweredBy: "RAIN RNG" and the fairness URL into every Terms meta and every /verify proof; your product must still display the badge (badge() in @rain/rng-core).

← 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.