Verify the lottery yourself. You don't have to trust us.
After a round closes, Luckotto waits the configured commit delay buffer, freezes the final paid ticket list, and anchors a fingerprint of that exact CSV on Bitcoin. The winning tiles come from a later Bitcoin block that does not exist yet when the fingerprint is anchored — so no one, Luckotto included, can rewrite the entrants to fit the result. Anyone can download the CSV, check the fingerprint, and replay the draw.
A round is acceptable when Luckotto locked the final ticket list before the draw block existed, committed the minor seed hash, revealed a matching minor seed, and replay selects the same 6 tiles and winning ticket shown publicly.
- 1Round closescloses_at
New reservations stop. Payments already confirmed for the round remain eligible.
- 2Commit buffer+4 blocks · ~40 min
Luckotto waits for chain depth so near-close deposits are not frozen too early.
- 3CSV lockedSHA-256(csv) · SHA-256(seed)
The final CSV bytes and hidden minor seed are fixed and hashed.
- 4Hash anchoredblock H · OP_RETURN
Outputs 0 and 1 store the CSV hash and minor seed hash.
- 5Future blockH + 2 blocks · ~20 min
A later Bitcoin block completes the draw seed.
- 6Replay resultprovisional until 100 confirmations
The committed CSV, minor seed, and future block select the winner.
The CSV hash and minor seed hash are already on-chain at block H before the draw block exists. Reordering, adding, or removing tickets after that point breaks the committed hash.
One limit on what this proves: that your own ticket was in the draw can only be checked after settlement, once the full CSV is published and shown to hash to the committed value — not before the draw runs. A ticket missing from that published, hash-matching list is then detectable by anyone, and is itself a protocol violation.
The round state is just evidence becoming irreversible.
The lifecycle below is the verification model. Each state adds a public artifact, and every transition is gated by time, chain depth, or a deterministic replay. A verifier does not need private database access; they need the artifacts named in the state they are checking.
Before the draw is resolved, canonical-chain changes move the round back to the affected chain-derived state. The committed CSV remains the input; only the commitment confirmation or draw seed can change.
A replay starts at locked: hash the exact CSV, verify the OP_RETURN, derive the future draw block, then run the draw against those bytes.
The byte-level protocol specification.
This is the normative specification of the Luckotto provably-fair protocol. Anything ambiguous here is a specification bug. An implementation built from this section alone, with no access to Luckotto code, MUST reproduce every resolved round bit for bit. The test vectors at the end are computed live by the production code that settles real rounds.
Protocol parameters
pool_size36 — Tiles are integers from 1 through pool_size.
hits_needed6 — Every ticket selects exactly this many unique tiles; the final survivor's tile set is stored as draw_tiles.
commit_delay_blocks4 — Blocks the chain tip must advance past a round's close before its CSV is frozen and committed on chain, so confirmed deposits bury before the artifact is fixed.
draw_delay_blocks2 — The draw block is this many heights after the commitment block. Its hash is combined with the revealed minor seed to derive the draw seed.
draw_hashes_per_elimination200,000 — The length of the sequential SHA-256 chain behind every elimination sample. A fixed protocol constant for the lifetime of the project; replay MUST use exactly this value.
min_payout_confirmations100 — Confirmations the draw block must accumulate before the draw is final and a win can be paid out. Until then the draw is provisional: a reorg of the draw block re-derives the winner.
draw_hashes_per_elimination is a draw input, not a tuning knob: two replays of the same round with different hash counts produce different digests and therefore different eliminations. That is why it is a fixed protocol constant rather than a per-round setting — every round, past and future, draws with the value above.
Exact CSV byte format
The final round CSV is the committed artifact. Its SHA-256 is taken over the exact bytes served at /rounds/<N>.csv.
- Encoding MUST be UTF-8 with no byte-order mark.
- Rows are separated by a single line feed (
0x0a). The file MUST end with a trailing line feed. - The header row is exactly
tiles,weight,ticketId,partnerPayoutAddress. tilesis the sorted tile list joined with underscores, e.g.3_8_13_21_25_31. Tiles are ascending and unique. Duplicate tile sets may appear on multiple ticket rows.weightis the ticket's draw weight in sats as a decimal integer. Weight is the ticket's fair value: the confirmed sats paid to it minus the partner's house-edge fee, computed per payment asfee = round(amount × house_edge)rounded to the nearest satoshi, with the edge fixed when the ticket was sold. Replay treats the committed weight as authoritative; the price paid is bookkeeping outside the protocol.ticketIdis the UUIDv7 Luckotto ticket id. It is the committed ticket identity and the final winner tie-break identity.partnerPayoutAddressis the partner's payout Bitcoin address snapshotted when the ticket was sold.- A field containing
",,,\n, or\ris wrapped in double quotes with inner quotes doubled (RFC 4180). This does not occur in practice with the current tile, UUID, address, and integer fields, but parsers MUST support it. - Only tickets with positive confirmed weight appear. Zero-weight reservations are excluded before the CSV is built.
Row order is deterministic: sort by weight descending, then by the sorted tile list ascending, then by ticketId ascending. Within one round, a ticket is identified by its UUID, not by its tile set.
Commitment transaction and draw seed
The commitment address for round N is the P2WPKH address of commitment_xpub/N (one unhardened child step). The canonical commitment transaction is the first outbound transaction from that address: among all transactions spending at least one input from the address, order confirmed transactions before unconfirmed ones, then by block height, then by block time, then by lexicographic txid; the first transaction in that order is canonical.
Output 0 of the canonical transaction MUST be:
script = OP_RETURN <32-byte payload> hex = 6a 20 <payload> payload = SHA-256 of the exact final CSV bytes
Output 1 MUST use the same script shape, with payload equal to SHA-256(raw minor seed bytes). Output 2 MUST roll remaining sats forward to commitment_xpub/(N + 1). The complete ticket list and hidden minor seed are therefore committed in one timestamped Bitcoin transaction before the draw block exists.
The draw block is the block at commitment_block_height + 2 in the active best chain, written as 64 lowercase hex characters in standard display order. Once that block exists, the raw minor seed is revealed and the draw seed is hex(bytes(minor_seed) || bytes(draw_block_hash)). The winner is computed from the committed CSV and published as soon as that block exists, but it is provisional until the draw block is buried 100 confirmations deep.
The draw always follows the canonical chain: if a reorganization changes the commitment confirmation or draw block while the result is still provisional, the recorded confirmation and draw block are replaced by the values from the new best chain and the winner is re-derived from the same committed CSV and minor seed. No win is ever paid out before 100 blocks have been built on top of the draw block.
One chain, consumed in segments
The entire draw derives its randomness from a single sequential SHA-256 chain seeded by the derived draw seed. The chain input is the UTF-8 string:
input = seed + ":draw:chain"
where seed is the 64-byte hex string derived from minor seed bytes plus draw block hash bytes. The first invocation hashes the input bytes; every later invocation hashes the previous 32-byte digest. The chain is consumed in segments of exactly draw_hashes_per_elimination invocations, each yielding one 256-bit sample value:
bytes = utf8(input)
next_value():
repeat draw_hashes_per_elimination times:
bytes = SHA256(bytes)
return bytes // 32 bytes, interpreted below as a big-endian integerThe whole draw is strictly sequential: invocation i+1 hashes the output of invocation i, and each elimination's segment continues from the previous elimination's final digest. No elimination — and no rejection retry — can be computed in parallel, out of order, or ahead of time; the only way to learn where a segment starts is to finish every segment before it.
Candidate sets and elimination choices
Parse the committed CSV into tickets, preserving file order. Discard zero-weight tickets. Duplicate ticket ids are protocol violations: replay MUST abort. Duplicate tile sets are valid and share a survivor group. Then eliminate losing tiles until one tile set remains:
chain = sha256_chain(seed) // section 4; one chain for the whole draw
candidates = tickets // file order; indexes below refer to this list
eliminated = {}
while count(unique_tile_sets(candidates)) > 1:
choices = next_elimination_choices(candidates, eliminated)
choice = rejection_sample(chain, choices) // section 6
eliminate choice.tile; add it to eliminated
candidates = [candidates[i] for i in choice.candidate_indexes]
winner = candidates[0] if count(candidates) == 1 else weighted_pick(candidates sorted by ticketId)
draw_tiles = winner.tilesElimination choices
Every pool tile that has not already been eliminated is tested. The survivor list for that tile is the ascending list of current candidate indexes whose tickets do not contain the tile. A tile with no survivors is not a valid choice. Every other tile becomes one weighted choice.
The choice weight is the sum of the surviving candidate ticket weights in sats, so duplicate tile sets contribute their combined weight while they survive together. Choices are ordered by their survivor index list lexicographically, with ascending tile number as the tiebreak. Reordering intervals under a uniform draw changes no probabilities; it only determines which valid elimination a given seed maps to.
Unbiased weighted selection
W = sum of choice weights // MUST be >= 1
limit = 2^256 - (2^256 mod W)
repeat at most 10,000 times:
v = chain.next_value() // one segment, section 4
if v >= limit: continue // rejected; consume the next segment
r = v mod W
running_sum = 0
for choice in choices, in the section-5 choice-list order:
running_sum += choice.weight
if running_sum > r: return choice
error // unreachable if W is correctRestricting accepted values to the largest exact multiple of W below 2^256 removes modulo bias: every weight unit has exactly the same probability. The probability that a single segment is rejected is (2^256 mod W) / 2^256 < W / 2^256, which is negligible for any realistic weight total; exhausting 10,000 segments for one elimination is cryptographically unreachable and replay MUST treat it as an error.
The boundary rule is cumulative weight with half-open intervals. For a choice list with weights [5, 3], r values 0 through 4 select the first choice and values 5 through 7select the second. Equivalently, after adding each choice's weight, select when running_sum > r, not running_sum >= r.
Winner uniqueness
A ticket survives exactly while none of its tiles have been eliminated. The tile-elimination phase stops as soon as all remaining candidates share one tile set. If multiple tickets remain, replay MUST consume the next chain sample and rejection-sample among those tickets by weight, ordered by ticketId. The stored draw_tilesvalue is the selected winner's sorted tile set, and winningTicketId is the selected UUID.
Reference test vectors
These vectors are recomputed on every render by the same production code that settles real rounds, using a chain length of 1 so they run instantly — real rounds always use the protocol constant. Check your implementation stage by stage before attempting a multi-minute production replay.
Vector CSV
tiles,weight,ticketId,partnerPayoutAddress 2_3_4_5_6_7,2500,018f58d2-2800-7000-8000-000000000002,tb1q022amqf3jv7mg744rcjmst222dfazqvm72jgzc 1_2_3_4_5_6,1000,018f58d2-2800-7000-8000-000000000001,tb1q022amqf3jv7mg744rcjmst222dfazqvm72jgzc 8_9_10_11_12_13,500,018f58d2-2800-7000-8000-000000000003,tb1q022amqf3jv7mg744rcjmst222dfazqvm72jgzc
csv_sha2567c7db4426b6392e9ab5ed35ec316953240a72d738bbec1e4eb7ada0321aed0db
csv_hash_op_return_script6a207c7db4426b6392e9ab5ed35ec316953240a72d738bbec1e4eb7ada0321aed0db
minor_seed7f8c2b8c0a6e2fca4e5d7c40a3f0dd8219e9d66e91d2b06f3f7d34d3f2c1b9a5
minor_seed_hash88819290a38ea85afd10d233d72c618aae4946f3f71d5776cb4f1a70e67bf557
minor_seed_hash_op_return_script6a2088819290a38ea85afd10d233d72c618aae4946f3f71d5776cb4f1a70e67bf557
draw_block_hash000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f
draw_seed7f8c2b8c0a6e2fca4e5d7c40a3f0dd8219e9d66e91d2b06f3f7d34d3f2c1b9a5000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f
Hash chain vectors (start of the chain)
input7f8c2b8c0a6e2fca4e5d7c40a3f0dd8219e9d66e91d2b06f3f7d34d3f2c1b9a5000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f:draw:chain
digest, 1 hash6837e56cb49025e77f2a83cbb6995b819d07ff34e33190506ec8259af8ff4ade
digest, 3 hashes891c955fc00b475bf3d1dc1ae32096e596a06052f3515d4631fe270bd1b265e8
Full draw result
draw_tiles2, 3, 4, 5, 6, 7
winning_ticket018f58d2-2800-7000-8000-000000000002
total_weight4000 sats
These vectors are for from-scratch implementations: make the chain length configurable while testing, reproduce the vectors at chain length 1, then pin it to the protocol constant before replaying real rounds.
Run the proof yourself in about five minutes.
Round 2 is resolved. Everything here is public evidence; none of it requires trusting this page. Run the commands, then compare the result against the on-chain commitment transaction in any block explorer.
curl -fsSL https://www.luckotto.com/api/luckotto/rounds/2/proof curl -fsSL -o round2.csv https://www.luckotto.com/rounds/2.csv shasum -a 256 round2.csv # expect 4ce96150bfe490be0a5bea414ecf2ca1f1c71daed75ddbd075e86414c37b96ff # replay inputs: minor seed 6dacd21139c8e0bcce05b1500e88379eb15103f47a7de481ac8e234e7e4b0ae7 # replay inputs: draw block c23a61066b075a123c0c90fb9be577a397a86a9c960676fbc29475ca332b13e4 # browser auditor: https://www.luckotto.com/embed/verify?partner=tb1q022amqf3jv7mg744rcjmst222dfazqvm72jgzc&playerIdentifier=demo&playerUname=demo&roundNumber=2 # draw calculator: https://www.luckotto.com/embed/draw-calculator?partner=tb1q022amqf3jv7mg744rcjmst222dfazqvm72jgzc&playerIdentifier=demo&playerUname=demo
The fastest full replay is the browser round auditor. For independent implementations, use the same CSV bytes, minor seed, and draw block hash in the draw calculator while checking each byte-level rule against the technical specification.
Each guarantee is a claim you can check, or reject.
The word guarantee is narrow here. These are not promises about business behavior, uptime, or custody — they are claims checkable from public round evidence. If the evidence does not satisfy a claim, the failure is visible to a verifier.
The committed ticket list cannot change without detection.
After close and the commit delay buffer, the final ticket list is hashed and that fingerprint is anchored on Bitcoin (see Commitment). Change one byte, reorder a row, or add or drop a ticket, and the hash no longer matches the value recorded on-chain.
Check it in the commitment calculator →The draw speed is a fixed protocol constant.
The hash count per elimination is a draw input — replay with a different count and you get a different winner. Luckotto fixes draw_hashes_per_elimination at 200,000 sequential SHA-256 hashes per elimination for the protocol's lifetime, published here and in the specification.
Replay it in the draw calculator →The seed is unknowable when the list is committed.
The seed is the hidden minor seed bytes plus the Bitcoin block hash 2 confirmations after the commitment block. The minor seed hash is committed first, and the block does not exist when the commitment is published.
Anyone can replay a round without the database.
The resolved round page, final CSV, commitment transaction, minor seed, draw block hash, and protocol constants are enough to recompute the outcome. Once that evidence is public, the production database is only a publication channel — not the authority.
Open the round auditor →A resolved draw has exactly one public winner.
Settlement eliminates losing pool tiles from the committed list, weighted by the ticket value that survives each elimination. Duplicate tile sets survive as a combined-weight group; if that group wins, one more draw-chain sample selects the winning ticket UUID by weight. Each ticket's odds stay equal to its weight divided by total weight, regardless of which tiles it picked.
The operator can verify a winner before paying.
Provable fairness protects Luckotto too. A winning ticket's deposit address commits to a partner-generated player secret, so a claim is checked against public chain data plus that secret before funds leave cold storage. With the payout delay, a compromised server cannot quietly redirect a prize: the replayable evidence would not match.
Re-derive an address from a payload →Cryptography proves the draw, not payout liveness.
The proof links a recorded payout transaction to a public round and shows the round was committed, drawn, and recorded consistently. It cannot make the operator broadcast a payout or settle on time. A missing payout is an operational default — visible to anyone watching the settlement address — not a hidden randomness failure.
Outside the cryptographic modelReplay narrows the trust surface to Bitcoin and the rules.
Verification starts with the public round ledger, not server access. The verifier fetches the final CSV, hashes it locally, checks the on-chain commitment, identifies the draw block, and runs the draw against the CSV with the protocol's fixed hash count. Each step removes an assumption until only public Bitcoin history and the published rules remain.
One canonical JSON object per round
Verifiers do not need to scrape the round page. Fetch /api/luckotto/rounds/<N>/proof for the public proof metadata, then fetch csvUrl and hash those exact bytes. Fields that are not available yet are null; status tells the verifier how far the round has advanced.
type RoundProof = {
roundNumber: number;
status: "open" | "closed" | "locked" | "drawn" | "resolved" | "paid_out";
csvUrl: string | null;
csvHash: string | null;
minorSeedHash: string | null;
minorSeed: string | null;
commitmentTxid: string | null;
commitmentBlockHeight: number | null;
drawBlockHeight: number | null;
drawBlockHash: string | null;
drawTiles: number[] | null;
winningTicketId: string | null;
payoutTxid: string | null;
};- Download the final CSV for a resolved round and hash the exact bytes with SHA-256.
- Compare that hash with the round CSV hash published by Luckotto.
- Derive the commitment address from the public commitment xpub and the round number.
- Inspect the first outbound transaction from that address and confirm output 0 is the OP_RETURN carrying the raw 32-byte CSV hash.
- Identify the block 2 confirmations after the commitment block and combine that block hash with the revealed minor seed.
- Replay the eliminations against the final CSV order with the protocol's fixed hash count, and confirm the draw tiles and winning ticket.
- If a payout txid is recorded, inspect the transaction and compare it with the published payout evidence.
proof = fetch_json(`/api/luckotto/rounds/${roundNumber}/proof`)
csv = download(proof.csvUrl)
assert SHA256(csv.bytes) == proof.csvHash
commitment_address = commitment_xpub.derive(proof.roundNumber)
commitment_tx = first_outbound_transaction(commitment_address)
assert commitment_tx.txid == proof.commitmentTxid
assert commitment_tx.vout[0] == OP_RETURN(proof.csvHash)
assert commitment_tx.vout[1] == OP_RETURN(proof.minorSeedHash)
assert SHA256(bytes(proof.minorSeed)) == proof.minorSeedHash
assert commitment_tx.block_height == proof.commitmentBlockHeight
seed = hex(bytes(proof.minorSeed) || bytes(proof.drawBlockHash))
result = replay_draw(seed, parse_csv(csv), DRAW_HASHES_PER_ELIMINATION)
assert result.draw_tiles == proof.drawTiles
assert result.winning_ticket == proof.winningTicketIdThe exact byte formats, ordering rules, and sampling algorithm — everything needed to reimplement this from scratch — are specified normatively in the technical specification above, which also renders executable test vectors computed by the production draw code.
Five deterministic components, each with its own check.
The rest of the page is for builders and auditors. The system is made of small deterministic components; each has one job, one public verification surface, and a calculator or reference page that exposes the same rule to a human.
Price funds weight; the partner fee is split out first.
Luckotto has no app-defined minimum ticket price. A player reserves one round-scoped deposit address and sends any positive Bitcoin amount — that amount is the ticket's price, and it is what the player-facing UI shows.
The draw cares about weight, the ticket's fair value: the price minus the partner's house-edge fee, fixed when the ticket was sold. Weight enters the committed CSV and sets the odds; the fee is tracked as partner profit. With a zero house edge, price and weight are identical. Payments that confirm after close roll forward into the next available round.
credited_ticket = original_ticket_or_late_payment_roll_forward(address) price = sum(confirmed_payments_credited_to(credited_ticket)) fee = sum(round(payment × house_edge)) // nearest sat, edge fixed at sale credited_ticket.weight = price - feeRead the player flow
Deposit addresses are derived from the ticket's payload.
Luckotto deposit addresses are not arbitrary server-side strings. The ticket's payload is signed with HMAC-SHA256 (a keyed fingerprint) under the hash-tweak label — a fixed protocol constant that namespaces the derivation. The first 21 bytes are split into seven 3-byte big-endian child indexes, which derive a native SegWit address from the configured xpub (an extended public key that derives addresses without exposing any private key).
The payload is the partner payout address, the player identifier, the round number, and the selected tiles. The player identifier is a per-player secret generated unpredictably by the partner, so only that player and their partner can re-derive the address. That is the point: at payout time, knowing the winning ticket's identifier is what proves the claim belongs to the right player. It proves the address belongs to the published derivation scheme, not custody of the private keys.
payload = partner_payout_address + player_identifier + round_number + tiles
hmac = HMAC_SHA256('hash-tweak', payload)
tweak = first_21_bytes(hmac)
path = split_into_7_uint24_be(tweak)
deposit_address = p2wpkh(deposit_xpub / path)extended public key = deposit_xpub payload = partner_payout_address, player_identifier, round_number, tiles derived path = /uint24_be(hmac[0:3])/.../uint24_be(hmac[18:21]) deposit address = p2wpkh(deposit_xpub / derived path)
Reserved tickets gain weight by payment rule.
A player-selected ticket is reserved for the round stored on its deposit address, and Luckotto records confirmed sats against the ticket they are credited to.
The allocation rule is short: unpaid reservations carry no draw weight, duplicate tile sets are allowed across tickets, and players may reserve multiple tickets.
ticket = reserved_ticket_for(deposit_address) credited_ticket = ticket if confirmed_before_close else roll_forward(ticket) credited_ticket.weight += payment_sats - partner_fee(payment_sats)Read the technical specification
The CSV and minor seed hashes are anchored before the draw block exists.
After a round closes, Luckotto waits a short finality buffer so deposits that confirmed near the close have time to bury before the list is fixed. At the commit point, the complete final ticket list is serialized as CSV, a plain-text table of the round's tickets. Luckotto hashes the exact byte stream and publishes that hash in output 0 of the first outbound transaction from the round's commitment address, which is itself derived from the public commitment xpub and the round number. Those exact CSV bytes are also stored immutably, and every later step — settlement and the published final CSV — reads them, never the live database.
What matters is ordering in time. The commitment transaction confirms first; the draw block comes later. A list chosen after seeing the block would produce a different hash, and the OP_RETURN value would no longer match. The widget below shows why: a single changed byte rewrites the whole fingerprint.
tickets = final_allocated_tickets_for_round(round) csv = format_csv(secret_sorted_ticket_order(tickets)) csv_hash = SHA256(csv_bytes) commitment_address_N = p2wpkh(commitment_xpub / roundNumber) tx = first_outbound_transaction(commitment_address_N) assert tx.vout[0] == OP_RETURN(csv_hash)
……Computing…
round N address = p2wpkh(commitment_xpub / N) canonical tx = first outbound spend from round N address vout 0 = OP_RETURN(SHA256(exact final CSV bytes)) vout 1 = OP_RETURN(SHA256(raw minor seed bytes)) vout 2 = commitment_xpub / (N + 1) draw block height = canonical_tx.block_height + 2
Losing tiles are eliminated from public entropy.
The draw uses the raw minor seed plus the block hash 2 confirmations after the commitment transaction confirms, eliminating losing pool tiles from the final committed CSV until one tile set remains. The winner is computed and published as soon as that block exists, but it stays provisional until the draw block is buried 100 confirmations deep — a reorg before then re-derives the winner from the same committed CSV and minor seed rather than leaving a stale result.
Each elimination choice is weighted by the draw weight behind the candidate tickets that would survive if that tile were eliminated. Each sample runs a sequential SHA-256 chain of exactly draw_hashes_per_elimination invocations, with rejection sampling so the 256-bit digest selects among weighted choices without modulo bias. If multiple tickets share the winning tile set, the next chain sample selects one ticket UUID from that group by weight.
seed = hex(bytes(minor_seed) || bytes(draw_block_hash)) tickets = parse_final_csv(csv) candidates = tickets eliminated = [] while count(unique_tile_sets(candidates)) > 1: tile = weighted_elimination_pick(seed, position, candidates, eliminated) eliminated.append(tile) candidates = tickets_without_eliminated_tiles(candidates, eliminated) winner = weighted_pick_by_ticket_id(candidates) if count(candidates) > 1 else candidates[0] draw_tiles = winner.tiles assert winner.id == round.winning_ticket_id assert draw_tiles == round.draw_tiles
chain seed = bytes of '<seed>:draw:chain' each sample advances the chain draw_hashes_per_elimination SHA-256 steps one chain feeds all eliminations; rejection retries advance it further tile = unbiased weighted sample from candidate elimination choices
Could a miner mine only the block that makes them win?
This is different from changing the CSV after the fact. A miner who finds a candidate draw block could privately test whether it produces a favorable outcome, publish it if it helps, and discard it if it hurts. Luckotto does not claim this is mathematically impossible — it makes the attack expensive to evaluate and irrational unless the prize is extraordinary.
The final CSV order is randomized and hidden until after the draw.
Luckotto secret-sorts the final CSV before hashing it, and the on-chain commitment publishes only the SHA-256 hash — not the ordered file. A third-party miner therefore cannot cheaply test private candidate blocks against the real draw inputs before deciding whether to publish a block.
The draw is slow enough that withholding is hard to act on.
Each elimination sample advances one fixed sequential chain by 200,000 SHA-256 hashes. A full draw can require up to pool_size - hits_needed elimination samples — more whenever rejection sampling retries — and it cannot be parallelized across cores or skipped, so even an operator running its own miners must burn scarce wall-clock time evaluating a found block before discarding it, while the rest of the network races to publish a competing block that would orphan it.
The economics bound the attack before the cryptography does. Discarding a valid block forfeits the full reward — subsidy plus fees — with certainty, in exchange for an uncertain improvement in lottery odds. Withholding only pays when the expected prize gain exceeds the block reward, which prices the attack far above any ordinary round. The minor seed mainly stops outside miners from learning the draw result before the draw block; the slow hash chain mainly constrains Luckotto itself, if it ever combined lottery operation with mining.
Why the construction accepts these costs.
Waiting for a future block adds settlement latency, but it is what keeps the operator from knowing the seed while choosing the CSV. Publishing an OP_RETURN commitment costs chain fees, but it creates a timestamped anchor independent of Luckotto's database. Committed ticket UUIDs keep the public winner unambiguous even when multiple tickets share the same tile set.
Post-commitment ticket mutation becomes detectable, the draw speed is fixed for the protocol's lifetime, duplicate-winner ambiguity is removed, and players get a repeatable path from public round data to the displayed winner.
A finality buffer before the CSV and minor seed hash are committed, then a 2-block wait to the draw block; the winner is published immediately but stays provisional, re-derived if a reorg changes the draw block, until it is buried 100 confirmations and a payout can be made. Plus one on-chain commitment fee per round and rejection of duplicate ticket selections.
What the proof covers, and where trust still lives.
The primary adversary is an operator who would prefer to choose or avoid a winner after learning the draw seed. The commitment blocks that attack by making the final CSV hash and minor seed hash public before the draw block exists. A verifier rejects a round if the CSV does not match the OP_RETURN hash, if the minor seed does not match its committed hash, if the draw block is not the specified future block, or if replay with the fixed hash count does not reproduce the displayed result.
The model assumes Bitcoin block hashes are not controlled by the operator and that the xpubs below are the intended keys. It does not assume this site's JavaScript is honest: the browser calculators are a convenience, and the same checks run from the downloadable script or a from-scratch implementation of the specification. A miner with meaningful influence over the future block could try to bias the entropy, but the result stays provisional — re-derived from the canonical chain — until the draw block is buried 100confirmations deep, no win is paid before then, and the public commitment makes the operator's own post-hoc manipulation visible. The security bound for block hashes as a public beacon is analyzed in Bonneau, Clark & Goldfeder (2015).
Two limits sit outside the cryptography, and they differ in kind. Because the final CSV bytes are withheld until after the draw (Defense A), a player cannot prove their ticket was included before the draw runs — only detect exclusion afterwards, when the published CSV must both match the pre-draw commitment and contain their ticket. The on-chain hash prevents changing the list after commitment, but the full ticket set is not third-party reproducible before publication.
The operational limits are separate: the model does not prove that every deposit was accepted, that settlement is prompt, that wallet custody is healthy, or that a payout will be broadcast. It proves a narrower property — once a round is published as resolved, its committed ticket list, draw seed, draw result, and winner can be checked from evidence outside the operator's database.
The published extended public keys.
Every claim above is relative to these keys on Bitcoin testnet4. Record them independently — if this page ever shows different keys, that substitution is itself the evidence.
- Copy both keys somewhere outside this site, and snapshot this page (for example via the Wayback Machine) so you have a dated independent copy.
- Confirm the keys are in use: derive round 1's commitment address from the commitment xpub in the commitment calculator and check it against the chain.
- The same keys appear in the technical specification; cross-check them there.
Calculators, records, and external sources.
The white paper is only useful if you can leave the prose and recompute the relevant part of a real round, or check the assumptions against independent sources.