Every few weeks somebody asks us to publish a randomness test. Run the numbers through a statistical suite, show the chi-square, prove the draws are fair. It is a reasonable request and we have now done it properly, on every draw the whole chain made rather than our own, and the result is worth writing down: the test passes, and it would have passed anyway. We built a fraudulent oracle that picks its outputs in advance from a counter, and it scores better on half the measurements than Chainlink does.
Here is the full accounting, and then the thing that actually protects a player.
TL;DR
- Every random word ever delivered on Base is reproducible from public data. We rebuilt all 34,029 of them from 13,828 Chainlink VRF draws over seven days, and confirmed the derivation byte for byte against four contracts that log their raw word.
- They pass everything. 4,355,559 ones in 8,711,424 bits (z = -0.104), byte chi-square 257.74 on 255 degrees of freedom, zero collisions.
- A counter passes identically. Seeds 1, 2, 3 and upward, pushed through the same derivation, score z = -0.439 and chi-square 271.32. No statistical test on delivered words can tell a verifiable oracle from one that chose its results on Monday.
- What does hold: the proof. Flip a single bit of the submitted proof and the coordinator reverts with
addr(c*pk+s*g)!=_uWitness. Add one to the request’s word count and it revertsIncorrectCommitment(). We ran both. - The games spend 2.16% of what arrives. Across the 85.45% of draws whose maths we could read in verified source, 8,130,816 delivered bits resolved roughly 175,698 bits of outcome. A week of gambling on Base fits in 21 KB.
The part nobody mentions: you can rebuild the words yourself
The coordinator does not emit the random words it hands your contract. It emits one number, outputSeed, inside RandomWordsFulfilled. The words themselves are derived from it in a loop that is three lines long in the deployed source:
randomWords[i] = uint256(keccak256(abi.encode(randomness, i)));
and randomness is exactly the value emitted as outputSeed. That means every word every game on Base has ever received is sitting in public log data, one keccak away. Nobody has to trust a dashboard for this.
So we pulled it. Base blocks 52,304,288 to 52,606,688, seven days ending 14 October 2026, every log the VRF 2.5 coordinator emitted. In that window: 13,828 requests and 13,829 fulfilments (one answer belonged to a request made just before the window opened), across 65 distinct consumer contracts and 34 subscriptions. Those requests asked for 34,029 words in total, which is 8,711,424 bits of randomness, or 1.089 megabytes.
Before testing any of it, we checked the derivation was real rather than merely plausible. Of the twelve busiest consumers, four write the full 256-bit word into their own event logs: a lottery contract, a staking pot manager, Chainlink’s own wrapper and one unverified contract making 468 requests a week. For each of them we took a fulfilment, derived the word from the coordinator’s outputSeed alone, and searched the consumer’s own logs in the same transaction for those 32 bytes. They are there, byte for byte, in all four. That is the control probe: the reconstruction is not a model of what happened, it is what happened.
The other eight do not log the raw word, because they log an outcome instead. A dice contract records a number from 1 to 20. That difference turns out to be the whole second half of this post.
The test everybody asks for
Four measurements on the 34,029 reconstructed words.
Monobit. 4,355,559 one-bits out of 8,711,424, which is 49.998244%. The z-score is -0.104. A fair coin flipped 8.7 million times lands this close to half about 92 times in 100.
Byte frequency. All 1,088,928 bytes binned by value, 4,253.6 expected per bin. Chi-square 257.74 on 255 degrees of freedom. Textbook.
The bytes games actually touch. A contract doing % 6 leans on the low end of the word, so we tested the low byte and the high byte on their own: chi-square 259.57 and 273.86 respectively. Both fine.
Collisions. Zero repeated words in 34,029, and 13,829 distinct output seeds in 13,829 fulfilments. Nothing repeated itself all week.
Per-bit-position, 19 of the 256 positions sit beyond two standard deviations where about twelve are expected, and one reaches 3.009. That is the single mildly interesting number in the set and it is not interesting enough to chase; at 256 positions you get an outlier or two for free.
If you came for a verdict: Base’s VRF output is statistically clean. Now here is why that sentence is nearly worthless.
So does a counter
We replaced the real output seeds with values an attacker would have chosen, kept the request shapes identical (same number of draws, same words per draw), ran the same derivation and the same tests.
Fraud A: the seed is a counter. Draw one uses seed 1, draw two uses seed 2, and so on. An operator running this knows every result for the rest of the year before any player places a bet.
Monobit z = -0.439. Byte chi-square 271.32. Largest bit-position deviation 3.041, with twelve positions beyond two sigma. Put those beside the real numbers and the honest reading is that the two datasets are indistinguishable, and on the bit-position measure the fraud looks slightly tidier.
This is not a flaw in the test. It is what a hash function does. keccak256 destroys the structure of its input by design, so a predictable sequence and an unpredictable one come out the far side looking the same. Any suite you run on delivered words, ours or NIST’s, is measuring keccak. It cannot measure the thing you care about, which is whether anyone knew the answer in advance.
Two fraudulent oracles do get caught, and the shape of the failure says exactly what the test is good for.
Fraud B: the same seed every time. Monobit z = 64.559, byte chi-square 2,154,075, every one of the 256 bit positions beyond two sigma. Obliterated.
Fraud C: seeds drawn from a space of 256 values. Chi-square 12,624, 186 positions out of range. Caught.
Fraud A is not a thought experiment. It is the shape of every off-chain RNG a gambling site has ever run, and it is the shape of a naive commit-reveal too: the operator picks a secret, publishes a hash of it, and reveals it later. The revealed values hash into output that sails through any suite you point at it, and the operator knew the lot from the start. The difference between that and a verifiable random function is not the quality of the numbers. It is whether a third party can confirm the numbers were not chosen, and that confirmation lives in a proof, not in a histogram.
Both the failing frauds are caught for the same reason: they repeat. A statistical suite on delivered randomness detects an oracle that reuses values. It does not detect an oracle that is merely predictable. Those are very different attacks, and the one worth money is the second one.
What actually holds it up
The protection is not in the output, it is in the proof that arrives with it. We tested how load-bearing that is rather than assuming.
Take a real fulfilment: transaction 0x3e8812b3…, Base block 52,606,617, 868 bytes of calldata carrying one VRF proof and one request commitment. Replay it as an eth_call against the state one block earlier and it succeeds, returning the payment the subscription owed. Then start breaking it, one bit at a time.
- Flip one bit of the proof’s
c:execution reverted: addr(c*pk+s*g)!=_uWitness - Flip one bit of
s: same revert - Flip one bit of
zInv:invZ must be inverse of z - Flip one bit of the
seed:NoCorrespondingRequest(), selector0x3688124a - Add one to the commitment’s word count, or to its callback gas limit:
IncorrectCommitment(), selector0xd529142c
Every single-bit edit we tried was rejected, and the rejections are specific. The curve arithmetic catches a doctored proof. The commitment hash catches a doctored request. The request ID itself is derived from the key hash and a pre-seed that already contains the key hash, the caller, the subscription and a nonce, so a proof cannot be moved from one draw to another.
Three separate mechanisms are doing that work, and they are worth naming because they are the actual product. The proof is checked against a proving key hash registered on-chain, so only the holder of that key can produce an accepted answer. The seed the key signs is not the one the caller supplied: the coordinator computes keccak256(preSeed, blockhash) of the request’s own block, so the value being signed does not exist until the request is already mined. And preSeed is itself keccak256(keyHash, sender, subId, nonce), which binds the answer to one caller, one subscription and one position in that caller’s sequence. The oracle cannot pick the output, cannot pick a different output, and cannot carry an answer across to another draw.
That is the real audit. It takes one eth_call and it answers a question the chi-square cannot: not “do these numbers look random” but “could this number have been anything else”. With a verified proof the answer is no, and no amount of statistical prettiness gets you there.
One wrinkle worth knowing if you try this. Every fulfilment on Base we sampled arrives through BatchVRFCoordinatorV2Plus, which wraps the coordinator call in a try/catch and emits ErrorReturned instead of reverting. Replay a tampered proof through the batch contract and the call reports success. The failure is real, it is just swallowed one frame up. Call the coordinator directly or you will measure the wrong thing.
The 2.16%
Having rebuilt every word, the obvious next question is what the games did with them. We read the verified source of every consumer we could, including through proxies, and worked out the size of the outcome each draw actually decided.
| Consumer | Share of draws | Bits delivered | Bits used |
|---|---|---|---|
| GridMining | 64.76% | 768 per draw | 15.25 |
| GameManager | 14.88% | 256 | 9.97 |
| Blackjack | 3.25% | 256 per word | 7.40 per word |
| Lotto39 | 0.74% | 1,280 per draw | 26.04 |
| Roulette | 0.48% | 256 | 5.21 |
| Dice (a d20) | 0.27% | 256 | 4.32 |
GridMining, which is two thirds of all randomness consumed on this chain, takes three full words and spends them on a grid cell out of 25, one coin flip and a 1-in-777 bonus roll. Fifteen bits of decision from 768 bits of verified entropy. Roulette asks for 256 bits to pick one of 37 pockets. A coinflip contract does randomNumber % (2**coins), so a single-coin bet resolves one bit of a 256-bit word and throws away the other 255.
The long tail is thinner still. Below the fourteen contracts we read, 49 further senders made 172 requests between them over the week, most of them single draws. Sixty-five contracts asked this coordinator for randomness in seven days and two of them account for 79.6% of it.
Across the 11,816 draws we could account for, which is 85.45% of the week, 8,130,816 bits of randomness were delivered to resolve about 175,698 bits of outcome. That is 2.16%, or one bit in 46. Every game result on Base for seven days, compressed to its information content, is 21,962 bytes. You could fit the week in an email.
This is not waste in any meaningful sense, because a VRF word costs the same whatever you do with it. It matters for a different reason: the reduction is where fairness actually goes wrong, and the reduction is in your contract, not Chainlink’s.
The classic bug is ordering. Cast the word down to a byte and then reduce, uint8(word) % 6, and your six-sided die rolls 1 to 4 about 2.3% more often than 5 or 6 forever. Reduce first and cast after, uint8(word % 6), and the bias is on the order of one part in 1076.
We checked all fourteen contracts we could read. Not one gets it wrong. Every single reduction on Base takes the modulus of the full 256-bit word before narrowing the type. Blackjack is the one contract doing anything clever, shifting each word by 128 bits to carve two independent cards out of it, which is correct and saves a request. Credit where it is due: this is a category of bug that has eaten real casinos, and the Base cohort is clean.
What we take from this at Satoshie
Satoshie runs raffles and coinflips on Base against this exact coordinator, so this is our own plumbing and the findings change what we publish.
First, we are not going to publish a randomness test as evidence of fairness, because we now have the measurement showing it proves nothing. A counter passes it. Anyone offering you a chi-square as proof of a fair game is either confused or counting on you being confused.
Second, the verification we do want to make easy is the one that works: for any draw, here is the request, here is the fulfilling transaction, here is the proof the coordinator accepted, and here is the word rebuilt from the output seed. The whole chain is public, it is four pieces of data, and a player can replay the last step with a hash function.
Third, the reduction gets published too. If a raffle picks a winner from 4,000 entries with word % 4000, that line should be visible and the word it consumed should be visible, because that is the step where a game can be unfair without the oracle being wrong at all. Provable fairness that stops at the coordinator’s boundary is half an answer.
Honest limits
We could not account for 14.55% of draws. Two of the busiest consumers are unverified on Blockscout, Chainlink’s own wrapper passes words through to a downstream contract we did not chase, and CasinoCoreV2’s reduction was not where we looked. The 2.16% figure covers what we could read, and we have not assumed the rest resembles it.
Thirty-four thousand words is a decent sample but not a large one. Our suite would catch gross structure and would not catch something subtle, which is the normal condition of statistical testing and part of the argument rather than a caveat against it.
The tamper replays are eth_call simulations against historical state, not transactions. And the whole exercise verifies the coordinator’s arithmetic and bookkeeping. It says nothing about whether the key behind the proving key hash is held safely, which is a different question that no amount of log analysis answers.
Check it yourself
No API key needed anywhere in this. Pull eth_getLogs on the coordinator at 0xd5d5…f634 in 1,000-block slices from Tenderly’s public Base gateway. RandomWordsFulfilled carries requestId and subId as topics with (outputSeed, payment, nativePayment, success, onlyPremium) in the data, and the matching RandomWordsRequested gives you numWords. Then keccak256(abi.encode(outputSeed, i)) for i from zero, and you have the words. Compare against any consumer that logs its raw word and you will see them match.
For the part that matters, pull a fulfilment transaction, decode the proof and commitment out of the batch calldata, rebuild the direct call to the coordinator, and change one nibble. The revert string is the audit.
The dice do not care how convincing they look. Ask what the proof says.
Figures measured on Base mainnet blocks 52,304,288 to 52,606,688, 14 October 2026.
📷 Photo by Alexander Lyashkov on Unsplash


