Anyone can recheck every draw.
Luckotto publishes the complete ticket list for every round — and writes its cryptographic fingerprint into the Bitcoin blockchain — before the block that decides the winner exists. The winner is then computed by fixed, public arithmetic from that sealed list, the later block's hash, and a round secret that is fingerprinted at the seal and revealed when the round resolves. Nothing in that sentence requires trusting Luckotto: once the round resolves, every input — the list, the fingerprint, the block, the secret, and the arithmetic — is public, so anyone can rerun the draw and confirm the result.
This page builds that claim from first principles: it draws a toy round by hand, checks a real round in a few shell commands, and ends with the exact specification needed to reimplement the verifier from scratch. To rig a round after it locks, Luckotto would have to forge a confirmed Bitcoin transaction or serve CSV bytes the chain contradicts — both detectable from public data — or find a second preimage for a published SHA-256 hash, which is computationally infeasible. The one subtler avenue, privately re-rolling the draw with a colluding miner, is priced and bounded in the threat model.
- SealHMAC-SHA256(list) → OP_RETURN
When sales close, the full ticket list is published and its 32-byte fingerprint is written into a Bitcoin transaction.
- Waitblock H + 2
The winner depends on a Bitcoin block that does not exist yet — 2 blocks after the commitment transaction confirms.
- Replaysame inputs → same winner
Anyone recomputes the winner from the sealed list and the public block. Same inputs, same winner, every time.
Change one byte of the sealed ticket list and its fingerprint no longer matches what Bitcoin already confirmed.
- Public — anyone can fetch or recompute it
- Held by Luckotto until reveal
- Bitcoin chain — neither party controls it
In a hurry: check a round now · read the threat model · jump to the LUCKOTTO-1 spec
A round is a public list of funded tickets.
Ordinary players buy tickets from resellers, who fund them with Bitcoin; the sales channel is documented in the docs. Fairness only needs four terms.
Exactly 6 distinct tiles from a 36-tile field, chosen at purchase. A ticket's committed identity is its round number plus entry number, and its draw weight is exactly the sats that funded it. Tickets cannot be edited or topped up once created; a reseller increases exposure by creating another ticket.
A numbered set of tickets. Sales stop at the scheduled close; the round then locks, seals its final list, and later resolves. An empty round still seals, commits, and resolves — with no winner.
The sum of every ticket's funded weight in the round, paid to the winning ticket's reseller at the jackpot address snapshotted when the ticket was created.
A ticket's chance of winning is exactly its weight divided by the round's total weight. The tile-bucket draw below preserves that ratio at every step, and any round with a funded ticket is guaranteed to produce a winner.
The only secret has a public fingerprint from the moment it exists.
At seal — immediately after the round closes, before any transaction broadcasts — the final CSV bytes, the round secret hash, and the CSV HMAC all become public. The OP_RETURN that confirms on-chain later is a stronger anchor of the same 32 bytes, not the first publication.
The round secret is the only private artifact. It is created at seal, and its SHA-256 fingerprint is published at that same moment — so a swapped secret is detectable forever after. Withholding the secret can stall a resolution, because nothing can compute draw samples without it; it can never alter the sealed list.
- Sales openopenPublic
- ticket list — still changing
Luckotto onlynothing — the round secret does not exist yet
- Sealedcloses_at · sealPublic
- final CSV bytes
- secret hash
- CSV HMAC
Luckotto only- round secret — created now
- Anchoredblock H · OP_RETURNPublic
- on-chain anchor of the same HMAC
Luckotto only- round secret
- Draw blockblock H + 2Public
- draw block hash
- checkpoints — as computed
Luckotto only- round secret
- draw samples — only Luckotto can compute them
- Resolvedresolved_atPublic
- round secret — revealed
- draw samples, tiles, winner
Luckotto onlynothing
- Final at depthdraw block 100 deepPublic
- same record, now reorg-final
Luckotto onlynothing
The only private artifact is the round secret, and its SHA-256 fingerprint is public from the moment it exists. Withholding it can stall a resolution; it can never change the sealed list.
One honest wrinkle: publication gates on the seal itself (the moment round_csv_hmacexists), not on the status label. The status flips to “locked” at the scheduled close, so there is a brief window after close — before the seal job runs — in which nothing is published yet.
A toy round with three tickets can be drawn by hand.
In the real protocol each sample is a 256-bit number derived from the round secret and a checkpoint hash — the artifact map shows where they come from. The sample values below are fixed teaching numbers, but every derived figure — buckets, pin positions, eliminations, the winner — is computed by the canonical draw core that defines these semantics, the same functions the browser verifier replays, and a unit test pins the outcome so this example cannot drift from the spec.
Entries 1 and 2 chose identical tiles on purpose — the winner draw at the end shows how weight, not tile choice, separates them.
Every undrawn tile held by a live ticket becomes a bucket, scanned in ascending tile order. A bucket's weight is the funded sats of every live ticket holding that tile, so each ticket contributes its full weight to each of its 6 tiles. The sample drops a pin on a measuring tape whose segments are funded weight — a tile's chance is its width.
Draw 2 lands on tile 20. A live ticket must contain every tile drawn so far; entry 3 has no tile 20 and drops out.
After 6 tiles, the live tickets are exactly those whose tile set equals the drawn set — entries 1 and 2. The final sample walks their weights in ascending entry order.
Identical tiles do not mean identical odds: entry 1 held 50,000 of the 80,000 live sats, entry 2 held 30,000. The pin landed in entry 2's segment.
A fair question: isn't modulo biased? Reducing a random number modulo a weight total is biased when the number's range is not an exact multiple of the total. It is — and the bias is bounded. Each sample is a uniform 256-bit integer; reducing it modulo a round's total weight W skews any outcome's probability by less than W ÷ 2²⁵⁶. Even if every bitcoin in existence were staked on one round, that is under one part in 10⁶¹ — not zero in principle, and far below anything measurable in practice. The spec therefore states odds plainly as weight ÷ total weight.
One map connects the ticket list to the winner.
Read it left to right, top to bottom: the immutable ticket rows derive the CSV bytes; the bytes are fingerprinted under an HMAC; the HMAC is anchored in an OP_RETURN that confirms at block H; Bitcoin itself then advances 2 blocks to the draw block; sequential hashing stretches that block's hash into checkpoints; the revealed secret turns checkpoints into samples; and the samples walk the weighted buckets down to a winner. Every arrow is a computation anyone can run.
- ticket rowsimmutable once sealed
- CSV bytesentry_number,tiles,weight,reseller
- CSV HMACHMAC-SHA256(hex_decode(secret_hash), csv_bytes)
- OP_RETURN, vout 06a 20 ‖ 32-byte HMAC · 0 sats
- block Hcommitment confirms
↑ key = SHA-256(secret) · public from seal
↓ key = the raw secret · private until resolution
Bitcoin advances +2 blocks (~20 min) ↓
- draw block hashheight H + 2
- checkpoints 1..7SHA-256 × 1,000,000,000, strictly sequential
- samples 1..7HMAC-SHA256(hex_decode(secret), hex_decode(checkpoint_i))
- 6 tile drawssample mod bucket_weight
- winnersample₇mod live_weight → entry
The round secret feeds two different locks with two different keys. The ticket-list commitment is keyed by SHA-256 of the secret, public from the moment the round seals — so checking a ticket's inclusion never needs the secret. The draw samples are keyed by the raw secret itself, private until resolution — so nobody can rehearse an outcome before the draw block exists. Neither key carries a tag, prefix, or round number: what binds a commitment to its round is the address it spends from, not the bytes it carries.
The latest resolved round can be checked right now.
Everything below is live data, not an illustration. The full packet needs a resolved round because it includes the revealed secret and the draw; commitment verification does not — the committed CSV, round secret hash, and CSV HMAC are published for any round as soon as it locks.
curl -fsSL https://luckotto.com/rounds/1.csv -o round1.csv
# response headers echo X-Luckotto-Round-Secret-Hash and X-Luckotto-CSV-HMAC-SHA256;
# X-Luckotto-Round-Secret appears only once the round is resolved
ROUND_SECRET_HASH=b7d08239267e9ed0fe8843688ff664b4d676d796c9376ad651682df823247595 node -e "const crypto=require('node:crypto'),fs=require('node:fs'); console.log(crypto.createHmac('sha256', Buffer.from(process.env.ROUND_SECRET_HASH, 'hex')).update(fs.readFileSync('round1.csv')).digest('hex'))"
# expect 50223e34c1c02efa16d6e7bc13920c1b5bf4a3fac5da8418cd11fe8546632deeROUND_SECRET=78367f685619d15fe14f46e1c5cee79cee722a0a7c35baa8b7eb2e35209e6b1a node -e "const crypto=require('node:crypto'); console.log(crypto.createHash('sha256').update(Buffer.from(process.env.ROUND_SECRET, 'hex')).digest('hex'))"
# expect b7d08239267e9ed0fe8843688ff664b4d676d796c9376ad651682df823247595curl -fsSL https://mempool.space/api/tx/8094eafd2487200ed67e709b06db1463457f7905957110be6240231fadc1d7bd | EXPECT_SCRIPT=6a2050223e34c1c02efa16d6e7bc13920c1b5bf4a3fac5da8418cd11fe8546632dee node -e "let input=''; process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => { const tx = JSON.parse(input); const out = tx.vout?.[0]; if (tx.status?.confirmed !== true || out?.scriptpubkey !== process.env.EXPECT_SCRIPT || out?.value !== 0) throw new Error('vout[0] is not the expected confirmed zero-sat OP_RETURN'); console.log(out.scriptpubkey); });"
# expect 6a2050223e34c1c02efa16d6e7bc13920c1b5bf4a3fac5da8418cd11fe8546632dee
# the OP_RETURN script of output 0, read from a third-party node —
# this command never touches Luckotto's serversOr verify natively with the Go reference tool — it runs every check in minutes and trusts nothing from this page:
go install github.com/luckotto/calc/cmd/luckotto-verify@latest luckotto-verify --app-url https://luckotto.com --esplora-url https://mempool.space/api 1
The browser verifier automates all of this and more: eight checks run entirely in your browser, every input is editable with a chip showing where its value came from, and the Esplora endpoint and commitment descriptor are swappable — point them at your own node to remove the last third party. The published-commitment check works from the moment a round locks; the on-chain checks fill in once the commitment transaction confirms; the secret reveal and full winner replay need a resolved round. Checkpoint equality can be compared against the published set in an instant, or regenerated from the draw block hash to prove the chain rather than trust it — the verdict wording distinguishes the two.
What is proved is exact, and what is not proved is listed.
The commands above prove more than they may seem to — and less than a careless reading would assume. Both lists below are part of the protocol's honesty.
Guarantees
- Luckotto cannot change the entrants after the winner could be known.After ticket sales close, Luckotto seals the final list of paid tickets with a public fingerprint. The list itself is public from lock, so anyone can immediately check it matches the sealed commitment.
- The winner is based on a public event that happens later.That event is a Bitcoin block 2 blocks after the commitment transaction confirms. The block does not exist yet when Luckotto locks the entrants.
- Anyone can recompute the winner after the round resolves.The sealed ticket list, the round secret revealed at resolution (checkable against the hash published at lock), the later public event, and the public draw rules are enough to independently produce the same winning ticket and the same draw tiles.
- A paid ticket's inclusion is checkable before the draw.The committed CSV, round secret hash, and CSV HMAC are published as soon as the round locks — before the draw block exists. A paid ticket either appears in that locked list or the round should be rejected.
Non-guarantees
- It does not force Luckotto to pay on time.The proof can show which ticket won. Jackpot payment is a separate, human-signed act, and the recorded jackpot txid is an operator record — not an automated guarantee.
- It does not make Bitcoin randomness perfect.A miner could try to withhold a block if the jackpot justified it. The design makes that expensive to evaluate; it does not make it mathematically impossible.
- It does not make an early resolution final.A resolution is provisional until the draw block is 100 blocks deep or a jackpot txid is recorded. An earlier reorg re-derives the winner from the same committed CSV — the ticket set cannot move, only the draw block can.
- It does not cryptographically enforce publication order before the reveal.That the artifacts appear at seal rather than later is enforced by code paths and observable in practice. What cryptography enforces is stronger where it matters: whatever is published must be consistent with the sealed fingerprint, forever.
- It does not require trusting this page.The browser tools are convenient, but the CSV, the Bitcoin chain, the constants, and the algorithms below can be checked entirely without them.
Every fairness claim has a concrete failure condition.
Every check compares one value Luckotto publishes against one value sourced or recomputed independently — the page never asks anyone to compare Luckotto against Luckotto. A review can mark each claim pass or fail without relying on the prose.
The browser verifier automates these attachments as 8 machine checks; every pin below states its accept and reject condition in full.
The ticket file was fixed before any draw sample could exist.
- Evidence
- The first confirmed spend of the round commitment address, its OP_RETURN output 0, and its confirmation height.
- Accept when
- The first confirmed spend from the round commitment address has output 0 equal to OP_RETURN(HMAC_SHA256(hex_decode(round_secret_hash), csv)) and confirms before the derived draw block.
- Reject when
- No canonical transaction exists, output 0 does not decode to the CSV HMAC, or the transaction confirms after the specified draw block.
- Check it now
- Checks 2 and 3 on the browser verifier cover the first-spend and OP_RETURN clauses and check 5 pins the timing clause — or shell command 3 in the evidence packet reads the OP_RETURN anchor from a third-party node without touching this site.
The served CSV bytes are the committed artifact.
- Evidence
- /rounds/<N>.csv, roundSecretHash (published at lock; header X-Luckotto-Round-Secret-Hash), X-Luckotto-CSV-HMAC-SHA256.
- Accept when
- HMAC_SHA256(hex_decode(roundSecretHash), exact downloaded bytes) equals the published roundCsvHmac from lock, and the on-chain OP_RETURN payload once the commitment confirms; rows are sorted by entry_number. The check does not wait for resolution; X-Luckotto-Round-Secret appears only once resolved.
- Reject when
- Any byte, line ending, row, order, or header change produces a different HMAC.
- Check it now
- Check 1 on the browser verifier, or shell command 1.
The draw samples were not chosen after the winner was knowable.
- Evidence
- Commitment confirmation height and the Bitcoin block at height + 2.
- Accept when
- drawBlockHeight equals commitmentBlockHeight + draw_delay_blocks on the active chain, and the checkpoint chain starts from that drawBlockHash.
- Reject when
- The draw uses a different block, missing block hash, stale reorged block, or unexpected delay.
- Check it now
- Checks 5 and 6 on the browser verifier.
The public replay selects the displayed winner and the displayed tiles.
- Evidence
- CSV, revealed round secret, draw block hash, locally computed checkpoints, LUCKOTTO-1 constants.
- Accept when
- A fresh implementation of the algorithms below returns the same draw tiles and the same winning ticket shown by the round ledger. A matching winner with different draw tiles is a reject, not a pass.
- Reject when
- Replay diverges, duplicate entry numbers appear, a ticket has invalid weight or tiles, the hash count differs, or the tiles mismatch even when the winner matches.
- Check it now
- Checks 7 and 8 on the browser verifier — or luckotto-verify runs the full replay natively in minutes.
Payment is outside draw correctness.
- Evidence
- No payment field is part of draw verification.
- Accept when
- The replayed winner and tiles match the published round using only the committed CSV and public randomness, with every jackpot payment field ignored.
- Reject when
- The published winner or tiles disagree with that replay. A missing or wrong jackpot payment never rejects the draw; escalate it separately as a settlement failure.
- Check it now
- None of the verifier's 8 checks reads a payment field — that absence is the claim.
Ticket inclusion is provable as soon as the round locks.
- Evidence
- The committed CSV, round secret hash, and CSV HMAC, all published at lock — before the draw block exists.
- Accept when
- A ticket is included if its entry_number appears in the published CSV whose HMAC matches the sealed commitment, checkable before the draw.
- Reject when
- A paid ticket's entry_number is missing from the HMAC-matching published CSV. Reject the round as a protocol violation.
- Check it now
- Shell command 1 plus a grep of the CSV — this works for any locked round, resolved or not.
Cheating requires the secret, block production, and sequential work.
The proof blocks the operator from changing the final ticket list after learning the draw samples. It assumes the canonical descriptors are the intended keys and that no one reorganizes Bitcoin outright for a jackpot; the race below addresses the cheaper attack — quietly testing candidate draw blocks before they are published.
- has candidate blocks
- lacks the round secret
Cannot compute any draw sample, so cannot evaluate whether any candidate block helps anyone.
Defended. Every candidate block looks the same to someone without the secret.
- has the round secret
- lacks block production
Cannot choose which block appears at height H + 2.
Defended. The only unilateral move is stalling the reveal — publicly visible, and powerless to change the sealed list.
- has the round secret
- has candidate blocks
7 × 1,000,000,000 = 7,000,000,000 strictly sequential SHA-256 per candidate, with no parallel shortcut across cores or ASICs, inside one ~10-minute block race per attempt, forfeiting the block reward of every discarded block
The honest residual. The protocol does not claim to prevent operator–miner collusion; it prices each attempt and makes the outcome final only at depth.
A resolution is provisional until the draw block is 100 blocks deep or the jackpot is recorded. Any earlier reorg re-derives the winner from the same committed CSV — the ticket set cannot move, only the draw block can.
The hidden round secret matters here only before resolution, and only for draw samples: a third-party miner cannot derive them without it. It does not hide the committed CSV — the CSV, round secret hash, and CSV HMAC are public from lock, so anyone can verify the committed bytes before the draw block exists. Without the sequential work floor, one candidate test would cost microseconds and private draw grinding would be trivial and invisible; with it, learning even the first drawn tile costs one full checkpoint of wall-clock time. Reorgs deeper than 6 blocks, or any reorg that crosses a recorded commitment, halt scanning entirely for manual intervention.
This section is the normative reference for an independent reimplementation.
The normative algorithm is the logic below, together with the constants. A reviewer should be able to implement it in any language, feed it a round number, the CSV, Bitcoin evidence, and derived checkpoints, and get the same result. The browser verifier is a convenience tool, not authority.
The LUCKOTTO-1 constants for this deployment.
A verifier pins these values with the deployment, and each value exists for a reason a reviewer can check. The network is not repeated in a round artifact; it is part of the endpoint and descriptor choice. GET /api/constantspublishes this panel's public numeric constants and commitment descriptor in machine-readable form; the spec version, CSV path, and derivation formulas are defined here.
1. Parse the exact CSV bytes
Verify the raw bytes with the round secret hash first — the HMAC covers every byte, including LF line endings and the trailing newline, so download the file rather than copy text. The round secret hash is published as soon as the round locks; the CSV response echoes it in the X-Luckotto-Round-Secret-Hash header alongside X-Luckotto-CSV-HMAC-SHA256 as a convenience. Treat headers as hints, because the on-chain OP_RETURN in step 2 is the evidence. Then parse rows and require canonical entry-number order.
csv_url = origin + '/rounds/' + round_number + '.csv' round_secret_hash = round.roundSecretHash # SHA256(hex_decode(round_secret)), published at lock bytes = fetch(csv_url) assert bytes are UTF-8 with no BOM csv_hmac = HMAC_SHA256(hex_decode(round_secret_hash), bytes) # once resolved, round.roundSecret is revealed; check the preimage: assert SHA256(hex_decode(round_secret)) == round_secret_hash rows = parse_csv(bytes) assert header row == ['entry_number', 'tiles', 'weight', 'reseller'] previous_entry_number = 0 for row in rows after the header: entry_number = positive integer assert entry_number > previous_entry_number previous_entry_number = entry_number tiles = parse integers split by '_' assert count(unique(tiles)) == 6 assert every tile is between 1 and 36 weight = decimal integer sats; assert weight >= 0 # tickets cannot be created unfunded; the weight > 0 guard still # excludes any zero-weight row so the draw never divides by zero tickets = rows with weight > 0, in entry_number order
2. Rebuild the commitment check
The commitment transaction is not whatever the server says it is. Derive the round address from the published Taproot descriptor, find its first confirmed spend, and decode output 0. One address per round number is what makes 'the first confirmed spend' a well-defined, server-independent way to locate the commitment.
address_N = p2tr(COMMITMENT_DESCRIPTOR / round_number) canonical_tx = first_confirmed_spend_from(address_N) assert canonical_tx.txid == recorded_round_commitment_txid assert count(canonical_tx.vout) == 2 committed_csv_hmac = decode_op_return_hmac(canonical_tx.vout[0].script) # script is exactly 6a 20 <32 bytes>, value 0 sats assert csv_hmac == committed_csv_hmac assert canonical_tx.vout[1] pays p2tr(COMMITMENT_DESCRIPTOR / (round_number + 1)) # round 1 spends the configured initial outpoint; # round N spends round N-1's vout[1], so the funding chain is unbroken
No magic prefix, no round number, no version byte — the payload is exactly the raw 32-byte CSV HMAC. Round binding is positional, shown below.
- descriptor / N − 1in ← spends round N−2's vout[1]vout[0] · OP_RETURN · 6a20 ‖ HMAC(N − 1)vout[1] · funds round N →
- descriptor / Nin ← spends round N−1's vout[1]vout[0] · OP_RETURN · 6a20 ‖ HMAC(N)vout[1] · funds round N + 1 →
- descriptor / N + 1in ← spends round N's vout[1]vout[0] · OP_RETURN · 6a20 ‖ HMAC(N + 1)vout[1] · funds round N + 2 →
Round 1 spends the configured initial outpoint instead of a previous vout[1]. The commitment for round N is defined as the first confirmed spend of taproot child N of the public descriptor — a second, different commitment cannot happen silently, because it would be a visible competing spend of the same address.
3. Verify the checkpoint chain
Checkpoints are numbered 1 through 7, and the draw block hash acts as checkpoint 0. Checkpoint 1 advances the draw block hash by exactly the canonical hash count, and each later checkpoint advances the previous checkpoint the same way. The hashes are strictly sequential on purpose — the threat model above explains the work floor.
draw_height = canonical_tx.block_height + DRAW_DELAY_BLOCKS
draw_block_hash = bitcoin_block_hash(draw_height)
checkpoints = published checkpoints keyed 1..(TILES_PER_TICKET + 1)
previous = draw_block_hash
for n in 1..(TILES_PER_TICKET + 1):
bytes = hex_to_bytes(previous)
repeat DRAW_HASHES_PER_CHECKPOINT times:
bytes = SHA256(bytes)
assert hex(bytes) == checkpoints[n]
previous = checkpoints[n]4. Draw TILES_PER_TICKET tiles
The live list starts in entry-number order. Each checkpoint is HMACed with the round secret before sampling aggregate tile buckets in ascending tile order. Every bucket weight is the funded sats of the live tickets holding that tile, so each ticket's conditional odds stay exactly weight-proportional through every tile draw — the worked example above runs this by hand.
live = tickets # entry_number order if live is empty: resolve with no winner # a header-only CSV still seals and commits drawn = set() for n in 1..TILES_PER_TICKET: sample = HMAC_SHA256(hex_decode(round_secret), hex_decode(checkpoints[n])) tile = sample_tile_bucket(sample, live, drawn) drawn.add(tile) live = filter(live, ticket => tile in ticket.tiles) assert live is not empty winner_checkpoint = checkpoints[TILES_PER_TICKET + 1] sample = HMAC_SHA256(hex_decode(round_secret), hex_decode(winner_checkpoint)) winner = modulo_sample(sample, live) draw_tiles = drawn # published in draw order, one tile per checkpoint
5. Map samples with modulo
A draw sample is a big-endian uint256 reduced modulo the current total weight. Tile samples use aggregate tile buckets; the final sample uses live tickets. The bucket construction is why a winner is guaranteed: only tiles held by a live funded ticket are drawable, so in a round with at least one funded ticket the live list can never empty, and a ticket's overall win probability is exactly its weight over the round's total weight.
modulo_sample(sample, items):
assert items is not empty
pick = uint256(sample) mod sum(item.weight for item in items)
for item in items:
if pick < item.weight: return item
pick -= item.weight
sample_tile_bucket(sample, live, drawn):
buckets = []
for tile in 1..TILE_COUNT:
if tile in drawn: continue
weight = sum(ticket.weight for ticket in live if tile in ticket.tiles)
if weight > 0: buckets.append({ tile, weight })
return modulo_sample(sample, buckets).tile6. Check against a resolved round
Use any resolved round number. The CSV and Bitcoin chain are the evidence; the CSV HMAC is keyed by the round secret hash, checkpoints are derived from the draw block hash, and samples are derived from the revealed round secret. An empty round still seals and commits a header-only CSV, and resolves with no winner.
round_number = N
csv_bytes = fetch('/rounds/<N>.csv')
tickets, csv_hmac = parse_csv_and_hmac(csv_bytes) # keyed by hex_decode(round_secret_hash)
canonical_tx, committed_csv_hmac = verify_commitment_transaction(round_number)
assert csv_hmac == committed_csv_hmac
assert SHA256(hex_decode(round_secret)) == round_secret_hash
draw_block_hash = bitcoin_block_hash(canonical_tx.block_height + DRAW_DELAY_BLOCKS)
checkpoints = compute_checkpoints(draw_block_hash)
samples = [HMAC_SHA256(hex_decode(round_secret), hex_decode(c)) for c in checkpoints]
result = replay_draw_from_samples(tickets, samples)
# a resolution is provisional until the draw block is
# MIN_JACKPOT_CONFIRMATIONS deep or a jackpot txid is recorded- Two different HMAC keys. The CSV commitment is keyed by
sha256(secret), public from seal — ticket inclusion never needs the secret. Draw samples are keyed by the raw secret, private until resolution. - No domain separation anywhere, deliberately. No tags, prefixes, or round numbers appear in any hash or HMAC input.
- Everything is raw bytes. Every key and message is hex-decoded before hashing; the only string input in the protocol is the CSV itself, as UTF-8.
- The OP_RETURN carries no round number. Binding is positional: the address is taproot child N of the public descriptor and the funding chain is unbroken.
- Checkpoints are not samples. Anyone can compute checkpoints from the draw block hash at any time; only the secret holder can compute samples before the reveal.
- Derived, never stored. The secret hash and the CSV are recomputed on every read and re-checked against the sealed HMAC; the server refuses to serve bytes that no longer match.
- “Resolved” is not final. The draw follows the canonical chain until the draw block is buried 100 deep or a jackpot txid is recorded.
Where to inspect each artifact.
Three ways to run the audit, in increasing order of independence: the browser verifier, the shell packet above, and the native Go tool — plus the round ledger and the background reading.