# PackLab Fairness Specification

This is the complete technical specification of PackLab's provably-fair system: every
formula, byte order, and check needed to verify any box, Evolve, or Mystery Pack open
yourself — or to build your own independent verifier. The in-browser verifier at
`/verify` implements exactly what is written here, and the test vectors at the bottom
let you validate an implementation without trusting ours.

Scope: boxes (pack rips), Evolve, and Mystery Pack opens. Everything here is enforced
in code; nothing is aspirational. Where the system has an honest limit, it is stated
plainly in section 8.

---

## 1. Primitives

- **Hash:** SHA-256. `sha256hex(s)` is the lowercase hex digest of the UTF-8 bytes of `s`.
- **MAC:** HMAC-SHA256. `hmac(keyBytes, msg)` uses the raw bytes of the hex-decoded key
  and the UTF-8 bytes of `msg`; output is 32 bytes.
- **Randomness beacon:** [drand](https://drand.love) **quicknet**
  (chain hash `52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971`),
  3-second rounds, run by independent organizations worldwide. A round's value is
  its published `randomness` field (hex). Round *r* publishes at
  `genesis + (r − 1) × 3s`; PackLab always commits to a round in the **future**, so
  its value cannot exist at commit time.
- **Beacon reference:** the string `drand:quicknet:<round>`.
- **Server seed:** 32 random bytes, hex-encoded (64 chars), generated server-side per
  box / per Evolve / per Mystery open, kept secret until reveal.

## 2. Commitments

Before anything is decided, PackLab publishes:

- **Seed commitment:** `seedCommitment = sha256hex(serverSeed)`.
- **Box manifest commitment (boxes only):** the box's full contents — every card in
  canonical order, with name, rarity, finish, snapshot value, plus `shuffleMode`,
  `packKinds` (God/Demigod markers), and (manifest v2) the `beaconRef` — serialized
  canonically (stable key order, no whitespace) with a secret `nonce`, then
  `manifestCommitment = sha256hex(canonicalJson)`. The nonce is revealed with the
  manifest, so the commitment is checkable but contents aren't brute-forceable early.
  Because `beaconRef` is inside the hashed manifest, swapping the round after commit
  breaks the public hash in every browser.
- **Mystery odds-table commitment (Mystery only):**
  `weightTableHash = sha256hex(JSON.stringify(weightTable))` where `weightTable` is
  the array `[{ "setId": string, "weight": integer }, …]` sorted by `setId`
  (ascending, string compare) — the exact odds the draw will use, fixed before the
  roll exists.

## 3. The final seed and the shuffle (boxes)

When the committed drand round publishes with value `beaconValue`:

```
finalSeed = HMAC-SHA256(hexDecode(serverSeed), utf8(beaconValue))   // 32 bytes
```

The shuffle is a Fisher-Yates permutation driven by a deterministic stream derived
from `finalSeed`, as implemented in the published `shuffledOrder(finalSeed, n)`:
counter-mode HMAC blocks expand the seed into uniform indexes; the result
`order` is a permutation of `0..n−1`.

- **Pack mode (`shuffleMode: "packs"`, all current boxes):** the permutation is over
  **pack order**. `order[drawPos] = canonicalPack` — the buyer who claims draw
  position `drawPos` receives canonical pack `order[drawPos]`, whose cards are the
  manifest's slots `canonicalPack × cardsPerPack … + (cardsPerPack − 1)` in manifest
  order (the real product's front-to-back slot order).
- **Legacy mode (`shuffleMode: "items"`):** the permutation is over individual card
  slots: the card at draw position `p` is manifest index `order[p]`.

Draw positions are claimed strictly lowest-unused, in rip order, under a
distributed lock — nobody (buyer or staff) picks a slot.

**The live order is never stored.** Opening a box persists only `beaconValue`; each
rip re-derives `finalSeed` and this pack's slots on demand, then discards them.
There is no column, cache, export, or backup that contains which pack holds what
while a box is live.

## 4. Rolls (Evolve and Mystery Pack)

Evolve stakes and Mystery opens pin a **future** quicknet round at the moment the
customer commits (~5 s out). When it publishes with value `randomness`:

```
sig  = HMAC-SHA256(hexDecode(serverSeed), utf8(randomness))
roll = parseInt(hex(sig)[0..12], 16) / 2^52        // 13 hex chars = 52 bits → [0, 1)
```

- **Evolve:** win iff `roll < winProbBps / 10000`. `winProbBps` is published at
  commit; the RTP anchor is `winProb = 0.90 × stakeValue / targetValue`, clamped ≤ 1.
- **Mystery Pack:** the winning set is the cumulative bucket-select of `roll` over
  the committed odds table:

```
target = roll × Σ weight
acc = 0
for entry in weightTable (in committed order):
    acc += entry.weight
    if target < acc: return entry.setId
```

The prize is a real, unopened booster of the winning set, minted at ⚡0 and ripped
through the normal box protocol above.

## 5. Reveal

- **Boxes** reveal when the last pack rips: `serverSeed`, the full manifest +
  `nonce`, `beaconValue`, per-pack opener list, and the seed-access summary
  (section 7) all become public on the box's `/verify/<lotId>` page and API payload.
- **Evolve / Mystery** reveal at resolve: `serverSeed`, `drandRandomness`, `roll`,
  and (Mystery) the full `weightTable` + `winningSetId`.

## 6. Verification checklist

A verifier must confirm, for a **box**:
1. `sha256hex(serverSeed) == seedCommitment`
2. `sha256hex(canonicalJson(manifest + nonce)) == manifestCommitment`
3. (v2) `manifest.beaconRef == published beaconRef`
4. `beaconValue` equals the drand round in `beaconRef`, fetched from drand itself —
   unreachable drand is a visible warning, never a silent pass; a non-`drand:` ref
   is a hard fail
5. Re-derive `finalSeed`, re-run `shuffledOrder`, reproduce every pack byte-for-byte

For an **Evolve**: seed hash matches; `roll` recomputes from the drand value; the
drand value matches the pinned round at source; win/loss follows `winProbBps`.

For a **Mystery open**: seed hash matches; `roll` recomputes;
`sha256hex(JSON.stringify(weightTable)) == weightTableHash`; the bucket-select of
the roll over that table equals `winningSetId`; the drand value matches the pinned
round at source.

## 7. Secret custody (who can access what)

- Server seeds are stored **encrypted at rest** (AES-256-GCM). The key
  (`FAIRNESS_SEED_KEK`) lives only in the API service environment — never in the
  database — so database access alone cannot decrypt any live seed. A DB dump or a
  read-only SQL console reveals nothing about a live box.
- Every decrypt of a still-secret seed writes an **append-only audit row**
  (`fairness.seed-access`, purpose-tagged: `open`, `rip`, `reconcile`, `admin`).
  There is no delete path. Each completed box publishes its rollup
  (`seedAccessSummary`) on the verify page: expect exactly one `open` plus one `rip`
  per pack — anything else is conspicuous to every visitor.
- No admin panel, support tool, or API endpoint exposes a live seed or the live
  order. The only code paths that touch the seed are the protocol steps above.

## 8. Honest limits

- **We could recompute, we cannot alter.** PackLab's server holds the seed (encrypted)
  and the beacon is public once its round lands, so the operator could in principle
  recompute a live box's order by running code inside the app environment. What the
  protocol makes *impossible* is changing the outcome: contents are hash-committed
  before sale, the round is committed inside that hash, slots are claimed in strict
  rip order, and your browser re-checks all of it. Recompute-resistance is raised as
  far as this architecture allows (no stored order, encrypted seed, logged access);
  eliminating it entirely needs per-pack lazy beacons or threshold/enclave custody —
  documented future work, not a shipped claim.
- **Commitments live in our database.** A commitment only binds once someone has
  seen it. After the first pack sells (or any third party snapshots the page), a
  rewrite breaks verification publicly; external anchoring is future work.
- **Our verifier is our JavaScript.** That's why the raw drand links, downloadable
  reveal JSON, this spec, and the test vectors below exist — you can check
  everything with your own tools.

## 9. Test vectors

Generated with the shipped implementation. All strings are exact; no trailing
whitespace or newlines are hashed.

**Inputs**

```
serverSeed  = 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
beaconValue = a0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9f
```

**Vector 1 — seed commitment**

```
sha256hex(serverSeed) = 73641c99f7719f57d8f4beb11a303afcd190243a51ced8782ca6d3dbe014d146
```

**Vector 2 — final seed**

```
finalSeed = HMAC-SHA256(hexDecode(serverSeed), utf8(beaconValue))
          = e96f8c15ef9d68a58d4b810b5146c542af450b81d3fe5a4fbf5258812c736297
```

**Vector 3 — shuffle**

```
shuffledOrder(finalSeed, 6)  = [3, 4, 1, 2, 0, 5]
shuffledOrder(finalSeed, 12) = [10, 9, 11, 3, 1, 2, 8, 6, 4, 0, 7, 5]
```

So in a 6-pack box, the buyer of draw position 0 receives canonical pack 3;
position 4 receives canonical pack 0.

**Vector 4 — roll (Evolve / Mystery)**

```
sig  = HMAC-SHA256(hexDecode(serverSeed), utf8(beaconValue))
     = e96f8c15ef9d68a58d4b810b5146c542af450b81d3fe5a4fbf5258812c736297
roll = parseInt("e96f8c15ef9d6", 16) / 2^52 = 0.9118583253435779
```

An Evolve with `winProbBps = 4500` (45.00%) **loses** on this roll
(0.9118… ≥ 0.45).

**Vector 5 — Mystery bucket-select**

```
weightTable = [ { "setId": "a", "weight": 500000 },
                { "setId": "b", "weight": 400000 },
                { "setId": "c", "weight": 100000 } ]
total  = 1000000
target = 0.9118583253435779 × 1000000 = 911858.3…
cumulative: a → 500000, b → 900000, c → 1000000
911858.3 ≥ 900000 and < 1000000  →  winningSetId = "c"
```

A conforming implementation must reproduce all five vectors exactly.

---

*The verifier source that ships to your browser is published at `/verify/source`.
This document is versioned in the public repository as `docs/fairness-spec.md`; any
change to the math requires a new manifest version, never a silent edit.*
