Generation, seeding events, and provably-fair consumption — how it works under the hood.
Each hash is SHA-256 of the previous one, starting from a random UUID seed, repeated N times. Verifiable forwards, impossible to compute backwards.
The randomUUID() that starts the chain. Never persisted in this implementation — see Known Problems.
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.
A public pastebin post revealing the round-1 hash and announcing which block becomes the next client seed.
Hashes stored AES-256-CBC encrypted in Postgres. Only services with CIPHER_KEY + CIPHER_IV can read them.
Provably-fair result derivation
Verify: revealed hash + published client seed → HMAC → compare with recorded result.
| File | Responsibility |
|---|---|
multi-player-hash.controller.ts | Admin endpoint — guards, conflict check, fires generation as fire-and-forget |
multi-player-hash-generator.service.ts | Singleton lock (one game at a time), graceful-shutdown awareness, timing logs |
generate-hashes.transaction.ts | Core generation loop — paged inserts, reverse-index assignment |
multi-player-hash.service.ts | Encrypt/decrypt, findNextHash, CRUD via RepositoryService |
consume-hash.transaction.ts | Marks one hash used=true and returns it |
multi-player-hash.entity.ts | DB schema — composite index on (used, gameId, index) |
rng.ts → createHashes() | SHA-256 chain generator — yields pages to keep memory bounded |
Generate: Admin API → MultiPlayerHashGeneratorService → GenerateHashesTransaction.
Consume: Game Engine → ConsumeHashTransaction → MultiPlayerHashService → Postgres.
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.
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.
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.
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.)
// 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 order | DB index | Consumed | Round # |
|---|---|---|---|
| 1st generated | 4 (highest) | Last | Round 5 — never reveal |
| 2nd | 3 | 4th | Round 4 |
| 3rd | 2 | 3rd | Round 3 |
| 4th | 1 | 2nd | Round 2 |
| 5th (last) | 0 (lowest) | First | Round 1 — reveal this |
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.
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.
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.
findNextHash throws InternalServerException (MP_HASH_ALL_USED) and the game is unplayable. Monitor hash count and generate well ahead.
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).
CIPHER_IV and CIPHER_KEY are visible only to AWS admins. You need them for the decrypt script and to verify revealed hashes.
The seeding event is the public commitment that lets any player verify past results. Flow:
The post commits to the round-1 hash (lowest index) and the block that will set the client seed.
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 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.)
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).
blockHash field. The number is just how you find it. The full hex (with or without 0x — be consistent) becomes clientSeed.
| Blocks ahead | ~Time | Suitable? |
|---|---|---|
| 10 | ~2 min | Too close — may mine before you publish |
| 100+ | ~20 min+ | Good — enough time to publish the post before the block is mined |
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(...)
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.
CIPHER_IV+CIPHER_KEY (AWS vault) · Pastebin creds (1Pass Admin vault) · global-config + redeploy access
Hide from UI so players aren't stuck waiting.
POST admin-api/multi-player-hashes/generate with { game, hashNumber: 5000000 }. Returns 201; runs in background (few minutes).
Query WHERE gameId='crash' AND used=false ORDER BY index ASC LIMIT 1, then run the decrypt script below.
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.
Set clientSeed to the block hash once mined, then redeploy.
Confirm seed is live in prod and the Pastebin hash matches the DB.
Unhide from UI. Done.
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');
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.
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.)
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.
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).
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.