Multi-Player Hash

Generation, seeding events, and provably-fair consumption — how it works under the hood.

Core Concepts

🔗

Hash Chain

Each hash is SHA-256 of the previous one, starting from a random UUID seed, repeated N times. Verifiable forwards, impossible to compute backwards.

🌱

Server Seed

The randomUUID() that starts the chain. Never persisted in this implementation — see Known Problems.

🎲

Client Seed

HMAC key that derives game results from each hash. Set to the hash of a future un-mined block — publicly verifiable, unpredictable at generation time.

📢

Seeding Event

A public pastebin post revealing the round-1 hash and announcing which block becomes the next client seed.

🔒

Encrypted Storage

Hashes stored AES-256-CBC encrypted in Postgres. Only services with CIPHER_KEY + CIPHER_IV can read them.

Provably-fair result derivation

Client Seed
(ETH block hash)
+
Game Hash
(from chain)
HMAC-SHA256
Game Result

Verify: revealed hash + published client seed → HMAC → compare with recorded result.

Architecture

FileResponsibility
multi-player-hash.controller.tsAdmin endpoint — guards, conflict check, fires generation as fire-and-forget
multi-player-hash-generator.service.tsSingleton lock (one game at a time), graceful-shutdown awareness, timing logs
generate-hashes.transaction.tsCore generation loop — paged inserts, reverse-index assignment
multi-player-hash.service.tsEncrypt/decrypt, findNextHash, CRUD via RepositoryService
consume-hash.transaction.tsMarks one hash used=true and returns it
multi-player-hash.entity.tsDB schema — composite index on (used, gameId, index)
rng.ts → createHashes()SHA-256 chain generator — yields pages to keep memory bounded

Generate: Admin API → MultiPlayerHashGeneratorServiceGenerateHashesTransaction. Consume: Game Engine → ConsumeHashTransactionMultiPlayerHashService → Postgres.

Hash Generation

Triggered by a SUPER_ADMIN via the admin API. The controller fires a floating promise — HTTP returns 201 immediately while work runs in the background. A production batch is 5M hashes (~1.5–2 years of play) and now finishes in a few minutes.

⚠️ One game at a time Generating while another game is generating returns 409 Conflict (MP_HASH_GENERATE_IN_PROGRESS). Check getGenerating() first.

The generator (rng.ts → createHashes) starts from SHA-256(uuid) and repeatedly hashes the previous value, yielding pages of 10,000 to keep memory bounded. For 5M that's 500 inserts, all inside a single GenerateHashesTransaction — so a partial run rolls back to zero new hashes; just re-run it.

Index & Ordering — The Critical Detail

🚨 Hashes are consumed backwards The hash generated last gets DB index 0 and is consumed first (round 1) — this is the one you reveal in the seeding event. The first-generated hash gets the highest index and is consumed last. Generation order and consumption order are mirror images.

Why reversed?

SHA-256 is one-directional. By consuming the last-generated hash first, each round's hash is the SHA-256 pre-image of the previous round's. A player verifies round k by hashing it once and checking it equals round k-1's published hash — chaining back to the revealed round-1 anchor. Future hashes stay hidden because you can't run SHA-256 in reverse. (Consuming in generation order would let anyone hash the first revealed hash forward to reproduce every future result — see Gotchas in Known Problems.)

Index assignment formula

// generate-hashes.transaction.ts
const hashes = hashPage.map((hash, i) => ({
  index: firstAvailableIndex
    + (numberOfHashes - 1)                  // max offset
    - (currentPage * resolvedPageSize + i),  // minus generation position
}));

For a fresh batch of 5 hashes (firstAvailableIndex = 0):

Generation orderDB indexConsumedRound #
1st generated4 (highest)LastRound 5 — never reveal
2nd34thRound 4
3rd23rdRound 3
4th12ndRound 2
5th (last)0 (lowest)FirstRound 1 — reveal this
idx 0
8ea9a675
REVEAL
(round 1)
idx 1
c3ab8ff1
← NEXT
idx 2
7f83b165
available
idx 3
2e99758
available
idx 4
ba7816bf
NEVER
(round 5)

Appending to an existing batch

Generating on top of a non-exhausted batch continues from the current highest unused index + 1. Old and new hashes share one consumption queue; old ones are consumed first.

Hash Consumption

Each round, ConsumeHashTransaction calls findNextHash to select the lowest-index unused hash (WHERE gameId=? AND used=false ORDER BY index ASC LIMIT 1), then marks it used=true and returns it for the caller to decrypt and feed into HMAC. The composite index (used, gameId, index) keeps this fast at 5M rows.

