Skip to main content

Fairness & Verify

Fairness and Verification

Most giveaway pickers ask you to trust a screen recording. Winner Picker works differently: the entry list and the rules are locked before the random number exists, and the random number comes from drand, a public network that nobody (including us and the host) can predict or change. Anyone can repeat the math and must get the same winners.

In Plain Words

  1. Freeze the list. Eligible entrants are sorted by X user id and fingerprinted with SHA-256. Changing one entry changes the fingerprint.
  2. Commit. The fingerprint, your rules, the number of winners and a drand round two rounds in the future are saved on our server and hashed into a commit hash, shown on screen before that round exists.
  3. Wait for drand. drand is run by independent organizations (the League of Entropy) and publishes a signed random value every 3 seconds. We check the signature before using it.
  4. Pick. The drand value and the fingerprint become a key, and the key produces a stream of numbers that picks entrants one by one with no bias toward any position.
  5. Check. The proof file and the eligible list are enough for anyone to recompute the whole draw.

Precise Spec

Everything below is enough to write your own verifier. All hashes are SHA-256 with lowercase hex output, all strings UTF-8.

  1. Participants digest. Eligible user ids as decimal strings (leading zeros stripped, duplicates removed), sorted by numeric value ascending. participantsDigest = SHA256(ids.join("\n")).
  2. Commitment. Version xcrop-winner-v2 holds tweetId, participantsDigest, participantCount, every rule, winnerCount, targetRound, chainHash, committedAt, followTargets and eligibilityAsOf. commitHash = SHA256(canonicalJSON(commitment)), where canonical JSON sorts keys recursively, drops undefined members and has no whitespace. Older xcrop-winner-v1 proofs still verify; the pick algorithm is the same.
  3. drand round. Chain quicknet (one round every 3 seconds, genesis 1692803367). targetRound is 2 rounds after the latest published one, and the server refuses a commit whose round has already been published. The randomness must equal SHA256(signature) and the BLS signature is checked against the quicknet public key.
  4. Key. key = SHA256(randomness + ":" + participantsDigest + ":"), raw 32 bytes. The trailing colon is an empty slot kept from an earlier host-seed field.
  5. Selection. Starting with counter = 0, compute r = HMAC_SHA256(key, "pick:" + counter) as a 256-bit big-endian integer and increment the counter. With n entrants left and limit = floor(2^256 / n) * n, a value r >= limit is thrown away and drawn again (rejection sampling, so there is no modulo bias). Otherwise remaining[r mod n] is picked and removed. Repeat until the list is empty or enough picks exist.
  6. Winners. Without a follow rule, the first winnerCount picks win. With one, the sequence is walked in order and a pick wins only if it follows every target; the others are listed as rejected.
  7. Reserves and re-rolls. The proof keeps the next picks after the winners (winners + 10 by default). Replacing a winner takes the next unused pick of the same sequence, skipping follow failures, and the sequence can be extended from the same key if the reserves run out. Each re-roll is stored with rank, replaced id, replacement, reason and time, and the verifier replays them in order.

How to Verify a Draw

The fastest way: open the public result page (/winner-picker/r/<id>) and press Verify now. Your browser downloads the eligible list, fetches the drand round, checks the signature and recomputes the fingerprint, commit hash, sequence, winners and re-rolls.

To check it without our site, you need the proof and the eligible list. The host gets both from the tool (Proof JSON and Eligible CSV). On a public result page, use Download entry list (CSV) and Proof JSON; save them as eligible.csv and proof.json (the script accepts either the bare proof or the whole public record). Then save the script below as verify.mjs and run node verify.mjs proof.json eligible.csv with Node 18 or newer. It has no dependencies. You can also paste it into a browser console and call await verifyXcropDraw(proof, ids). Open the checkRoundAt link in the output to compare the randomness with what drand itself serves.

verify.mjsJAVASCRIPT
// Winner Picker verifier. Recomputes the list fingerprint, the selection key and the winners.
const enc = new TextEncoder()
const hex = b => Array.from(new Uint8Array(b), x => x.toString(16).padStart(2, '0')).join('')
const sha256 = async s => new Uint8Array(await crypto.subtle.digest('SHA-256', typeof s === 'string' ? enc.encode(s) : s))
const cmp = (a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0)
const canon = v => v === null || typeof v !== 'object' ? JSON.stringify(v)
  : Array.isArray(v) ? '[' + v.map(canon).join(',') + ']'
  : '{' + Object.keys(v).filter(k => v[k] !== undefined).sort().map(k => JSON.stringify(k) + ':' + canon(v[k])).join(',') + '}'

