← All articles

No. 06Solucky

A dice roll every validator agrees on

Solucky draws one winning ticket a day on Solana, where every validator must compute the same answer. Here is where its chance comes from, when it is read, and who could still bend it.

Alex Colls Outumuro

Co-founder and CTO · · 6 min read

Print 06, Suminagashi. Drops and combs on a bath, pulled when the site is built.Point at it to drop ink.

Solucky is a daily lottery on Solana. Each UTC day is a round: players buy numbered tickets, and after midnight one ticket is drawn to win the pot. We have just rebuilt it from its 2022 prototype, and the part worth writing about is the draw. A blockchain is built so that every validator runs the same code on the same data and reaches the same answer. A lottery needs an answer that nobody could know in advance, or steer. This is how Solucky gets one, and where it falls short.

Where the dice cannot come from

A program cannot call a random number generator. Every validator replays every transaction and must reach the same state, so each input to a draw has to be data the whole cluster already shares. The obvious candidates fail for one reason: they can be known before the transaction runs. The slot in the Clock sysvar counts up by one, its timestamp is public, and a transaction must normally carry a recent blockhash to be accepted at all. A player could simulate the draw, and only let it land when it wins. Even SlotHashes, the sysvar Solucky does read, only holds slots that are already in the past.

There are two textbook answers. In commit–reveal, each player publishes the hash of a secret, reveals the secret once sales close, and the draw mixes them all. Nobody can steer the result alone, but the last player to reveal sees it first and can refuse. A verifiable random function, or VRF, moves the secret to an oracle: it answers a seed with an output and a proof, and anyone can check with its public key that no other output was possible. The oracle cannot choose the number. It can only fail to answer. Solucky uses neither. It splits the draw in time: first it pins a slot that does not exist yet, then it reads that slot’s hash once it does.

A slot nobody has seen yet

Tickets for a day can only be bought while the cluster clock is still in that day, so sales stop at midnight UTC on their own. Each purchase is folded into a running SHA-256 that the round keeps as entropy: the previous value, the buyer, the number, the slot and the ticket’s address. After midnight, anyone can call close_round. It checks the clock and pins target_slot, eight slots ahead. At that moment sales are over, and the hash of the target slot does not exist yet.

Once that slot is in the past, anyone can call draw_round. It reads the slot’s hash from SlotHashes, the list of the last 512 produced slots and their hashes. If the pinned slot was skipped, it takes the first slot produced after it. Then it hashes everything together and keeps the first eight bytes:

let (slot, slot_hash) = {
    let data = ctx.accounts.slot_hashes.try_borrow_data()?;
    find_slot_hash(&data, round.target_slot).ok_or(SoluckyError::RandomnessUnavailable)?
};

let randomness = hashv(&[
    &round.entropy,
    &slot_hash,
    &slot.to_le_bytes(),
    round_key.as_ref(),
])
.to_bytes();
let seed = u64::from_le_bytes(randomness[..8].try_into().unwrap());
let winning_index = (seed % u64::from(round.ticket_count)) as u32;

Every validator reads the same sysvar, so they all land on the same index. The entropy adds no secrecy, since all its inputs are public: it ties the result to the exact tickets sold, and the slot hash brings the chance. The index picks a purchase, not a number, and the winning number is read off that ticket afterwards. So there is always exactly one winner and never a tie, and since a round only exists once its first ticket is sold, nothing rolls over. Your odds are your tickets over all the tickets sold that day. Anyone can then send claim_prize, which pays the pot, minus a house fee of at most 10%, to that ticket’s owner and to nobody else.

Eight bytes, modulo n

That last line deserves a second look. Reducing a uniform number modulo n is only exactly fair when n divides the range. A small case shows the bias. Draw one byte, 256 values, and reduce it modulo 3:

256 = 3 × 85 + 1
P(0) = 86/256    P(1) = P(2) = 85/256

Ticket 0 is 86/85 times as likely to win as the others, about 1.2% more. In general, write 2⁶⁴ = q·n + r: the first r indices get one value more than the rest, an edge of 1/q. A round counts its tickets in a u32, so n is below 2³², q is above 2³², and the edge is under 2⁻³², less than one part in four billion. Solucky does not remove it with rejection sampling, which would throw away any seed of q·n or above and hash again. At 64 bits, the bias is there, and it is tiny.

Who could still bend it

This draw is fair against players, not against validators. A slot hash is a bank hash: it depends on the transactions in the block, and the leader of that slot chooses them. A leader holding a ticket can work out the result of each block it could publish before publishing one, or skip the slot, so the draw falls through to the next, which may be its own too, since leaders produce four slots in a row. And because the leader schedule is known ahead, a validator that sends close_round itself can aim the pinned slot at its own slots.

The second gap is time. Once the pinned slot exists, anyone can compute the winner before drawing. SlotHashes only keeps 512 slots, and close_round only pins a new slot once the sysvar proves the old one is gone for good, so nobody can throw a known result away early. But a player who dislikes the result could hold back and hope nobody draws in time. Against that, drawing is open to anyone: the repository ships a crank, a script that closes, draws and pays every finished round, and any player can do the same from the app.

This draw is fair against players, not against validators.

Everything the draw used stays in the round account: the entropy, the slot it read, that slot’s hash and the result. The round page in the app recomputes the last SHA-256 and the modulo in your browser, and says whether they match. That checks the arithmetic. The slot hash itself was read by the program from the sysvar, and the entropy can be rebuilt from the round’s buy_ticket transactions, in order, though the repository ships no tool for that yet. For pots of real money, our README says it plainly: replace this draw with a VRF oracle such as Switchboard On-Demand or ORAO VRF. The draw is one function. For now, the public app runs on devnet, with test SOL.

Alex