⚠️ The select and mark are not a single atomic step findNextHash runs the SELECT outside the transaction (no manager, no FOR UPDATE/SKIP LOCKED); only the used=true UPDATE is transactional. Concurrent consumers could read the same lowest-index hash. This is safe today only because each game runs rounds sequentially in a single loop.
🚨 All hashes exhausted If no rows remain, findNextHash throws InternalServerException (MP_HASH_ALL_USED) and the game is unplayable. Monitor hash count and generate well ahead.

Encryption

Hashes are stored AES-256-CBC encrypted so direct DB access can't leak future results, using the CIPHER_KEY (32-byte hex) and CIPHER_IV (16-byte hex) env vars.

The key/IV pair is fixed and shared across all three multiplayer games and every batch, so encryption is deterministic — the UNIQUE constraint on encryptedHash also rejects duplicate hashes. This key is purely for at-rest encryption; it is not the provably-fair server seed (that's the per-batch random UUID — see Known Problems).

⚠️ Secrets are AWS-vault only CIPHER_IV and CIPHER_KEY are visible only to AWS admins. You need them for the decrypt script and to verify revealed hashes.

Seeding Events & Provably Fair

The seeding event is the public commitment that lets any player verify past results. Flow:

Generate
5M hashes
Decrypt
index 0
Post to
Pastebin
Update
client seed
Enable
games

The post commits to the round-1 hash (lowest index) and the block that will set the client seed.

Example pastebin post

Moonspin.com

Seeding Event for Crash game!
Our 5 millionth hash is 8ea9a675098f2d028edfd5d51ad76c984d3d77970aa144e0752522f82eb5c7a7.
The client seed will be the hash of the un-mined Ethereum block #18733310.

"5 millionth" is the hash's generation position (last of 5M produced), not its round number. It is the round-1 hash.

The Ethereum block as client seed

Ethereum blocks and Swivel's hashes are unrelated. Swivel runs its own SHA-256 chain in Postgres. An Ethereum block hash is used only as a source of public randomness for the client seed; the two never structurally interact.

The client seed must be (1) unknown at generation time, (2) publicly verifiable afterward, and (3) outside Swivel's control. A future Ethereum block hash satisfies all three: every ~12s a validator produces a new block with a unique 32-byte hash, unpredictable until mined, then permanently public. At seeding time the target block doesn't exist yet. (Any public randomness source — Bitcoin, a lottery — would also work; Ethereum is just the chosen one.)

NOW
#18733100 head
~12s/block
PENDING
#18733310 ~42 min
MINED
hash → client seed

Finding it on Etherscan

Look up blocks and their hashes on etherscan.io — the latest block number is in the header, and etherscan.io/block/<number> shows the "Block Hash" field once mined (404 while still pending).

⚠️ Use the block hash, not the number "Hash of block #18733310" means the blockHash field. The number is just how you find it. The full hex (with or without 0x — be consistent) becomes clientSeed.

How far ahead to pick

Blocks ahead~TimeSuitable?
10~2 minToo close — may mine before you publish
100+~20 min+Good — enough time to publish the post before the block is mined

Where clientSeed lives

ℹ️ Not in the multi-player-hash module. That module only stores/retrieves hashes. The seed lives in GlobalConfig and is wired up only in the engine game services. Changing it requires a redeploy (procedure step 5).
// apps/engine/src/games/crash/services/game.service.ts
const nextHash = await this.consumeHashTransaction.run(this.gameId);
this.hash = { id: nextHash.id, hash: this.hashService.decrypt(nextHash.encryptedHash) };

this.multiplier = this.getRoundMultiplier(
  this.hash.hash, houseEdgePercentage,
  this.gameConfig.clientSeed  // ← from GlobalConfig.games.crash.clientSeed
);
// inside: const seed = createHmac(clientSeed, hash); → getCrashMultiplier(...)

Verifying results

const crypto = require('crypto');
const round1Hash = '8ea9a675...';  // the revealed hash = round 1
const clientSeed = '4d9b4a97...'; // the mined ETH block hash

// Round 1: same HMAC the engine runs
const seed1 = crypto.createHmac('sha256', clientSeed).update(round1Hash).digest('hex');

// Round 2: prove it chains back — SHA-256(round2) must equal round1Hash
const link = crypto.createHash('sha256').update(round2Hash).digest('hex');
console.log(link === round1Hash);  // must be true

In-product, the "check hash" link on each multiplayer game's provably-fair modal opens a CodeSandbox prefilled with the round's seed and hash (e.g. https://qwqymg.csb.app/?seed=…&hash=…), so players can run the same verification without writing code.

ℹ️ Verify back toward the anchor, never forward. SHA-256 is one-way: the revealed round-1 hash reveals nothing about future rounds. Each later round is checked by hashing it once and confirming it equals the previous round's known hash — even a fully compromised backend can't retroactively fake past results.
⚠️ We switch the seed before the old chain drains The new chain is generated and the client seed switched while the old chain still has thousands of unused hashes — those play under the new seed, breaking the fairness chain. The most consequential flaw; see Known Problems #1.

Step-by-Step Procedure

Prerequisites SUPER_ADMIN admin-API access · DB read access · CIPHER_IV+CIPHER_KEY (AWS vault) · Pastebin creds (1Pass Admin vault) · global-config + redeploy access
  1. Disable multiplayer games

    Hide from UI so players aren't stuck waiting.

  2. Generate hashes

    POST admin-api/multi-player-hashes/generate with { game, hashNumber: 5000000 }. Returns 201; runs in background (few minutes).

  3. Decrypt index-0 hash

    Query WHERE gameId='crash' AND used=false ORDER BY index ASC LIMIT 1, then run the decrypt script below.

  4. Post seeding event to Pastebin

    Post the revealed hash + target future block number. Reuse an existing Moonspin/Sidepot post as template. Ideally also publish a public blog post — Pastebin isn't discoverable, so a blog post (or in-product page) makes the commitment findable by players.

  5. Update client seed + redeploy

    Set clientSeed to the block hash once mined, then redeploy.

  6. Verify

    Confirm seed is live in prod and the Pastebin hash matches the DB.

  7. Enable multiplayer games

    Unhide from UI. Done.

Decrypt Script

Run locally with secrets from the AWS vault. Pass the encryptedHash of the lowest-index row per game.

// decrypt-hashes.js — run: node decrypt-hashes.js
const crypto = require('crypto');
const IV  = 'XXXX';  // CIPHER_IV  (hex)
const KEY = 'XXXX';  // CIPHER_KEY (hex)

function decrypt(label, encryptedHash) {
  const d = crypto.createDecipheriv('aes-256-cbc',
    Buffer.from(KEY, 'hex'), Buffer.from(IV, 'hex'));
  console.log(label, d.update(encryptedHash, 'hex', 'utf-8') + d.final('utf-8'));
}

// encryptedHash WHERE gameId=? AND used=false ORDER BY index ASC LIMIT 1
decrypt('CRASH', 'XXX');
decrypt('WMB',   'XXX');
decrypt('CHART', 'XXX');

Known Problems — Why This Isn't Fully Provably Fair

🚨 As operated today, the ceremony does not deliver a complete provably-fair guarantee. The pieces exist (hash chain, public client seed, seeding post), but the rotation breaks the chain and there's no usable server-seed reveal. These are structural, not cosmetic.

1. Old chain consumed under the new client seed

We don't wait for a chain to exhaust before rotating. The seed is switched while thousands of old hashes remain — they were committed to the old seed, so replaying them under the new one breaks reproducibility for thousands of rounds, detectable by anyone who kept the original post.

Old hashes
(old seed announced)
→ seed switched early →
Broken window
(old hashes + new seed)
New hashes
(new seed)

2. The server seed is never persisted, so it can't be revealed

A proper scheme reveals the server seed once a chain exhausts, so anyone can regenerate the whole chain and confirm nothing was cherry-picked. We can't: the chain's initial randomUUID() is generated inline in generate-hashes.transaction.ts, used once, and discarded — it's gone the moment generation finishes. There's no per-chain secret left to reveal, so the "reveal then rotate" step is impossible. (The AES CIPHER_KEY/CIPHER_IV is unrelated — it's only at-rest encryption of the stored hashes, not the provably-fair seed.)

3. The three games never deplete in sync

Crash, WhenMoonBro, and Chart burn hashes at different rates, so they're never all exhausted together. With one shared secret and one combined rotation, there's no clean moment to retire a chain — so we rotate early and globally, which triggers problem 1.

4. The commitment isn't published immutably or discoverably

Pastebin can be edited or deleted after the fact, and it isn't discoverable — players have no reliable way to find the post. A seeding event should ideally be accompanied by a public blog post (or in-product page) so the commitment is findable, and it must be tamper-evident before the first round of the new chain runs (ideally anchored on-chain).

What "doing it properly" would require
  • Persist a per-chain, per-game server seed; publish its commitment before any round runs.
  • Rotate the client seed only at a chain boundary — never replay old hashes under a new seed.
  • Persist that per-chain server seed and reveal it once the chain is fully consumed, then start fresh.

Generated from apps/backend (admin multi-player-hash controller, libs/common/src/multi-player-hash/, libs/common/src/rng/, engine game services) and the operational runbook.