DOCS/HOUSE-NODE — RUNNING THE COUNTERPARTY
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 anRngStore; nothing session-scoped lives in process memory except a bounded cache of recomputed hash chains (deterministic fromchainSecret, so any worker recomputes the same chain).RngStore—FileStore(append-only JSONL,write+fsyncper record, torn-tail tolerant),PgStore(pgoptional dep; ordering + uniqueness enforced by aFOR UPDATEtransaction andPRIMARY KEY (session_id, k)),MemoryStore(tests).- Transports — HTTP JSON and WebSocket envelopes, one handler.
/verify,/healthz,/metricsalways HTTP. - Why not
RainRngHousedirectly: it is an in-process state machine that cannot be rehydrated at cursorkfrom a store, which HA requires.StatelessHousere-implements the same six checks on the same@rain/rng-coreprimitives and reusessessionIdOf+ wire types, so the client is byte-for-byte the same. (Upstream ask indocs/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
kis 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_kitself; 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):
- Anchoring (
RAIN_ANCHOR=1) — recommended for any operator who runs its own node. The node publishescommit / bindPlayer / revealSeedto a deployedRngAnchor.solat session open and batchessubmitRevealseveryRAIN_ANCHOR_EVERYrounds (both chains). A player can then check: my session's commitments were on-chain before I played; the reveals for rounds1..kwere 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. - 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_kbefore 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/verifyproof.
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/:krecoveryWS /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 vsRAIN_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) orRAIN_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)beforestartNode();RAIN_OPERATOR_KMS=awskms:arn:…then resolves through yourKmsSigner(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 changeverifyEd25519accordingly, 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/healthzheadroom) 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:
- Store + HA skeleton — reuse
RngStore/FileStore/PgStore, addchannel/staterecord types (dual-signedChannelStateat every nonce is the WAL: the last dual-signed state IS the settlement claim). - On-chain open — house submits
openChannelonChannelManagerAA(AA sponsorship for smart-account players), waits for inclusion, then answersopen_result(mock-housefakesopenTx: "0xmock"). - Bond + allocation accounting —
HouseBonddeposit, per-channelallocation≤ free bond,maxUnitStakepolicy; refuse opens beyond the bond. - Closes — cooperative close (
closeChannelwith both sigs), watchtower forforceClosechallenges (the@rain/channelsfraud helpers already produce the proofs),demandRevealresponder. - Multi-tenant brands / operators —
operatorper deployment (registerDeployment()), one node serving several. - 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.