Skip to main content

A coinflip has one winner and a single-prize raffle has one winner, and for both of those the whole fairness claim fits in one sentence: the number was not chosen by anybody. Add a second prize and a third, and that sentence quietly stops covering the outcome. A three-prize draw does not just produce a set of winners. It produces an order, and the order decides who takes the car and who takes the keyring.

Almost every provably fair page in this industry is written for the single-winner case and then reused, unchanged, for draws with prize tiers. The randomness section is impeccable. The part that turns one random number into three ranked winners is not described at all, and that part is where a well-meaning developer can hand a measurable advantage to early ticket buyers without writing a single line of dishonest code.

TL;DR

  • Chainlink VRF returns one verified random output; extra “words” are derived from it by hashing it with an index, so asking for three words is one proof expanded by a published rule, not three independent proofs.
  • Picking three winners from 10,000 tickets by simple modulo collides about once every 3,334 draws; at 50 prizes from 10,000 tickets it collides in 11.5% of draws, roughly one in nine.
  • The three standard fixes (resample, step to the next ticket, partial Fisher-Yates shuffle) are not equivalent: one is uniform with variable gas, one is cheap and measurably biased, one is uniform and cheap but needs the ticket list addressable on-chain.
  • Sorting the winning ticket numbers to dedupe them, then awarding prizes top-down, gives ticket number 1 three times the average chance at the top prize while ticket 9,000 gets three per cent of it. Every ticket still has an identical chance of winning something.
  • A fairness claim needs to cover the expansion rule, the collision rule and the prize assignment rule, and all three have to be in the deployed contract before the random word exists.

One random word is not one random number

Start with what actually arrives. A Chainlink VRF request is answered with a proof that the coordinator verifies on-chain before your callback is allowed to run, and that proof yields a single verified value. When a contract asks for three words rather than one, the coordinator does not generate three independent proofs. It derives the additional values from that one verified output by hashing it together with an index, and hands you the array.

This is not a criticism. It is the correct design, it is cheaper, and the derivation is deterministic and public, so anybody can recompute the whole array from the one value in the proof. But it does mean the honest description of a three-word request is “one random value, expanded by a rule”, and once you know that, the interesting question stops being how much randomness did we buy and becomes who wrote the expansion, and when.

A contract that hashes the word with an index itself is doing the same thing the coordinator would have done, one layer up. Both are fine. What is not fine is an expansion rule decided after the word lands, which is the failure we have written about before in the context of who supplies the last ingredient of a random number. Whoever moves last, decides.

The collision nobody plans for

Here is the version of multi-winner code that gets written first, everywhere. Take the word, derive three values, take each one modulo the ticket count, and you have three winners. (Set aside modulo bias itself, which is real, tiny at 256 bits, and already has its own post. Assume perfect uniformity for everything that follows.)

The flaw is that nothing in that loop stops the same ticket being picked twice. It is a birthday problem, and the numbers are worth knowing rather than guessing at:

  • 3 prizes, 10,000 tickets: 0.030% chance of a repeat, about one draw in 3,334.
  • 10 prizes, 10,000 tickets: 0.449%, about one draw in 223.
  • 10 prizes, 1,000 tickets: 4.41%, about one draw in 23.
  • 50 prizes, 10,000 tickets: 11.55%, about one draw in nine.
  • 20 prizes, 500 tickets: 32.0%, about one draw in three.

Read the top line the way an operator should read it. Once every 3,334 draws sounds like never, right up until you notice it means a platform running ten draws a day meets it inside a year, and a platform running one a day meets it in about nine. It is not a rare event, it is a deferred one, and the code that handles it will be written in a hurry on the day it first fires unless it was written calmly in advance.

Three ways to deduplicate, and they are not the same

Resample. If the derived ticket is already a winner, hash again with a fresh index and try once more. This is uniform over the remaining tickets and it is the boring correct answer. Its cost is that the work is variable: usually zero extra hashes, occasionally a few, and no hard ceiling, which is an awkward property inside a VRF callback running under a fixed gas limit.

