# Provably fair randomness

RocketDFS commits to every random choice it makes on your behalf **before** the
outcome exists, and publishes the seed afterwards. This document is the whole
specification: enough to re-derive any result yourself, without our cooperation
and without trusting this site.

Public verifier: [`/verify`](/verify) — paste a contest ID.
Public API: `GET /api/v1/fairness?contestId=<uuid>` (unauthenticated).

---

## 1. The scheme

| Step | What the platform does | What it proves |
|---|---|---|
| **Commit** | Draws a 32-byte seed from `crypto.randomBytes`, stores it sealed, publishes `sha256(seed)`. | The platform is pinned to exactly one seed. SHA-256 is preimage-resistant, so the published hash reveals nothing about the seed — and cannot be changed later without the change being obvious. |
| **Reveal** | Once the outcome can no longer be gamed (entries closed, card dealt), publishes the seed and the outcome it produced. | Anyone can check `sha256(revealedSeed) == committedHash`. A platform that swapped the seed after seeing who bought what would fail this check. |
| **Verify** | — | Re-run the algorithm below on the revealed seed. If you get the same outcome, the draw was not tampered with. |

Both timestamps are published. `committedAt` strictly before `revealedAt` is
what makes the scheme meaningful; the API reports `preCommitted: false` for any
draw whose commitment was minted at draw time (defined as a lead of under one
second), because such a record proves only that the seed was not swapped
*afterwards* — never that the operator was constrained *in advance*. We would
rather label a weak proof than let it pass as a strong one.

---

## 2. The algorithm — `lcg32-fisher-yates-v1`

Every commitment record carries an `algorithm` field. Today there is exactly one
value, `lcg32-fisher-yates-v1`, defined as follows. Source of truth:
`src/lib/fairness/index.ts`.

### 2.1 Seed → generator

A 32-bit linear congruential generator, seeded from the first 8 hex characters
of the seed:

```js
function createSeededRng(seed) {
  let state = parseInt(seed.slice(0, 8), 16);
  return () => {
    state = (state * 1103515245 + 12345) & 0x7fffffff;
    return state / 0x7fffffff;
  };
}
```

One generator drives one continuous stream. A draw that shuffles twice (Block
Pools: rows, then columns) consumes the *same* stream for both — it does not
restart.

The LCG is not a cryptographic PRNG and does not need to be. Its output is
public and fully determined by a seed that is 256 bits of `crypto.randomBytes`
and was committed to before the outcome existed. What matters is that the map
seed → outcome is deterministic, published, and reproducible off-platform.

### 2.2 Shuffle

Fisher–Yates, descending index, `Math.floor(rng() * (i + 1))`:

```js
function fisherYates(items, rng) {
  const arr = [...items];
  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(rng() * (i + 1));
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
}
```

### 2.3 Legacy seeds

If the first 8 characters of the seed are not valid hex, `parseInt` yields
`NaN` and the state falls back to a `djb2`-style 32-bit string hash
(`hash = (hash << 5) - hash + charCode`, then `Math.abs`). Every seed minted by
the commit path is 64 hex characters, so this branch is unreachable for any
committed draw; it exists only so pre-existing callers that passed opaque
labels keep producing what they always produced.

---

## 3. Per-game derivations

### 3.1 Block Pools — `block_pools_grid`

Entity: the **contest**. Committed when the contest is created (in the same
transaction as the contest row, so a Block Pools contest cannot exist without a
published commitment); revealed when the contest locks and the grid digits are
drawn.

Contests that predate the feature, or that were created by a migration seed
phase rather than an API, are picked up by a self-healing sweep in the
lock-contests cron (`src/lib/blockpools/precommit.ts`), which runs before the
lock pass on every tick. Anything still missing a commitment when its grid is
drawn gets one at draw time and is reported `preCommitted: false`.

```js
const rng = createSeededRng(seed);
const digits = [0,1,2,3,4,5,6,7,8,9];
const rowNumbers = fisherYates(digits, rng);   // consumes the stream first
const colNumbers = fisherYates(digits, rng);   // continues the same stream
```

Published outcome: `{ rowNumbers, colNumbers }`.

`rowNumbers[r]` is the home-score last digit for grid row `r`;
`colNumbers[c]` is the away-score last digit for column `c`. A square's index is
`row * 10 + col`.

### 3.2 Fantasy Bingo — `fantasy_bingo_card`

Entity: the **entry** (one commitment per card). Committed and revealed together
at purchase, because the card is handed straight to its buyer — so `preCommitted`
is `false` for these by construction, and the proof they carry is "this seed,
which we published, produced this card".

