Provably fair, and auditable by anyone.
A normal provably fair system lets you take steps to verify that your own result was fair. We decided to take that up a few notches and build a system that lets anyone verify any historic round. That means there is no strange sequence of actions you need to perform, such as recording a server-seed hash beforehand. More importantly, unlike a normal provably fair system, Luckotto is auditable by everyone, so there is never a "he said, she said" situation. At a high level, when a round concludes, we seal the final list of paid tickets and publish that seal to the Bitcoin blockchain. Once that transaction confirms, we use a later Bitcoin block hash to derive the winner.
We use a few extra safeguards that make the system more robust, but also a little more complex. For example, instead of using the Bitcoin block hash from the block where the commitment transaction confirms, we wait 2 blocks and use that later block hash. This helps prove we did not privately mine the commitment transaction ourselves. We also hide the finalized shuffle of the ticket list until the draw block is mined. We have already committed to that shuffle, so it cannot be changed, but hiding it makes it harder for a Bitcoin miner to interfere with the lotto. The draw function is also intentionally CPU-intensive for the same reason.
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. When the list is later revealed, anyone can check it is the same sealed list.
- The winner is based on a public event that happens later.That event is a Bitcoin block 2 blocks after the ticket list is sealed. 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 later public event, and the public draw rules are enough to independently produce the same winning ticket.
- A missing paid ticket becomes publicly detectable.Once the final list is revealed, 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. It cannot make an operator broadcast a payout transaction or prove wallet custody is healthy.
- It does not reveal the full entrant list before the draw.Before the public draw event happens, outsiders see only a fingerprint of the final list. Full inclusion checks happen after reveal.
- It does not make Bitcoin randomness perfect.A miner could try to withhold a block if the prize justified it. The design makes that expensive to evaluate; it does not make it mathematically impossible.
- It does not require trusting this page.The browser tools are convenient, but the proof is the public evidence and the rules below, which can be reimplemented separately.
Now prove the guarantees against a concrete round.
Start with the latest resolved round, or enter any round number. The evidence packet gives reviewers the locked ticket-list file, its fingerprint, the public Bitcoin record that timestamped that fingerprint, the future block used for randomness, the draw constants, the winner, and the payout reference in one place.
curl -fsSL https://testnet4.luckotto.com/api/luckotto/rounds/2/proof -o round2.proof.json curl -fsSL https://testnet4.luckotto.com/rounds/2.csv -o round2.csv shasum -a 256 round2.csv # expect dcbbf3f52ff5979c7a324773a227f9e7b1648b21c94c57ca32f4c89f0ca76e9f # commitment tx 5ecf4794ff38c214bc3dc7bd4e56797073d84e17f1b1f44c480708bd39826ed0 # draw block 130531: c23a61066b075a123c0c90fb9be577a397a86a9c960676fbc29475ca332b13e4 # browser replay https://testnet4.luckotto.com/embed/verify?partner=tb1pyqn8k6d9lyjen5akpz7v42y37vc42cp6tzepvvvr8h63ulecu27suulrjw&playerIdentifier=account-9f22&playerUname=player-42&roundNumber=2
- 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)
The final CSV bytes are fixed, secretly ordered, and hashed.
- 4Hash anchoredblock H · OP_RETURN
Output 0 stores the CSV hash; output 1 rolls funds forward.
- 5Future blockH + 2 blocks · ~20 min
A later Bitcoin block completes the draw seed.
- 6Replay resultpayout waits 100 confirmations
The revealed CSV order and future block select the winner.
The CSV hash is already on-chain at block H before the draw block exists. Reordering, adding, or removing tickets after that point breaks the committed hash.
Every fairness claim has a concrete failure condition.
An expert review should be able to ignore the prose and mark each row pass or fail. The public JSON, CSV, Bitcoin chain, and inline algorithms below are the full review surface.
The final ticket file was fixed before the draw seed existed.
- Evidence
- commitmentAddress, commitmentTxid, commitmentBlockHeight, csvHash, expectedCommitmentOutputScriptHex
- Accept when
- The canonical first outbound spend from the round commitment address has output 0 equal to OP_RETURN(csvHash), and it confirms before the draw block.
- Reject when
- No canonical transaction exists, output 0 does not decode to csvHash, or the transaction confirms after the specified draw block.
The CSV bytes are the committed artifact.
- Evidence
- csvUrl, csvHash, csvByteLength, X-Luckotto-CSV-SHA256
- Accept when
- SHA256(exact downloaded bytes) equals csvHash; parser preserves row order from the file.
- Reject when
- Any byte, line ending, row, order, or header change produces a different hash.
The seed was not chosen after the winner was known.
- Evidence
- commitmentBlockHeight, drawBlockHeight, drawBlockHash, draw.delayBlocks = 2
- Accept when
- drawBlockHeight equals commitmentBlockHeight + draw.delayBlocks on the active chain.
- Reject when
- The draw uses a different block, missing block hash, stale reorged block, or configurable per-round delay.
The public replay selects the displayed winner.
- Evidence
- csv, drawBlockHash, drawHashesPerElimination, drawTiles, winningTicketId
- Accept when
- A fresh implementation of the algorithms below returns exactly the published drawTiles and winningTicketId.
- Reject when
- Replay diverges, duplicate ticket IDs appear, a ticket has invalid weight or tiles, or hash count differs.
The payout evidence is separate from draw correctness.
- Evidence
- winningTicketId, payoutTxid, payout transaction outputs
- Accept when
- If a payout txid is recorded, its transaction can be compared with the published winner and payout policy.
- Reject when
- A missing payout is an operational failure, not hidden randomness. A recorded payout that contradicts the winner is a payout failure.
Ticket inclusion is only provable after reveal.
- Evidence
- published CSV after draw block
- Accept when
- A ticket is included if its round-local ticketId appears in the revealed CSV whose hash matches the pre-draw commitment.
- Reject when
- A paid ticket missing from a hash-matching CSV is detectable after reveal and should be treated as a protocol violation.
The proof endpoint is the downloadable evidence packet.
Fetch /api/luckotto/rounds/<N>/proof for the canonical round packet. It includes backwards-compatible top-level fields plus grouped CSV, commitment, draw, payout, and parameter records so reviewers do not scrape HTML.
type RoundProof = {
specVersion: "LUCKOTTO-1";
network: "testnet4" | "signet" | "mainnet";
proofUrl: string;
roundNumber: number;
status: "open" | "locked" | "revealed" | "resolved" | "paid_out";
csvUrl: string | null;
csvHash: string | null;
csvByteLength: number | null;
ticketCount: number | null;
commitmentAddress: string;
commitmentPath: string;
commitmentTxid: string | null;
commitmentBlockHeight: number | null;
commitmentBlockHash: string | null;
commitmentOutputIndex: 0;
expectedCommitmentOutputScriptHex: string | null;
drawBlockHeight: number | null;
drawBlockHash: string | null;
drawHashesPerElimination: number;
drawTiles: number[] | null;
winningTicketId: string | null;
payoutTxid: string | null;
parameters: {
drawDelayBlocks: number;
drawHashesPerElimination: number;
minPayoutConfirmations: number;
};
csv: { byteLength: number | null; hash: string | null; rowCount: number | null; url: string | null };
commitment: {
descriptor: string;
path: string;
address: string;
nextPath: string;
nextAddress: string;
txid: string | null;
blockHeight: number | null;
blockHash: string | null;
outputIndex: 0;
expectedOutputScriptHex: string | null;
};
draw: {
delayBlocks: number;
hashesPerElimination: number;
blockHeight: number | null;
blockHash: string | null;
tiles: number[] | null;
winningTicketId: string | null;
};
payout: { txid: string | null };
};The csvUrl field is non-null only after the draw block exists. Before reveal, the CSV bytes stay hidden; after reveal, the hash-matching CSV order is the canonical order for replay.
Inline logic for an independent reimplementation.
There is no separate verifier binary to trust. A reviewer should be able to implement the following logic in any language, feed it the proof JSON and CSV, and get the same result. The browser auditor and calculators are convenience tools, not authority.
tile_count36 tiles numbered 1 through 36.
tiles_per_ticket6 unique tiles per ticket.
draw_delay_blocks2 blocks after the commitment confirmation block.
draw_hashes_per_elimination200,000 sequential SHA-256 hashes per sample.
min_payout_confirmations100 confirmations before a payout is allowed.
1. Parse the exact CSV bytes
Hash the raw bytes first. Then parse rows while preserving file order. The pre-reveal secret ordering is intentionally not reproducible by outsiders; after reveal, the committed file order is the canonical order.
assert bytes are UTF-8 with no BOM assert SHA256(bytes) == proof.csvHash rows = parse_csv(bytes) assert header == ['tiles', 'weight', 'ticketId', 'partnerPayoutAddress'] for row in rows[1:]: 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 ticketId = lowercase deposit-address UUIDv7; assert no duplicate ticketId within the round tickets = rows with weight > 0, in file 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 the canonical first outbound spend, and decode output 0.
address_N = p2tr(commitment_descriptor / proof.roundNumber) address_next = p2tr(commitment_descriptor / (proof.roundNumber + 1)) txs = bitcoin_transactions_for(address_N) outbound = txs where any input spends from address_N sort outbound by: confirmed before unconfirmed block_height ascending block_time ascending txid lexicographic ascending canonical_tx = outbound[0] assert canonical_tx.txid == proof.commitmentTxid assert canonical_tx.block_height == proof.commitmentBlockHeight assert canonical_tx.vout[0].script == '6a20' + proof.csvHash assert canonical_tx.vout[1].address == address_next assert canonical_tx.vout.length == 2
3. Consume one sequential SHA-256 chain
The draw does not create independent random values. One chain feeds every elimination and every rejection retry, so the work cannot be skipped or parallelized by elimination.
chain_bytes = utf8(proof.drawBlockHash + ':draw:chain')
next_value():
repeat proof.drawHashesPerElimination times:
chain_bytes = SHA256(chain_bytes)
return big_endian_uint256(chain_bytes)4. Build elimination choices from survivor weight
Each possible eliminated tile is weighted by the candidate tickets that would survive if that tile were removed. This preserves ticket odds while producing a public suspense sequence.
candidates = tickets
eliminated = set()
while not all candidates share the same tile set:
choices = []
for tile in 1..36:
if tile in eliminated: continue
survivor_indexes = indexes of candidates whose ticket does not contain tile
if survivor_indexes is empty: continue
weight = sum(candidates[i].weight for i in survivor_indexes)
choices.append({ tile, survivor_indexes, weight })
sort choices by survivor_indexes lexicographically, then tile ascending
choice = rejection_sample(next_value, choices)
eliminated.add(choice.tile)
candidates = candidates at choice.survivor_indexes
if candidates.length == 1:
winner = candidates[0]
else:
winner = rejection_sample(next_value, candidates in CSV order by weight)
assert winner.tiles == proof.drawTiles
assert winner.ticketId == proof.winningTicketId5. Use rejection sampling, not modulo sampling
Modulo bias is removed by rejecting the tail above the largest exact multiple of total weight below 2^256.
rejection_sample(next_value, choices):
W = sum(choice.weight for choice in choices)
assert W >= 1
limit = 2^256 - (2^256 mod W)
repeat at most 10000 times:
value = next_value()
if value >= limit: continue
r = value mod W
running = 0
for choice in choices:
running += choice.weight
if running > r: return choice
abort: could not sample unbiased value6. Check against a resolved round
Use any resolved round's proof JSON and CSV as the fixture, then require exact agreement with the published evidence.
proof = fetch_json('/api/luckotto/rounds/<N>/proof')
csv_bytes = fetch(proof.csvUrl)
tickets = parse_and_verify_csv(csv_bytes, proof.csvHash)
verify_commitment_transaction(proof)
assert proof.drawBlockHeight == proof.commitmentBlockHeight + proof.draw.delayBlocks
assert bitcoin_block_hash(proof.drawBlockHeight) == proof.drawBlockHash
result = replay_draw(tickets, proof.drawBlockHash, proof.drawHashesPerElimination)
assert result.drawTiles == proof.drawTiles
assert result.winningTicketId == proof.winningTicketIdWhat this proves, what it does not prove, and the block-withholding bound.
The proof blocks the operator from changing the final ticket list after learning the draw seed. It assumes Bitcoin block hashes are not controlled by the operator and that the published descriptors are the intended keys. It does not prove prompt settlement, wallet custody, payout liveness, or pre-reveal ticket inclusion.
A miner with a ticket could privately test a found draw block and publish only favorable blocks. To evaluate one candidate, they must run the sequential draw before the rest of the network finds a competing block.
Up to 30 elimination samples are needed before one 6-tile set remains, so a normal full evaluation consumes at least 6,000,000 sequential hashes before duplicate-winner or rejection retries.
withholding_is_profitable only if: expected_prize_gain > forfeited_block_reward + forfeited_fees expected_prize_gain = prize * (win_probability_if_published - normal_win_probability) hash_work_floor = (36 - 6) * 200000 actual_hash_work >= hash_work_floor actual_hash_work also includes duplicate-final-ticket sampling and rejection retries
The hidden CSV order matters here only before reveal: it prevents a third-party miner from cheaply laying out the exact draw inputs in advance. After reveal, reviewers do not need the secret. They replay the committed CSV order exactly as published.
The published Taproot descriptors for Bitcoin testnet4.
Every proof is relative to these keys. Record them outside this site; if the page later serves different keys, that substitution is itself review evidence.