Provably Fair

This page documents the exact scheme (algorithm version v2) used to commit and verify deck order in rip events. It is written for people who want to check our work.

Commitment (before the event starts)

  1. Each card slot in the deck has a unique ID paired with the exact card in it. Each slot is written as slotId:sku, the pairs are sorted, joined with |, and the integrity hash is computed as SHA-256(slot1|slot2|…|slotN). Because the card identity is bound in — not just the slot — swapping the card behind a slot after commitment changes the hash and is detectable.
  2. A random 256-bit seed is generated from a cryptographic RNG and kept secret while the event runs.
  3. The deck hash is computed as SHA-256(integrityHash + ":" + seed) and published on the event page before the first pull.
  4. The deck is shuffled with a Fisher–Yates shuffle driven by a SHA-256 counter stream over the full seed (each draw hashes seed:counter). Using the whole 256-bit seed — not a 32-bit slice — is what makes the live order infeasible to brute-force from public pulls. The resulting order is frozen in the database.

Dealing

Each pack opening deals the next unpulled card in the frozen sequence, in a single atomic database operation. Pull order is recorded and the most recent pulls are shown publicly on the event page.

Reveal & verification (after the event ends)

Once an event has ended, the seed is revealed on the event data. Every event also has its own fairness page, linked from the event itself, listing the sealed deck in sequence order alongside the order cards were actually pulled in. To verify an event:

  1. Recompute SHA-256(integrityHash + ":" + seed) and confirm it equals the deck hash that was published while the event was live. This proves the seed was fixed before the first pull.
  2. Re-run the shuffle with the seeded SHA-256 stream below over the sorted slot IDs. The resulting order must match the observed pull order.

Reference implementation

// Deterministic RNG keyed on the FULL seed. Each draw hashes
// `seed:counter` with SHA-256 and reads 6 bytes as a float in [0,1).
async function makeSeededRng(seedHex) {
  let counter = 0;
  const enc = new TextEncoder();
  return async function next() {
    const buf = await crypto.subtle.digest(
      "SHA-256", enc.encode(seedHex + ":" + counter++));
    const h = new Uint8Array(buf);
    let v = 0;
    for (let i = 0; i < 6; i++) v = v * 256 + h[i]; // 48-bit int
    return v / 0x1000000000000;                      // -> [0,1)
  };
}

async function seededShuffle(ids, seedHex) {
  const rng = await makeSeededRng(seedHex);
  const a = ids.slice();
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor((await rng()) * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}
Because the deck hash binds both the card set and the seed, changing either one after publication would change the hash — any tampering is detectable by anyone who saved the commitment while the event was live.