Step to the next ticket. If ticket 4,812 is taken, award 4,813 instead. Bounded, cheap, one line. It is also not uniform, and the direction of the distortion is specific rather than random: a ticket becomes more likely to win precisely when the ticket before it has already won, so tickets sitting immediately after a cluster of winners are advantaged and the tickets in the cluster are not. The size of that distortion is governed by the same quantity that governs collisions, so it stays negligible in the 3-of-10,000 case and becomes a real number in the 50-of-10,000 case. In other words, it is smallest exactly where nobody would have cared and largest exactly where somebody should.

Partial Fisher-Yates. Treat the tickets as an array, and for each prize in turn, swap the next position with a randomly chosen position from the unpicked remainder. Exactly uniform, fixed cost per prize, no rejection loop. The usual objection is that it needs the full array in storage, which is not true in practice: you only need to record the positions that actually moved, so a mapping with a handful of writes does the job at any draw size. The reason it is not the common answer is that the naive loop looks like it already works.

The order is a second outcome

Now the part that costs players money without anybody lying to them.

Suppose you have solved deduplication perfectly and hold three distinct winning ticket numbers, drawn uniformly. You still have to assign prize one, prize two and prize three. The cheapest way to hold a set of distinct numbers in a contract is sorted, because sorted order makes the duplicate check trivial. So the winners come out sorted, and the natural next line awards the top prize to the first element.

That single implementation convenience means the top prize goes to whichever winner holds the lowest ticket number. With three prizes drawn from 10,000 tickets, the chance of taking the top prize by ticket position looks like this:

  • Ticket 1: three times the average chance.
  • Ticket 1,000: 2.43 times.
  • Ticket 2,500: 1.69 times.
  • Ticket 5,000: 0.75 times.
  • Ticket 7,500: 0.19 times.
  • Ticket 9,000: 0.03 times, which is to say nothing at all.

The first ten per cent of tickets sold collect 27.1% of the top prizes. The second half of the ticket list, five thousand tickets, collects 12.5% of them. And through all of that, every single ticket has an identical 0.03% chance of winning something, which is precisely what makes it so hard to spot: the headline fairness property is intact, verifiably, on-chain. Only the distribution of prize value has been tilted towards early buyers, by a sort that was written to make deduplication cheap.

Nobody has to be dishonest for this to happen. The draw is uniform, the proof verifies, and the platform can truthfully say no human touched the outcome. It is still a rule that rewards buying early, and a player who has read the fairness page has no way of knowing it exists.

What a player should actually be able to check

For a multi-prize draw the verifiable claim has four parts, not one: the random value and its proof, the expansion rule that turns that value into candidate indices, the collision rule, and the assignment rule that maps a draw position to a prize tier. Miss any of the last three and you can verify the randomness completely while remaining unable to verify the result.

The test is blunt. Could a stranger holding nothing but the transaction hash and the contract address recompute all three winners and their ranking? If the prize assignment lives in the front end, a spreadsheet or a Discord announcement, the answer is no, however good the VRF integration is. We have written the step-by-step version of that recomputation for the single-winner case, and the multi-prize version should read the same, only longer.

Where Satoshie stands, stated narrowly

Our coinflip takes one word and one modulo by two: no expansion, no collision path, no order. Our raffle draws a single winner from the minted ticket count using a formula published in the deployed contract, so again there is no second outcome to get wrong. None of the failures above can occur in today’s games, and that is not a triumph of engineering. It is a consequence of having one prize.

Which is the actual reason to publish this now. Multi-prize draws are the obvious next thing to build, and the commitments are worth making while they cost us nothing: the expansion rule and the prize assignment go in the contract before the draw opens, deduplication uses a method that is uniform rather than a method that is cheap, and the winners are ranked by draw order rather than by ticket number. Judge that when it ships, not now.

Three honest limits. Everything above assumes the VRF key behaves, and a compromised coordinator key makes the choice of deduplication method irrelevant. The gas argument for the cheap and biased option is real, and it gets more tempting as draws get bigger, which is why naming it in public is useful. And none of this is a claim about anyone else’s implementation, because almost nobody publishes theirs, which is the whole point of the post.

So, three questions worth asking of any draw with more than one prize. How does one random value become several winners, and where is that rule written? What happens when the same ticket is drawn twice? And who decided which winner gets the big one, before or after the number arrived?

📷 Photo by Joshua Golde (@joshgmit) on Unsplash

Valentina Ní Críonna

Author Valentina Ní Críonna

More posts by Valentina Ní Críonna