# Provably Fair Raffle Draws (drand) <!-- omit in toc -->

**Tickets:** DCC-1168 (GYM-3), DCC-1170 (spike) · **Entries:** DCC-1166 · **API:** DCC-1167 · **Architecture:** `docs/gym-challenge-system-design.md`

Anyone — no account, no trust in Collector.Club — can recompute a Gym Challenge raffle draw from published data and get the same result. This document is the specification a third-party verifier implements, and the reasoning behind each step.

- [The property we guarantee](#the-property-we-guarantee)
- [Lifecycle](#lifecycle)
- [Step 1 — Freeze the eligible set](#step-1--freeze-the-eligible-set)
- [Step 1b — Freeze the contributed total](#step-1b--freeze-the-contributed-total)
- [Step 2 — Commit to a future drand round](#step-2--commit-to-a-future-drand-round)
- [Step 3 — Fetch and verify the beacon](#step-3--fetch-and-verify-the-beacon)
- [Step 4 — Derive the seed](#step-4--derive-the-seed)
- [Step 5 — Select the drawn entrants](#step-5--select-the-drawn-entrants)
- [Publishing and verification](#publishing-and-verification)
- [Void and redraw policy](#void-and-redraw-policy)
- [Threat model](#threat-model)
- [Operations](#operations)

## The property we guarantee

The eligible entrant set is frozen and its hash published **before** the randomness exists; the randomness comes from a public beacon that nobody, including us, can predict or replace; and the winner-selection function is fixed, documented, and deterministic. Together: we cannot pick a favourable outcome, cannot alter the entrant list after the fact, and cannot silently redraw — and anyone can check all three.

What this does **not** guarantee is eligibility fairness: the draw can be perfect while the rules for earning entries are wrong. Entry rules are published separately (`docs/raffle-entries-runbook.md`) and every entry is an auditable ledger row.

## Lifecycle

```
draft ──open──▶ open ──(cron at entry_close_at)──▶ closed ──(same tx)──▶ committed ──(cron or admin draw, after target round)──▶ drawn
                 │                                                          │
                 └───────────────────── void (with reason) ─────────────────┘
```

Every transition is `UPDATE raffle SET … WHERE id = $1 AND status = $expected RETURNING`, so two cron ticks, or an admin and the cron, cannot double-run a step. **Nothing on a raffle is edited after `committed`.** A mistake is a `void` with a public reason and, optionally, a superseding raffle that links back (`supersedes_raffle_id`); the voided record stays online.

Until its entry window starts (`entries_from`; a raffle with none is public as soon as it opens), an opened raffle answers exactly like a draft: not found. A gym challenge cancelled before its start is deleted, not voided, because no entry can count before its window starts; once its record is public, a mistake is a `void`.

| Field                            | Meaning                                                                                                                                                                                                                                                                                                                                                                                                         |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `entries_from` / `entries_until` | The published action window: which actions (by `source_timestamp`) earn entries. `entries_from = NULL` means all-time, which the retroactive account-creation entry needs.                                                                                                                                                                                                                                      |
| `entry_close_at`                 | The ledger cut: `created_at` must precede it. Set ≥ 24 h after `entries_until` so one daily reconcile cycle has landed any missed hook. The cron closes + commits at this instant, unless a milestone raffle's contributed total reached no milestone or a reached milestone still has an unrevealed mystery prize (see Operations).                                                                            |
| `committed_at`                   | The freeze instant, stamped by the commit transaction and hashed into the canonical snapshot. A void is bounded on it: a row voided before it is out, and a void recorded at or after it is too late to change the freeze. A superseding raffle freezes at its own, later instant, so every entry reversed before that instant, including one reversed after the original closed, stays out of the replacement. |
| `draw_delay_seconds`             | How far in the future the committed round is (default 600 s). The public sees the snapshot hash and the round number for this long before the beacon exists.                                                                                                                                                                                                                                                    |

## Step 1 — Freeze the eligible set

At `entry_close_at` the cron computes, from the ledger and never from the rollup, with `:committed_at` the freeze instant it is about to stamp:

```sql
select user_id, sum(entries)
from raffle_entry_ledger l join "user" u on u.id = l.user_id
where l.created_at < :entry_close_at
  and l.source_timestamp < :entries_until
  and (:entries_from is null or l.source_timestamp >= :entries_from)
  and u.email not like 'guest\_%@temp.dcc.local'
  and (l.voided_at is null or l.voided_at >= :committed_at)
  and (u.anonymized_at is null or u.anonymized_at >= :committed_at)
group by user_id having sum(entries) > 0;
```

Bounding on `created_at` is what makes the snapshot **reproducible from raw rows at any later time**: a row recovered by the reconcile sweep after the cut has `created_at > entry_close_at` and is provably outside it. A void is bounded on the freeze instant, which the snapshot publishes as `committedAt`: `voided_at` excludes a row only if it lands before `committed_at`, so every void already recorded at the freeze is out, an entry reversed after the freeze stays in a set already frozen, and recomputing from raw rows after that later void still reproduces it. Both stamps come from the app servers' clocks, so a void landing within their skew of the freeze can fall on either side of it. The bound is the raffle's own instant and not its window on purpose: a superseding raffle copies the window, and bounding on it would carry the original's immunity to late voids over to the replacement, so an entry reversed after the original closed would count again in the re-run. An account an admin anonymized is bounded the same way on `anonymized_at`: it is excluded only if anonymized before `committed_at`, so anonymizing an entrant after the freeze never changes a set already frozen, and re-deriving later still reproduces it.

The Gym Challenge progress and leaderboard endpoints (`/api/gym-challenge/progress`, `/api/gym-challenge/leaderboard`) rank and count entries with this **same query** — same predicate, same guest and anonymized-account exclusions, summed live from the ledger — scoped to one open raffle whose window has started: the one a caller names by slug, where the endpoint accepts one (the `/gym` hub passes the challenge it shows), and otherwise the earliest-closing one, skipping one whose close was refused because it reached no milestone (see Operations). A raffle whose window has not started answers like none. Both payloads carry `window: { slug, entriesFrom, entriesUntil, entryCloseAt }` naming that raffle. With no open raffle the endpoints return zero totals, a null viewer, and `window: null`, without querying the ledger. Between `entries_until` and `entry_close_at` the board is bounded exactly as the draw is: actions after the deadline no longer count, while a row the reconcile sweep recovers for an in-window action still does, until the cut. An open raffle has no freeze instant yet, so the board excludes every voided row and every anonymized account. The progress endpoint also reports the community's contributed total (Step 1b), and both endpoints give the viewer's own and each listed row's contribution, computed live over the same rows, so every voided spin entry is out.

Each entrant becomes a **leaf**:

```
leaf_hash = SHA-256( UTF-8( "dcc-raffle-leaf-v1:" + slug + ":" + userId ) )   // lowercase hex
```

The list is **pseudonymous, not anonymous**. Any user can recompute their own leaf (`GET /api/raffles/[slug]/my-entry` returns it) and find themselves — and so can anyone who holds that user's id, since the domain and slug are public: user ids appear in public profile payloads, so participation and exact entry count in a named raffle are linkable to a known profile. From version 3, a raffle with milestones also publishes each entrant's contributed cents on their row (Step 1b), so the exact dollars that user paid for spins on the challenge's machines inside the window are linkable to their profile in the same way, permanently, on the unauthenticated `GET /api/raffles/[slug]/entrants` response. A raffle without milestones publishes no cents. From version 4, while the insider limit is on, the `insiders` list (Step 5) publishes which entrants the operator listed as insiders, so insider status is linkable to a known profile in the same way, and a draw result that shows its winner's public name beside their leaf marks a winning insider as one. What the hash prevents is recovering an id from the list (ids are unguessable random strings). A per-raffle secret salt would close the link but make the leaf uncomputable off-server and break the public recompute, so the linkability is accepted and stated here rather than hidden.

Account deletion severs the link without touching the published record. `raffle_entrant.user_id`, `raffle_draw_result.user_id` and `raffle_entry_ledger.user_id` are all `ON DELETE SET NULL`; the leaf hashes, entry counts and results never change. The ledger rows themselves are kept — they are what makes the snapshot reproducible — but pseudonymised whenever the row is deleted: in the same transaction when the app's own account-deletion path runs it, or by a `BEFORE DELETE` trigger on `"user"` for any other deletion path. Either way, `dedupe_key` becomes `erased:<row id>` and `ref_id` is cleared, while `source`, `entries`, `source_timestamp` and `created_at` stay. Re-deriving a snapshot after deletions therefore groups the erased rows into a `user_id IS NULL` bucket that no leaf can claim. The ledger keeps no erasure timestamp, so that bucket cannot tell a row erased after this raffle's freeze (which the published total already counted, and which the re-derivation must add back) from one erased before it (which was already excluded by the eligibility join at freeze time, and never entered the published total). The check this supports is therefore a one-sided bound, not an equality: `re-derived total (user_id is not null) + sum(entries) where user_id is null >= published total`, evaluated over the same window — equal only when none of the window's rows were erased before this raffle's own freeze. Anonymizing an account is not deletion: the account row and every ledger `user_id` stay, and the account leaves the eligible set only through the `anonymized_at` bound above, so a published total re-derives exactly, with no `user_id IS NULL` bucket.

Entrants are sorted by `leaf_hash` ascending (bytewise on lowercase hex = plain string order), must be unique, and are serialized into the **canonical snapshot** — hand-serialized, no whitespace, this exact key order, timestamps as ISO-8601 UTC with milliseconds, integers as plain decimals:

```json
{
  "v": 4,
  "raffle": "gym-challenge-2026-10",
  "rulesVersion": "2026-10-01",
  "entriesUntil": "2026-10-31T23:59:59.000Z",
  "entryCloseAt": "2026-11-02T00:00:00.000Z",
  "committedAt": "2026-11-02T00:00:02.000Z",
  "milestones": [
    [0, 1],
    [500000, 2],
    [2500000, 2]
  ],
  "insiders": ["00a3…"],
  "entrants": [
    ["00a3…", 12, 2600000],
    ["01f9…", 1, 1250]
  ],
  "totalEntries": 13,
  "contributedUsdCents": 2601250
}
```

```
entries_hash = SHA-256( UTF-8( canonical_json ) )
```

The exact bytes are stored (`raffle.canonical_snapshot`) and served verbatim by `GET /api/raffles/[slug]/entrants`, so a verifier hashes precisely what we hashed. `parseCanonicalSnapshot` re-serializes what it parses and rejects any input whose bytes differ — reordered keys, whitespace, an unsorted list, a wrong total. `v` is `CANONICAL_SNAPSHOT_VERSION`, frozen on the row at commit and served as `spec.version`. Version 3 (Step 1b) adds `milestones` between `committedAt` and `entrants`, the raffle's community milestones as `[thresholdUsdCents, prizeCount]` pairs sorted by threshold ascending with no duplicate threshold (`[]` for a raffle without milestones). When the ladder is not empty, every entrant row gains a third integer, that entrant's contributed cents, and `contributedUsdCents` follows `totalEntries` as the sum of those cents, which the parser rejects unless it equals the sum exactly as it does for `totalEntries`. With an empty ladder the rows stay `[leaf, entries]` and there is no `contributedUsdCents`: nothing unlocks on dollars, so no one's spend is published. Version 4 adds `insiders` between `milestones` and `entrants`: the leaves of the entrants the operator listed as insiders (Step 5), sorted ascending, each one a leaf that also appears in `entrants`, and `[]` when the raffle has none or the insider limit is off. The list is frozen from the `raffle_insider` table in the same transaction as the entrants, so it is fixed before the round exists and covered by `entries_hash` like everything else in the bytes; only leaves are published, never emails or user ids. Version 3 bytes have no `insiders` key. Version 2 bytes have no ladder or cents either, entrant rows are pairs, and their milestones were evaluated against `totalEntries`. Version 1 bytes also have no `committedAt` key (their void bound was `entry_close_at`). A verifier accepts all four versions and re-serializes under the version the bytes declare.

## Step 1b — Freeze the contributed total

Community milestones unlock on **dollars contributed**, not on entries. Winners are still drawn by entries (Step 5); only which prizes are unlocked depends on dollars. When the raffle has milestones, the Step 1 query is replaced by one statement that also sums, per entrant, the USD paid for the spin behind each of their spin entries, in integer cents. Entries and cents are read from **the same ledger rows** in one snapshot — the same window, cut, guest exclusion, void bound and anonymized-account bound on `:committed_at` — so an entry committed mid-freeze lands in both columns of a row or in neither:

```sql
select l.user_id, sum(l.entries), coalesce(sum(round(s.cost_usdc * 100)), 0)
from raffle_entry_ledger l
join "user" u on u.id = l.user_id
left join arcade_spins s
  on l.source = 'themed_pull' and l.dedupe_key = 'themed_pull:' || s.memo
where <the Step 1 predicate on l and u>
group by l.user_id having sum(l.entries) > 0;
```

A spin entry (`themed_pull`) is written only for a paid spin on one of the challenge's machines, so this is the money paid into the challenge. Free (AMOE) entries, eligible-pull bonus entries and badge-tier entries contribute $0, and so does a spin that earned no entry (spin entries switched off, or a guest account). Nothing voids an entry automatically on a refund or chargeback: an operator records one by voiding the spin's `themed_pull:<memo>` row by hand (`docs/raffle-entries-runbook.md` → "Reversing an entry"), which takes out its tickets and its dollars together. Until then the spin's dollars count. A void at or after `committed_at` changes neither, exactly as in Step 1. A raffle without milestones skips this step: it publishes no cents (Step 1, version 3).

Each entrant's cents are published on their snapshot row and the total is their sum, so the contributed total has the same trust model as the entry total: anyone can re-add the published rows, and each player can check their own row (`GET /api/raffles/[slug]/my-entry` returns `contributedUsdCents` beside `entries`). The total is also stored on the row (`raffle.contributed_usd_cents`, null for a raffle without milestones) and served as `snapshot.contributedUsdCents` by `GET /api/raffles/[slug]`. Hashed with the milestone ladder it is compared against, it lets a verifier recount the prizes from the snapshot alone.

## Step 2 — Commit to a future drand round

In the **same transaction** as the freeze, after it:

```
commit_at           = now   (the committedAt hashed in Step 1: the freeze and the commitment share one instant)
drand_target_time   = first quicknet round publication time ≥ commit_at + draw_delay_seconds
drand_target_round  = ceil((drand_target_time_unix − 1692803367) / 3) + 1
drand_chain_hash    = 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971
```

Because the round is chosen after the set is frozen and before the round exists, nobody can wait for a favourable beacon or pick which beacon applies. The public API exposes `entries_hash`, `entrant_count`, `total_entries`, `drand_target_round`, and `committed_at` immediately.

**Publish the commitment outside our database.** `committed_at` is our own write, so on its own it cannot prove the commitment preceded the round. As soon as the raffle commits, an operator posts the commitment from the official X account: the slug, `entries_hash`, the target round and its publication time, `committed_at`, the frozen spec version (`spec v<N>`, the rule the draw will follow), and the verification page URL. The post has to land before `drand_target_time`, which is what `draw_delay_seconds` leaves room for. The operator then records the post's link on the raffle; the record stores the post id, the commitment text as it was prepared for posting and the time the link was recorded (not the time the post went up, which only X knows), and refuses a second post. The draw never waits for the post.

**drand quicknet** (League of Entropy) publishes a BLS12-381 signature every 3 seconds from a threshold of independent operators; no single party can predict or bias a round. It is _unchained_: each round signs only its round number, which is what allows committing to a round number ahead of time.

## Step 3 — Fetch and verify the beacon

Once `drand_target_time` has passed, the cron (or an admin's `draw`, which runs the same code) fetches `https://api.drand.sh/v2/beacons/quicknet/rounds/<round>` (falling back to `api2.drand.sh`, `api3.drand.sh`, `drand.cloudflare.com`). The relay answers `{ round, signature }`. Authenticity never depends on the relay:

```
message   = SHA-256( uint64_be(round) )
valid     = BLS_verify( signature (G1, 48 bytes), hash_to_curve_G1(message, DST="BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"), quicknet_public_key (G2) )
randomness = SHA-256( signature bytes )
```

The pinned public key is the `publicKeyHex` constant in `src/lib/drand/constants.ts`; it must match `https://api.drand.sh/v2/beacons/quicknet/info` (the value is a public BLS key and is deliberately not repeated here so secret scanners do not mistake it for a credential).

The raw signature is published so any drand tool or BLS library re-checks it. A relay outage leaves the raffle `committed`; the next tick retries. The round never changes, so a late draw is safe and explainable.

## Step 4 — Derive the seed

```
seed = SHA-256( UTF-8( "dcc-raffle-seed-v1:" + slug + ":" + rulesVersion + ":" + entries_hash + ":" + round + ":" + randomness_hex ) )
```

Binding the raffle's identity and frozen set into the seed means one beacon round can never be reused ambiguously across raffles.

## Step 5 — Select the drawn entrants

Each entry is one ticket. Prizes are drawn one at a time, in the order of the published prize list, and each prize is drawn across every ticket still in the draw. A win uses up one of the winner's tickets for the rest of the draw: an entrant can win as many prizes as they have entries, and their chance at each prize is their share of the tickets left.

Entrant `i` starts with `remaining_i = entries_i`. Before each prize, the tickets are the integers `[0, T)` with `T = Σ remaining_i`, laid out in leaf order: entrant `i` owns `[start_i, start_i + remaining_i)`, where `start_i` is the sum of `remaining_j` over every `j < i`.

```
counter = 0
for prizeRank in published prize-list order:
  if T = 0:                                   record prizeRank as unfilled; continue
  loop:
    candidate = SHA-256( seed_bytes(32) || uint64_be(counter) )        // as unsigned 256-bit big-endian
    counter += 1
    if candidate >= 2^256 − (2^256 mod T):   record "rejected_range"; continue    // rejection sampling: no modulo bias
    ticket = candidate mod T
    entrant = owner of ticket on the current line
    record "accepted" (prizeRank, counter−1, candidate, ticket, leaf_index); break
  remaining_entrant −= 1; T −= 1                                          // the win uses up one ticket
```

**Insiders win at most one prize.** The snapshot's `insiders` list (Step 1, version 4) names the entrants the operator designated as insiders before the freeze. When an insider wins, the win uses up **all** of their remaining tickets instead of one (`T -= remaining_entrant; remaining_entrant = 0` in place of the last line above), so they are skipped for every later prize and their chance at each later prize is zero. Everyone else keeps the rule above. An empty `insiders` list draws exactly as it would without the rule.

The counter never resets, so the whole draw is a pure function of `(seed, entrant list, published prize order, insider list)`, and every counter evaluated is stored in `draw_trace` so a verifier replays without guessing. A recorded `ticket` is its index on the line as it stood for that prize. If the tickets run out before the prizes do, the remaining ranks are recorded as unfilled.

**Prize order.** The published prize list (`prizes` on `GET /api/raffles/[slug]`) is in draw order: highest declared value first, and prizes of equal value keep their rank order, which is tier order and then the order the prizes are listed within a tier. A prize keeps its rank, so each result names the prize it was drawn for. The list and the spec version are fixed in the commit transaction, before the beacon exists, and the order follows from them under that version's rule. Prize values are not published, so a verifier replays the draw in the published order but cannot check that order against the values; that it follows value is the operator's statement, as the prize list itself is.

**Earlier versions.** `spec.raffleSpecVersion` on `GET /api/raffles/[slug]` is the version of this specification frozen at the raffle's commit, and it picks the selection rule, each version published in its own copy at `/raffle-spec/v<N>.md`. A raffle committed under version 12 draws in value order with every win using up one ticket and no insider rule: its snapshot (version 3) has no `insiders` list and nobody is limited. A raffle committed under version 11 or earlier is drawn and verified by that version's rule: prizes in rank order, one prize per entrant, and a candidate that lands on an entrant who already won is recorded as `"duplicate"` and skipped.

For a raffle with community milestones, `prize_count` and the prize list are frozen in the commit transaction: they are the prizes of every milestone whose dollar threshold is at or below the snapshot's `contributedUsdCents`, and a raffle that reached no milestone does not commit. A verifier recomputes `prize_count` from the snapshot alone: the sum of `prizeCount` over the `milestones` pairs whose threshold is at or below `contributedUsdCents`, which must equal the published `prize_count`, and the published prize list must hold exactly that many prizes. A milestone prize can be a **mystery prize**, a to-be-announced placeholder with its own rank: it counts toward `prize_count` from the moment the challenge is saved, a reveal fills in its label and value at that same rank, and a raffle does not commit while any reached milestone still has one, so no prize is ever drawn unknown. Reference implementation: `drawWinners` in `src/lib/raffle/draw/selectDrawnEntrants.ts` (pure, isomorphic, byte-for-byte the code that runs on the server and in the browser), which picks the rule from the raffle's spec version.

## Publishing and verification

| Surface                            | What                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET /api/raffles/[slug]`          | Rules, snapshot commitment, the insider leaves (`insiders`, version 4), drand commitment (with every relay URL and the recorded X post, if any), verified beacon, seed, full trace, results, void/supersede links. No user ids. 404 for a draft, or for an opened raffle whose window has not started.                                                                                                                               |
| `GET /api/raffles/[slug]/entrants` | The canonical snapshot bytes, verbatim.                                                                                                                                                                                                                                                                                                                                                                                              |
| `GET /api/raffles/[slug]/my-entry` | Signed-in only: the viewer's leaf hash, leaf index, entry count, contributed cents (version 3 or later with milestones, otherwise null), ticket range on the frozen line (`[start, end)`, checkable against the snapshot; a version 12 or later result's `ticketIndex` is on the line as it stood for that prize, so only the first prize's ticket can be checked against it; match later prizes by `leafIndex`), every prize drawn. |
| `/raffles/[slug]`                  | The verification page. "Recompute in your browser" re-hashes the snapshot, recounts the prizes from its contributed total (version 3 or later with milestones), re-derives the seed, and re-runs the selection with WebCrypto, diffing every step against the published values.                                                                                                                                                      |
| `scripts/verify-raffle.ts`         | The zero-trust path: `pnpm tsx scripts/verify-raffle.ts https://<host>/api/raffles/<slug>` fetches the record and snapshot, fetches the round from drand itself, verifies BLS, and recomputes everything. No database.                                                                                                                                                                                                               |

Drawn entrants are named only through the leaderboards' public-profile rule (a `user_profile` row, placeholder names suppressed); otherwise only the leaf hash is shown.

## Void and redraw policy

- A raffle can be voided from any non-draft state with a public reason. Nothing else on the row changes — including a draw that already happened: the seed, trace, and results stay published (the public record, page, and `my-entry` all keep them) and are marked **repudiated** by the `void` block that travels in the same payload. Hiding a repudiated draw would defeat the "operator redraws" mitigation, which is that the trace stays public.
- `drawn` is therefore not a terminal state for caching; only `void` is.
- There is **no redraw on the same row**. A replacement is a new raffle with `supersedes_raffle_id`, a new slug (`-r2`, `-r3`, …), and therefore new leaves, a new snapshot, and a new committed round; both records stay public and link to each other. It copies the original's window but freezes at its own, later `committed_at`, so every entry reversed before that instant — including one reversed after the original closed — is out of the replacement. Creating the replacement is optional and isolated from the void: a slug collision retries the next `-rN`, and if none is free the void still stands and the admin is told (`supersedeError`). A gym challenge is never replaced: a void that asks for a replacement is refused before anything is voided, and running the challenge again means creating a new one.
- Unfilled prizes (the tickets ran out first: fewer entries than prizes, or, from version 13, an insider's win using up their remaining tickets) are recorded on the public record, never re-drawn and never rolled into another raffle. Product does not expect them to occur.
- Eligibility is by account: a guest checkout promoted to a full account becomes eligible once that account's email is verified.

## Threat model

| Threat                                                               | Mitigation                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Operator alters the entrant list after seeing the result             | `entries_hash` and the entrant count are public before the beacon exists; the snapshot bytes are served verbatim.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| Operator picks a favourable beacon                                   | The round is chosen inside the freeze transaction, before it is published; the commitment is public for `draw_delay_seconds` first.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| Operator redraws                                                     | Status-guarded transitions; results are unique per `(raffle, prize_rank)`; the trace is published.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| Relay serves a forged beacon                                         | BLS verification against the pinned quicknet key; the raw signature is published for independent checks.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| Modulo bias                                                          | Rejection sampling over the full 256-bit candidate.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| Cross-raffle beacon reuse                                            | The seed binds slug, rules version, and `entries_hash`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| Account deletion breaks verification                                 | `raffle_entrant.user_id`, `raffle_draw_result.user_id` and `raffle_entry_ledger.user_id` are all `ON DELETE SET NULL`; ledger rows are kept with `dedupe_key` / `ref_id` scrubbed, so the published hashes and results never change. A scrubbed row's `user_id` is null, though, so it drops out of the eligibility join and its leaf cannot be re-derived from raw rows for that participant specifically.                                                                                                                                                                                                            |
| Operator misstates the contributed total                             | Each entrant's cents are on their snapshot row and the total must equal their sum, so every player can check their own contribution and anyone can re-add the total. The rows and the milestone ladder are hashed in the freeze transaction, so the prize count is fixed before the beacon exists; it decides how many prizes are drawn, never who wins them.                                                                                                                                                                                                                                                          |
| Operator changes the prize order or the spec version before the draw | Both are published on the record from the commit, before the beacon exists. The spec version is also in the X post, so a recorded post pins the selection rule under X's timestamp. The prize order is in neither the hashed snapshot nor the X post, and prize values are not published, so a reader who saved the record before the round can hold us to it; otherwise the order rests on the operator's word, as the prize list itself does. Once the draw is published, the verifier replays it by the published version and in the published order, so a later change to either no longer reproduces the results. |
| Operator backdates `committed_at`                                    | `committed_at` is a database value, so hashing it into the snapshot only pins it after publication. The mitigation is the X post described in Step 2: it carries `entries_hash`, the target round and `committed_at`, and X's own timestamp shows the commitment existed before the round was published. The verifier page links to the post and keeps the text stored when the link was recorded, so a reader can compare it with the live or deleted post. A raffle without a recorded post is still a fair draw; it only lacks this third-party proof.                                                              |

## Operations

- **End extension.** While a raffle is `open` and before its `entries_until`, an admin may move `entries_until` later; `entry_close_at` moves with it, to the new end plus the challenge's close grace (48 hours). A shorter or unchanged end is refused, and so is one that overlaps another non-void raffle's window. The extension happens before the freeze, so it cannot change the frozen entrant set, the committed hash or the drand round, all of which are chosen only at close; the committed record carries the extended `entries_until` and `entry_close_at` it was frozen against. Once a raffle is `closed`, `committed`, `drawn` or `void`, its window never moves later.
- **Early close.** `close` before `entry_close_at` is refused (409 `early`) unless `confirmEarly: true`; the panel asks for that confirmation. An early close moves `entry_close_at` (and `entries_until`, if later) to the freeze instant in the same `open → closed` claim, so the instant the eligibility query bounds on, the instant hashed into the canonical bytes, and the instant the page shows as the freeze are identical. Closing only ever tightens the window.
- **Close refusals.** A raffle with community milestones does not commit when its frozen contributed total reached no milestone (`no_milestone`), or reached a milestone that still has an unrevealed mystery prize (`mystery_prize`): the close transaction rolls back and the raffle stays `open`. A refused early close changes nothing: the raffle keeps its original cut and can still reach a milestone before it. Once the cut has passed, a `no_milestone` refusal never heals: the raffle stops being the board's default subject and its exit is a public `void`. A `mystery_prize` refusal commits on the first tick after the prize is revealed, and an entry recorded after `entry_close_at` still never counts, however late that commit lands. A reveal replaces one prize's label, value, image and NFT at its rank and never changes the prize count, the ranks, the milestone thresholds or any entry, so it cannot change which entrants win.
- **Relays.** `fetchVerifiedDrandRound` verifies BLS inside its relay loop: a relay whose beacon does not verify is skipped and recorded, the next relay is tried, and the draw proceeds on the first authentic beacon while ops is told about the bad relay — once, from whatever claimed the draw (the cron's tick, an admin's Draw winners, or the lifecycle tick), so the warning can never say a raffle was drawn when it was not. Only when no relay verifies does the tick fail with a `verification` failure.
- **Failure state and paging.** A failed close or draw never changes the raffle's status (the row stays in the cron's retry set, so an outage self-heals), but it stamps `last_failure_kind/message/at` and bumps `failure_count` on the row (shown in the panel). Ops is paged once per raffle per classification — `verification`, `no_milestone` and `mystery_prize` at once, `unavailable:late` / `error:late` after 30 minutes — with `alerted_kind` stamped only on confirmed delivery, so a stuck raffle pages on each change of state rather than every five minutes. The exits are a relay recovering, a mystery prize being revealed, or a public `void`.
- Ops is paged when a committed raffle is more than 30 minutes past its round without a draw, and when NO relay serves a verifying beacon (a `verification` failure, paged at once). A relay that served a bad beacon while another served a good one is reported as a warning by whatever completed the draw — so if that attempt fails after the fetch, the rejected relay is recorded in the draw's error log rather than paged.
- **Spec link.** The record's `spec.docsUrl` points at `/raffle-spec/v<N>.md`, the copy of this document that was current when the raffle committed. `RAFFLE_SPEC_VERSION` versions this whole document and is independent of `CANONICAL_SNAPSHOT_VERSION`, which versions only the serialization. A change to what the spec promises (policy, relay or paging behaviour, not just prose) bumps it and adds a new copy; a raffle keeps the version it froze at commit, and the old copy is never edited again.

Operator procedures (cron, admin routes, close failures, the commitment post, how the spec copy is cut) are in `docs/raffle-draw-runbook.md`.