// Proof formats xcrop-winner-v1 and v2. Rules (filters) only decide who is in the list, which the
// fingerprint fixes; the pick algorithm is the same for both. v2 adds followTargets and rerolls.
async function verifyXcropDraw(proof, ids) {
  const c = proof.commitment
  const sorted = Array.from(new Set(ids.map(s => String(s).trim().replace(/^0+(?=\d)/, '')))).sort(cmp)
  const digest = hex(await sha256(sorted.join('\n')))
  const commitHash = hex(await sha256(canon(c)))
  const keyBytes = await sha256(proof.beacon.randomness + ':' + digest + ':')
  const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
  const pool = sorted.slice(), seq = [], TWO256 = 1n << 256n
  let counter = 0
  while (pool.length && seq.length < proof.sequence.length) {
    const n = BigInt(pool.length), limit = (TWO256 / n) * n
    let r
    do { r = BigInt('0x' + hex(await crypto.subtle.sign('HMAC', key, enc.encode('pick:' + counter++)))) } while (r >= limit)
    seq.push(pool.splice(Number(r % n), 1)[0])
  }
  const followRule = (c.followTargets || []).length > 0 || !!c.conditions.mustFollowAuthor
  const passes = id => !followRule || (proof.followChecks || {})[id] === true
  const N = Math.min(c.winnerCount, sorted.length), winners = [], rejected = []
  let i = 0
  for (; i < seq.length && winners.length < N; i++) (passes(seq[i]) ? winners : rejected).push(seq[i])
  // A proof cut short must fail: N winners, unless every eligible entry was picked. Re-rolls never change the count.
  const completeOk = winners.length === N || seq.length === sorted.length
  // Re-rolls: each replacement must be the next unused pick (follow failures skipped), in order.
  let rerollsOk = true
  for (const r of proof.rerolls || []) {
    if (winners[r.rank - 1] !== r.replacedId) { rerollsOk = false; break }
    const skipped = []
    let next
    while (i < seq.length) { const x = seq[i++]; if (passes(x)) { next = x; break } skipped.push(x) }
    if (next !== r.replacementId || skipped.join() !== (r.skipped || []).join()) { rerollsOk = false; break }
    winners[r.rank - 1] = next
    rejected.push(...skipped)
  }
  const result = {
    digestOk: digest === c.participantsDigest,
    commitHashOk: commitHash === proof.commitHash,
    randomnessMatchesSignature: !!proof.beacon.signature && hex(await sha256(Uint8Array.from(proof.beacon.signature.match(/../g), h => parseInt(h, 16)))) === proof.beacon.randomness,
    // The beacon must be the committed round, and the commit must predate it (drand quicknet: genesis 1692803367, 3 s rounds).
    roundOk: proof.beacon.round === c.targetRound,
    committedBeforeRound: Date.parse(c.committedAt) < (1692803367 + (c.targetRound - 1) * 3) * 1000,
    sequenceOk: seq.join() === proof.sequence.join(),
    completeOk,
    winnersOk: winners.join() === proof.winners.join(),
    rejectedOk: rejected.join() === (proof.rejected || []).join(),
    rerollsOk,
    winners,
    // The commitment must name drand quicknet (lib/winner/drand.ts DRAND_QUICKNET.chainHash): the round timing above
    // and the beacon check assume that chain.
    chainOk: c.chainHash === '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971',
    checkRoundAt: 'https://api.drand.sh/' + c.chainHash + '/public/' + c.targetRound,
  }
  result.ok = result.chainOk && result.digestOk && result.commitHashOk && result.randomnessMatchesSignature && result.roundOk
    && result.committedBeforeRound && result.sequenceOk && result.completeOk && result.winnersOk && result.rejectedOk && result.rerollsOk
  return result
}

// RFC 4180 reader (quoted fields may contain commas and line breaks).
function parseCsv(t) {
  const rows = [[]]
  let f = '', q = false
  for (let i = 0; i < t.length; i++) {
    const ch = t[i]
    if (q) { if (ch === '"') { if (t[i + 1] === '"') { f += '"'; i++ } else q = false } else f += ch }
    else if (ch === '"' && f === '') q = true
    else if (ch === ',') { rows[rows.length - 1].push(f); f = '' }
    else if (ch === '\n') { rows[rows.length - 1].push(f.replace(/\r$/, '')); f = ''; rows.push([]) }
    else f += ch
  }
  rows[rows.length - 1].push(f)
  return rows.filter(r => r.join('') !== '')
}

// Node: node verify.mjs proof.json eligible.csv  (reads the "id" column of the CSV)
if (typeof process !== 'undefined' && process.argv && process.argv[3]) {
  const fs = await import('node:fs')
  const raw = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'))
  const proof = raw && raw.proof && raw.proof.commitment ? raw.proof : raw // accepts the bare proof or the public record JSON
  const rows = parseCsv(fs.readFileSync(process.argv[3], 'utf8').replace(/^\uFEFF/, ''))
  const col = rows[0].indexOf('id')
  const ids = (col >= 0 ? rows.slice(1) : rows).map(r => r[Math.max(col, 0)].replace(/^'/, ''))
  console.log(JSON.stringify(await verifyXcropDraw(proof, ids), null, 2))
}
ℹ
ok: true means the list matches the fingerprint, the commitment matches its hash, the randomness matches its signature, and the winners, skipped picks and re-rolls are exactly what the math produces. The script does not check the BLS signature itself; the result page does.

What Is Not Covered by the Proof

Two things rely on our servers rather than on math. First, who was collected: the proof shows the list was not changed after the commit, but it cannot show that X returned every reposter. The host cannot edit the list though: our server rebuilds the eligible list from its own stored collection with the committed rules, and refuses to publish a list that differs. Second, follow checks, which our server makes and stores at draw time (the host cannot edit them) but which cannot be proven cryptographically. Real draws need drand: if it cannot be reached, the draw stops and asks you to try again. Only sample mode falls back to a clearly labelled simulated round, which cannot be published or verified by anyone else.

More in Tools

Full documentation with search: xcrop.io/docs