The card is built from two shuffles driven by **one** generator — the same
single-stream shape as the Block Pools grid. Both are recorded as *permutations
of indices* rather than of props, which keeps verification independent of
whichever players and teams were in the pool that week:

```js
const rng = createSeededRng(seed);
const indices = (n) => Array.from({ length: n }, (_, i) => i);

templateOrder  = fisherYates(indices(templateCount),  rng);  // consumes the stream first
placementOrder = fisherYates(indices(placementCount), rng);  // continues the same stream
```

Fisher–Yates consumes exactly `length - 1` draws whatever it is shuffling, so
the placement shuffle always starts from the same stream position.

Published outcome: `{ templateCount, placementCount, templateOrder, placementOrder }`.

`templateOrder` is the order the prop template catalogue was shuffled into
before the per-difficulty quota (8 easy / 10 medium / 6 hard) was taken off the
top. `placementOrder` is the order those 24 selected props were laid onto the
5×5 grid, skipping the centre FREE square.

### 3.3 Games with nothing to verify

A game whose results follow only from the box score makes no random choice and
carries no commitment. **Draft Baron is one of these**: its draft order is the
real NFL slot order, maintained in `draft_baron_draft_order` and overlaid with
recorded picks. A fairness badge there would be decoration, so it does not get
one. `GET /api/v1/fairness` reports `expectsDraw: false` for such contests
rather than returning an ambiguous empty list.

---

## 4. Verifying by hand

```js
const { createHash } = require('crypto');

const seed = '…64 hex characters, from /api/v1/fairness…';
const committedHash = '…64 hex characters, from the same response…';

// Step 1 — the seed is the one that was committed to.
console.log(createHash('sha256').update(seed).digest('hex') === committedHash);

// Step 2 — the outcome follows from that seed.
let state = parseInt(seed.slice(0, 8), 16);
const rng = () => { state = (state * 1103515245 + 12345) & 0x7fffffff; return state / 0x7fffffff; };
const shuffle = (items) => {
  const a = [...items];
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(rng() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
};
const digits = [0,1,2,3,4,5,6,7,8,9];
console.log({ rowNumbers: shuffle(digits), colNumbers: shuffle(digits) });
```

Both lines must agree with what `/verify` shows. If either disagrees, that is a
bug or worse, and it is visible to anyone who looks — which is the point.

---

## 5. Storage

One table, `fairness_commitments` (migration phase 067), holds every recorded
draw:

| Column | Meaning |
|---|---|
| `entity_type`, `entity_id`, `purpose` | Natural key. A contest-level draw keys on the contest; a per-entry draw keys on the entry. `purpose` lets one entity carry several independent draws. |
| `seed_hash` | Published at commit. |
| `seed` | Written at commit, withheld from every read surface until `revealed_at` is set. |
| `outcome` | The derived result, published at reveal. |
| `committed_at`, `revealed_at` | The evidence for `preCommitted`. |
| `contest_id` | Lookup key for `/verify`. |

Two properties are deliberate:

- **A unique index on the natural key.** A retry, a concurrent request, or a
  re-run of an admin action can never re-roll a live seed — so the commit path
  cannot be used to shop for a favourable draw.
- **No foreign key to `contests`.** This is an audit trail whose value is that
  it outlives the thing it describes. An `ON DELETE CASCADE` would let deleting
  a contest erase the proof its draw was honest — exactly the accusation the
  table exists to answer.

Phase 067 creates the table and nothing else. Minting seeds needs Node's
`crypto`, and `migrations.ts` is reachable from the Edge middleware bundle
(middleware → auth.ts → auth.config.ts → db/index.ts → migrations.ts), where a
Node crypto import fails the build outright. Backfilling therefore lives in the
cron sweep described in §3.1, which is better regardless: it runs continuously
instead of once, so it also covers contests that later seed phases create.

---

## 6. Adding a game

1. Add a `purpose` to `FAIRNESS_PURPOSE` in `src/lib/fairness/store.ts`.
2. Add its derivation to `recomputeOutcome` in `src/lib/fairness/verify.ts`.
3. Commit at the point the entity is created, reveal at the point the draw is
   made, using `commitFairnessSeed` / `revealFairnessOutcome` (or
   `commitAndRevealFairness` when the two coincide).
4. For a contest-level draw, register the game type in `CONTEST_LEVEL_FAIRNESS`
   so the badge and `expectsDraw` pick it up.
5. Document the derivation in section 3 above.

A purpose with no registered derivation still gets the seed↔hash check and is
reported `commitment_only` — never a false pass.
