FairBreaksdocs/ALGORITHM.md

FairBreaks fairness algorithm, version fb-v1

> Plain-language companion to the formal standard: packages/fb1/SPEC.md (FB-1 v1.0). Where they differ, SPEC.md and the official vectors win.

This document is enough to reimplement the verifier in any language. Every step is deterministic. The only external input is the drand quicknet randomness beacon.

0. Primitives

1. Normalization

Applied before anything is hashed. The receipt shows the normalized values.

Buyer handle normalizeHandle(s):

  1. Unicode NFKC normalize.
  2. Trim leading/trailing whitespace.
  3. Remove exactly one leading @ if present.
  4. Collapse internal runs of whitespace to a single space.
  5. Lowercase (toLowerCase, default locale-independent mapping).

Team / spot name normalizeTeam(s):

  1. Unicode NFKC normalize.
  2. Trim, collapse internal whitespace to a single space.

Case is preserved.

Text fields (title, case product, case serial): NFKC, trim, collapse whitespace. Empty optional fields become null.

2. Break spec

A break is the following object. Field names are exact.

{
  "algorithm_version": "fb-v1" | "fb-v2",
  "break_id":    string   // UUID of the break, lowercase
  "title":       string
  "platform":    string   // e.g. "whatnot", "ebay_live", "tiktok_shop", "fanatics_live", "other"
  "break_type":  "random_teams" | "random_spots" | "pick_your_team"
  "case_product": string | null
  "case_serial":  string | null
  "teams":   [ string, ... ]                       // entered order, normalized
  "entries": [ { "buyer_handle": string, "team": string | null }, ... ]
  "evidence": { "sealed_case": EvidenceRef | null }   // fb-v2 only; see SPEC §15
}

fb-v2 (current) adds evidence: the SHA-256, media type, trust level and drand time window of a sealed-case capture taken before lock, or null. Everything else is unchanged and fb-v1 receipts keep verifying. EvidenceRef integers serialize as plain JSON integers.

entries are buyer entries in entered order. A buyer with quantity 3 appears as 3 consecutive entries. team is null for random_teams and random_spots; for pick_your_team it is the team the buyer chose (see §8).

3. Canonical JSON

canonical_json = canonicalize(spec) where canonicalize is:

Example (abbreviated): {"algorithm_version":"fb-v1","break_id":"…","break_type":"random_teams","case_product":null,…}

4. Lock

At lock time the server:

  1. Generates salt = 32 bytes from a CSPRNG.
  2. commitment = SHA256( utf8(canonical_json) || salt ), published as hex.
  3. Records locked_at (UTC, millisecond precision).
  4. Computes target_round (§5) and publishes it.
  5. Stores canonical_json, commitment, salt (hidden), target_round, locked_at. Every one of these is immutable from this moment.

The salt stays secret until randomization so the commitment reveals nothing about the buyer list to anyone who could otherwise grind the small input space.

5. Target round (drand quicknet)

Chain constants (pinned in the verifier, never fetched blindly):

chain hash:   52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971
public key:   83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a
genesis_time: 1692803367  (unix seconds, round 1)
period:       3 seconds
scheme:       bls-unchained-g1-rfc9380

roundTime(r) = (genesis_time + (r - 1) * period) * 1000 milliseconds.

LOCK_DELAY_MS = 30000
target_round  = ceil( (locked_at_ms + LOCK_DELAY_MS - genesis_time*1000) / (period*1000) ) + 1
              = floor( (locked_at_ms + LOCK_DELAY_MS - genesis_time*1000 + period*1000 - 1) / (period*1000) ) + 1

locked_at_ms is truncated to whole milliseconds before use.

This is the first round whose publish time is at least 30 s after locked_at. It is a pure function of locked_at; the seller cannot pick it, and it does not exist yet when it is published on the receipt.

6. Seed

After target_round is published by drand:

  1. Fetch the beacon for exactly target_round.
  2. Verify the BLS12-381 signature over SHA256(be64(round)) with the pinned public key, scheme bls-unchained-g1-rfc9380, DST BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_. Also check randomness == SHA256(signature_bytes). (drand-client does all of this.)
  3. seed = SHA256( randomness_bytes(32) || commitment_bytes(32) ).

Mixing the commitment into the seed means two breaks locked at the same second get different shuffles, and the seed cannot be known before both inputs exist.

7. PRNG and shuffle

Counter-mode PRNG. Block i (starting at 0):

block_i = SHA256( seed || be32(i) )          // 32 bytes

Each block yields 8 unsigned 32-bit integers, read big-endian, in order. When all 8 are consumed, move to block i+1.

Unbiased integer in `[0, n)` by rejection sampling:

limit = 2^32 - (2^32 mod n)
loop:
  u = nextUint32()
  if u < limit: return u mod n

n = 1 returns 0 without consuming a value. n = 0 is an error.

Pool.

pool = teams (copy, entered order)
while len(pool) < len(entries): pool.append(null)

Fisher-Yates (descending, Durstenfeld):

for i = len(pool) - 1 down to 1:
  j = randomBelow(i + 1)
  swap pool[i], pool[j]

Assignment.

results[k] = { position: k, buyer_handle: entries[k].buyer_handle, team: pool[k] }   for k < len(entries)
unclaimed  = [ pool[k] for k >= len(entries) if pool[k] != null ]

Rules for mismatched counts:

8. Pick-your-team breaks

break_type = "pick_your_team" has no randomness step. Locking commits the buyer→team picks, so the receipt proves the list was fixed at locked_at and not edited afterward. There is no target_round, no seed, and status ends at locked. Verifiers only run §3–4.

9. Reveal and receipt

On randomization the server stores and publishes: salt, target_round, drand randomness and signature, randomized_at, and the results rows. The receipt shows all of them next to the original lock-time fields.

10. Verification (what the browser does)

Given a receipt:

  1. Rebuild the spec from the displayed fields, canonicalize, compare with canonical_json.
  2. SHA256(utf8(canonical_json) || salt) == commitment.
  3. target_round == ceil((locked_at + 30000 - genesis*1000) / 3000) + 1, and roundTime(target_round) > locked_at.
  4. Fetch beacon target_round from a drand relay, verify its signature with the pinned key, check randomness equals the receipt's.
  5. Recompute seed, shuffle, assignment. Compare every (position, buyer_handle, team) and the unclaimed list.

All five pass → "This break was locked before the randomness existed, and the result matches." Any failure names the step.

11. What this does and does not prove

Proves: the assignment is a deterministic function of (a) a list the seller committed to and (b) randomness produced by a public network after that commitment, and nobody edited the list or the outcome afterward.

Does not prove: that the buyer list is the real list of people who paid. Anyone watching the stream can check the list against chat. With a sealed-case capture (SPEC §15) the lock also covers a file that provably existed inside a drand time window before the list was fixed; what that capture shows is for the viewer to judge, and the trust level says how it was taken.

Relies on: the server recording an honest locked_at. An observer who loads the receipt before target_round fires can see the commitment exists and the round is still in the future, which removes even that reliance. The overlay shows this state live on stream.

12. Test vectors

See tests/fairness.vectors.test.ts. The fixture uses quicknet round 32403419:

randomness: 681c41f82aace4cc855953ef3e2817ba15acd44a4d599570c2d412c51e78fc58
signature:  80102b48235cb0e8137829a7e7623e068740012e4821787226a7c0cb846ead88bc3f5e89c99b55c26c29ffcb54942b96