On 4 October at 15:05:07 UTC, a lottery contract on Base received a random number, checked it against the round it belonged to, and threw it away. Four seconds earlier, at 15:05:03, that round had already been decided by a different random number. Both were produced by Chainlink VRF. Both carried a proof that verified on chain. Only one of them picked a winner, and what separated them was two blocks of arrival order.
That happened twenty-three times in the past week on Base. We pulled every log the VRF coordinator emitted over seven days to find out how often a provably fair draw gets delivered, paid for, and binned, and what decides which of two valid answers a round actually uses.
TL;DR
- Seven days of Chainlink VRF v2.5 on Base mainnet: 14,031 randomness requests, 14,032 fulfilments, 42,443 coordinator logs, blocks 52,130,600 to 52,433,000.
- Median delivery is 3 blocks (6 seconds). Only 23 deliveries in the week arrived more than 100 blocks late, and every single one of those 23 was refused by the contract it was sent to.
- All 23 belong to one lottery contract that re-requests randomness after a five minute timeout. The retry gets answered in 5 to 8 blocks, the original then lands and reverts with
RandomnessAlreadyReceived(). Every one of the 23 lost the race by between 2 and 8 seconds. - That contract bought 107 draws to decide 84 rounds. The subscription is billed for the binned ones, at 67.3% of the price of a draw that actually resolved something.
- Eighteen of the 23 losing deliveries came from a fifth VRF key that sent only 21 transactions all week, and whose fastest delivery in the entire window was 156 blocks.
- The coordinator keeps no record of which answers were used. One boolean in one event is the only on-chain evidence that a round had two valid seeds and took the first to arrive.
What we measured, and how
Every log emitted by the Chainlink VRF v2.5 coordinator 0xd5D517aBE5cF79B7e95eC98dB0f0277788aFF634 on Base mainnet, from block 52,130,600 to block 52,433,000. That is 2026-10-03 18:02:27Z to 2026-10-10 18:02:27Z: exactly 302,400 blocks across exactly 604,800 seconds, which is 2.000 seconds per block with no rounding needed.
The pull was 303 slices of 1,000 blocks through base.gateway.tenderly.co, still the only free endpoint that will serve both deep history and eth_getLogs. It returned 42,443 logs: 14,031 RandomWordsRequested, 14,032 RandomWordsFulfilled, 14,032 L1GasFee, plus 296 native funding events, one LINK funding, 23 consumer additions, 15 removals, 10 subscriptions created, one cancelled, and two logs whose topics we did not decode. The extra fulfilment belongs to a request made before the window opened. Nothing in the window went unanswered.
Cross-check, because a single endpoint is a single point of being wrong: a 40-block slice re-pulled from 1rpc.io/base and base.drpc.org returned byte-identical transaction-hash and log-index fingerprints to the Tenderly pull and to our cache. mainnet.base.org answered “request limit reached” for the whole session and publicnode refused anything beyond its recent window, which is why neither appears below.
Randomness on Base is fast, and the tail is tiny
Pairing each fulfilment to its request gives 14,031 clean lag measurements:
| Delivery lag | Fulfilments | Share |
|---|---|---|
| 1 to 3 blocks | 9,858 | 70.26% |
| 4 to 6 blocks | 3,992 | 28.45% |
| 7 to 10 blocks | 45 | 0.32% |
| 11 to 30 blocks | 89 | 0.63% |
| 31 to 100 blocks | 24 | 0.17% |
| 101 to 200 blocks | 19 | 0.14% |
| 201 to 500 blocks | 4 | 0.03% |
Median 3 blocks, p90 5, p99 10, p99.9 158, maximum 471. For 98.7% of draws on this chain, randomness shows up inside twelve seconds. That is the headline anyone selling an oracle would quote, and it is true.
The interesting number is the other one. Twenty-six fulfilments in the week returned success = false: the proof verified, the coordinator called the consumer, and the consumer’s callback reverted. The coordinator records that as a boolean in the event and charges the subscription anyway. Nothing about the outer transaction gives it away, because the outer transaction succeeds. We flagged four of these in a single day of data when we measured fulfilment latency on 5 October, and could not say why they happened. Seven days is enough to say why.
The rule that holds without exception
Split the week’s 14,031 deliveries at 100 blocks:
| Arrival | Deliveries | Refused by the consumer |
|---|---|---|
| Within 100 blocks | 14,008 | 3 (0.02%) |
| Later than 100 blocks | 23 | 23 (100%) |
Not “mostly”. Not “a higher rate”. Every delivery in the week that took longer than 100 blocks was rejected on arrival by the contract that had paid for it. And all 23 went to the same address.
One contract, 107 draws, 84 rounds
The address is 0xE7B38Fd0DD070CEaD9c3A17EF0eEA5F39f3BD4E0, verified on Sourcify on 6 October as Lotto39, Solidity 0.8.25. It is a five-number lottery with 3 USDC tickets, 120-minute rounds, a 50% first prize, and fixed third and fourth prizes. It asks for 5 random words, 3 confirmations, a 250,000 gas callback, and it pays in LINK rather than ETH.
In the seven-day window it made 107 requests. Eighty-four of them resolved a round. Twenty-three were refused. And 302,400 blocks divided by 84 rounds is 3,600 blocks exactly, which at two seconds a block is the contract’s own ROUND_DURATION of 120 minutes. The arithmetic closes: 84 rounds happened, 84 draws landed, and 23 extra draws were bought and discarded. Just over a fifth of what that contract spent on randomness bought nothing.
Proving what killed them
The obvious guess is gas starvation: a 250,000 limit is not generous. It is also wrong. We pulled receipts for all 107 of that contract’s fulfilments, not a sample. The refused ones used less gas than the successful ones, 167,150 to 211,572 against 291,627 to 302,759, with no overlap at all between the two ranges, which is the signature of an early revert rather than a limit being hit. All 84 successful fulfilments emit three logs and all 23 refused ones emit two: the consumer’s own event is missing because its state changes were rolled back.
So we replayed the callback. Take a refused request, build the exact rawFulfillRandomWords calldata, send it as eth_call from the coordinator’s address against historical state, and binary-search for the block where it flips from working to reverting. All 23 reverted with the same custom error selector, 0x896a2f75. The openchain.xyz signature database names it RandomnessAlreadyReceived(), and the verified source confirms it: if (roundRandomnessReady[roundId]) revert RandomnessAlreadyReceived();.
Then the part that settles it. In 19 of the 23 cases, the first block at which the callback starts reverting is exactly the block at which the contract’s own retry request was fulfilled. In the other four, it is the block at which a second or third retry landed. The round had already been decided by the time the original answer turned up.
The source says so out loud. VRF_REQUEST_TIMEOUT = 5 minutes, with the comment “Auto re-requests if the previous request timed out”. Observed retry gaps: 150 to 157 blocks, which is five minutes plus block jitter. The retry is then answered in 5 to 8 blocks. The original arrives after that and finds the door shut.
By how much? Here is the whole distribution of how late the losing answer was, measured against the block its replacement was accepted in, for all 23:
| Lost by | Cases |
|---|---|
| 2 seconds (1 block) | 4 |
| 4 seconds (2 blocks) | 16 |
| 6 seconds (3 blocks) | 1 |
| 8 seconds (4 blocks) | 2 |
Not one of them missed by a minute. Not one of them missed by ten seconds. The median loser was four seconds behind a race it did not know it was in.
The fifth key
Those late deliveries are not spread across the oracle network. Mapping every delivery slower than 7 blocks back to the key that signed it:
| Fulfiller key | Transactions in the week | Slow deliveries | Refused | Slowest |
|---|---|---|---|---|
0x1cb7309d...4c8205d4 |
3,630 | 46 | 0 | 50 blocks |
0x19a1c36e...d1e5dd5a |
3,591 | 40 | 0 | 66 blocks |
0xc60ea96a...8f464873 |
3,591 | 39 | 0 | 68 blocks |
0xedfeea1c...d0109815 |
3,197 | 38 | 5 | 471 blocks |
0xd65f7a3d...ab3581a84 |
21 | 18 | 18 | 319 blocks |
The transaction counts come from nonce deltas at the window’s first and last block, which is exact rather than sampled. Four keys did roughly 3,200 to 3,600 transactions each. The fifth did 21, and 18 of those 21 were deliveries that arrived too late to be used. Its fastest delivery in seven days was 156 blocks, which is 31 times the chain’s p90. It never once turned up on time.
That key is not broken. It looks like a fallback that engages when the regular rotation has already missed, which is a sensible thing to have. The problem is arithmetic: a five minute consumer timeout and a backup path whose floor is five minutes and twelve seconds do not leave room for anyone to win. Every time the fallback fired, it was already too late, by a margin measured in single-digit seconds.
What a draw that did not happen costs
Priced at $13.07 LINK and $2,508.85 ETH from CoinGecko, cross-checked against the coordinator’s own LINK_NATIVE_FEED, which read 0.005226139689251451 ETH per LINK and implies $13.11, a 0.3% disagreement:
| Item | LINK | USD |
|---|---|---|
| Average completed draw | 0.00055767 | $0.00729 |
| Average binned draw | 0.00037515 | $0.00490 |
| All 23 binned draws | 0.00862841 | $0.1128 |
A binned draw costs 67.3% of a completed one, because the bill is computed from gas actually burned and a reverted callback burns less. The chain gives you a 33% discount for not finishing the job. None of the 14,032 fulfilments in the week used the coordinator’s onlyPremium path, so nobody got a cheaper outcome than that.
For scale: all of the randomness consumed on Base over seven days, 14,032 draws across 35 subscriptions, cost $128.81. That is 0.918 US cents per draw. The entire week of discarded randomness came to eleven US cents.
Why eleven pence is not the point
Nobody is going broke here. The interesting part is what a verifier can and cannot see afterwards.
Go and check one of those rounds. Pull the seed Lotto39 used, pull the proof, verify it. Everything checks out, because everything is fine: the seed is a genuine VRF output, the proof was verified on chain by the coordinator before the callback ran, and the winner follows from the seed by published code. Provable fairness did its job.
What the proof does not cover is that a second seed existed. For roughly five minutes, that round had two live requests, and at the moment of resolution it had two valid, independently proven candidate answers, four seconds apart. Which one decided the round was settled by arrival order in a sequencer’s queue. That is an infrastructure property, not a cryptographic one, and no amount of verifying the winning proof will surface it.
The only on-chain trace is one boolean in one event. Call s_requestCommitments(requestId) on a binned request today and it returns 32 zero bytes. Call it on a completed one and it returns 32 zero bytes. Call it on a request ID that never existed and it returns 32 zero bytes. We ran all three as a control. The coordinator considers a request closed when it delivers, not when the answer is used, so it keeps no record at all of which seeds mattered.
Then there is the shape of the thing. Lotto39 exposes retryRandomnessRequest(), owner-only, which fires a second request for the current round whenever the timeout has elapsed. Nobody can see a pending seed before it lands, and Base has no public mempool to watch, so there is no evidence of anything untoward here and we are not suggesting there is. But notice what the design makes true: the outcome of a round can depend on when a second answer is called into existence, and someone holds that lever. You do not need to see the number to influence which number wins. You only need to influence the race.
That is a class of question a fairness proof was never built to answer, and it only comes up because a timeout turned one request into two.
The other failure mode, for honesty
Three of the week’s 26 refusals were not this. They belong to 0xeDB1257b823e713Ec5D1422fcd32662363352456, a contract called Dice, which made 19 requests across the week. All three refusals fell inside a single 92-block window on 5 October, all with a delivery lag of 2 blocks, all with the same 1,000,000 gas callback that its other 16 requests used successfully. Replaying those callbacks one block after the request reverts too, so they were never going to work regardless of timing. Whatever went wrong there was in the contract’s own state, not in a race, and Dice has not failed since.
Two different failure modes, three orders of magnitude apart in frequency, both reported identically as a false boolean in a successful transaction.
What this means for how we build Satoshie
Satoshie runs raffles and coinflips on Base with Chainlink VRF, so this is our own plumbing, and the measurement changes two things about how we think.
First, one outcome gets one live request. If randomness for a draw is slow, the draw waits. We would rather hold a raffle open for an extra five minutes than have two valid seeds in flight and let transaction ordering choose between them. A pending draw is a liveness problem that everyone can see; a race is a fairness problem that nobody can see.
Second, if a timeout path is ever genuinely needed, the superseded request has to be made dead on chain, not just superseded in practice. Delete the mapping, emit an event that says this request is void and why, and let the late answer be refused for a stated reason. The cost of not doing that is exactly what we measured: 23 refusals that read, to any explorer, as transactions that succeeded.
Honest limits on all of this. We have written before about what happens when randomness never arrives, and “the draw waits” is not a free position: a round that cannot resolve is a round whose prize pool is stuck, and the right answer there is a permissionless, contract-enforced path that anyone can trigger after a published deadline, not an owner with a retry button. We also depend on the same oracle set as everyone else. Integrity is cryptographic and we are comfortable there. Availability has a headcount, and this week one member of it went 18 for 18 on turning up after the fact.
Check it yourself
None of this needs an API key. Pull eth_getLogs on the coordinator in 1,000-block slices from Tenderly’s public Base gateway. RandomWordsFulfilled carries requestId and subId as topics and (outputSeed, payment, nativePayment, success, onlyPremium) in the data, so the sixth value is the one to read. Match each fulfilment back to its RandomWordsRequested by requestId to get the lag. Where success is false, replay rawFulfillRandomWords as an eth_call from the coordinator’s address against historical state and bisect for the block it stops working. Then compare that block to the fulfilment block of the next request from the same sender.
For the record, one of them: request 53590719182516650877007908954229341378022929958698161392314477180574199698266, output seed 0x17ff2af4c10ca80cacfc37ef45b2d71f9dc61153a275584f353ff1b00307f11c, delivered in transaction 0x718a35c6b7e586f21f3eb295bd79d7e39cfa0ebecde476a6e2a08ff5a3554835, paid for in full, used for nothing. It is a perfectly good random number. It has been sitting on Base since 4 October with nowhere to go.
📷 Photo by William Warby on Unsplash


