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
SHA256(bytes): SHA-256, output 32 bytes.utf8(string): UTF-8 encoding, no BOM.hex(bytes): lowercase hexadecimal.||: byte concatenation.be32(n): 4-byte big-endian unsigned integer.
1. Normalization
Applied before anything is hashed. The receipt shows the normalized values.
Buyer handle normalizeHandle(s):
- Unicode NFKC normalize.
- Trim leading/trailing whitespace.
- Remove exactly one leading
@if present. - Collapse internal runs of whitespace to a single space.
- Lowercase (
toLowerCase, default locale-independent mapping).
Team / spot name normalizeTeam(s):
- Unicode NFKC normalize.
- 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:
- Objects: keys sorted by UTF-16 code unit order, ascending. Recurse into values.
- Arrays: element order preserved. Recurse.
- Strings: JSON string per ECMA-404 using the escaping rules of ECMAScript
JSON.stringify(escape",\, control chars U+0000–U+001F as\uXXXXexcept\b \f \n \r \t; lone surrogates as\uXXXX; everything else literal). nullliteral. No numbers or booleans appear in a spec.- No whitespace anywhere.
Example (abbreviated): {"algorithm_version":"fb-v1","break_id":"…","break_type":"random_teams","case_product":null,…}
4. Lock
At lock time the server:
- Generates
salt= 32 bytes from a CSPRNG. commitment = SHA256( utf8(canonical_json) || salt ), published as hex.- Records
locked_at(UTC, millisecond precision). - Computes
target_round(§5) and publishes it. - 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) ) + 1locked_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:
- Fetch the beacon for exactly
target_round. - Verify the BLS12-381 signature over
SHA256(be64(round))with the pinned public key, schemebls-unchained-g1-rfc9380, DSTBLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_. Also checkrandomness == SHA256(signature_bytes). (drand-client does all of this.) 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:
- More teams than entries. Every entry gets a team. The teams left at the end of the shuffled pool are listed on the receipt as *unclaimed* (unsold / house). Which teams are unclaimed is random, not seller-chosen.
- More entries than teams. The pool is padded with
nullplaceholders before the shuffle, so which entries receive *no team* is also random. Those entries appear on the receipt as *no team* and the seller owes a refund or a re-run. Entry order never decides who misses out.
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:
- Rebuild the spec from the displayed fields, canonicalize, compare with
canonical_json. SHA256(utf8(canonical_json) || salt) == commitment.target_round == ceil((locked_at + 30000 - genesis*1000) / 3000) + 1, androundTime(target_round) > locked_at.- Fetch beacon
target_roundfrom a drand relay, verify its signature with the pinned key, checkrandomnessequals the receipt's. - 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
