Every Chainlink VRF fulfilment on Base emits an event with seven fields. Six of them get read constantly: the request id, the output seed, the subscription, the payment, whether it was paid in ETH or LINK, whether only the premium was charged. The seventh is a bare boolean called success, and it records whether the game that asked for the number was actually able to accept it.
Over the seven days to 15 October 2026, Base’s VRF 2.5 coordinator answered 13,590 requests. Thirteen of them came back with that boolean set to false. Every one of those thirteen was generated correctly, proved correctly, delivered on time by the standards of the protocol, and charged to the subscription that asked for it. And in all thirteen cases the game threw the number straight back.
Each of those thirteen transactions has a receipt status of 0x1. Green tick. Nothing to see.
TL;DR
- Over 302,000 Base blocks to 15 October 2026 the VRF coordinator answered 13,590 draws for 62 consumer contracts. Thirteen fulfilments carried
success = false, all from one lottery contract, and all were paid for anyway. - Replaying the callbacks returns selector
0x896a2f75, which matchesRandomnessAlreadyReceived()in the contract’s verified source. The game had more than one live request open for the same round. - For round 103, four provably fair sets of winning numbers existed simultaneously. The set that counted was decided by the order the oracle submitted its backlog, and the order was not the order of the requests.
- For round 127, two competing fulfilments landed in the same Base block, at transaction index 59 and 78. The one at 59 set the numbers. Round 127 then never drew at all, and is still undrawn 58 rounds later.
- Nine other game contracts on Base run with overlapping live requests, covering 1.60% of all draws in the window. Provable fairness says nothing about which of several fair numbers becomes the result.
What was measured
The window is Base blocks 52,347,600 to 52,649,599, a shade under seven days, pulled as eth_getLogs over the VRF 2.5 coordinator at 0xd5d517abe5cf79b7e95ec98db0f0277788aff634. It contains 13,589 RandomWordsRequested events and 13,590 RandomWordsFulfilled events, the extra fulfilment belonging to a request made just before the window opened. Sixty-two distinct contracts asked for randomness. The busiest, a contract called GridMining, accounts for 8,880 of the draws on its own.
Delivery is, by any ordinary standard, excellent. Median delivered depth is two blocks between the request and the answer. The ninetieth percentile is five. Nothing in the window went unanswered.
Then there is the success field. Thirteen false. That is 0.10% of the window, which is the kind of number that disappears into a rounding error on a dashboard, and it is the only number in this post that matters.
All thirteen belong to one contract
The thirteen refusals are not spread across the chain. Every one of them comes from 0xe7b38fd0dd070cead9c3a17ef0eea5f39f3bd4e0, a verified contract named Lotto39, which ran 95 draws in the window. Thirteen of its 95 draws, 13.7%, were delivered and refused.
Finding out why does not require guessing. The coordinator wraps the consumer callback in a try/catch, so the revert data is not in the receipt, but the call can be rebuilt and replayed. Reconstructing rawFulfillRandomWords(uint256,uint256[]) with the real request id and the real delivered words, then eth_calling it against the contract from the coordinator’s address at the parent block, reverts every time with four bytes: 0x896a2f75.
Hashing the 44 custom errors declared in Lotto39’s verified source gives exactly one match. RandomnessAlreadyReceived().
The callback itself is three lines of guard and a loop:
uint256 roundId = requestIdToRound[requestId];
if (roundId == 0) revert UnknownRequest();
if (roundRandomnessReady[roundId]) revert RandomnessAlreadyReceived();
So a second answer for a round that already has an answer is rejected. On its own that is correct and sensible behaviour. The interesting part is why a second answer existed at all.
The retry storm
Lotto39 draws on a clockwork two-hour cadence, 3,600 blocks apart. A keeper function runs every five seconds, and at draw time, if the round’s randomness has not arrived, it calls into the request path. That path has a timeout:
uint256 public constant VRF_REQUEST_TIMEOUT = 5 minutes;
// If a previous request exists and hasn't timed out, skip.
// If it timed out, re-request (replaces the old requestId mapping).
The comment is wrong, and the whole failure lives in the gap between the comment and the code. Re-requesting writes roundRequestId[roundId] = requestId, which does replace the round’s pointer to its latest request. But it also writes requestIdToRound[requestId] = roundId, and that mapping is never cleared. Every previous request id still points at the round. Nothing is replaced. The old requests stay live, stay fundable and stay capable of setting the round’s numbers, and the contract has no idea how many of them are out there.
Here is round 127, reconstructed from the logs:
| Request block | Fulfilled block | Depth | Tx index | Result |
|---|---|---|---|---|
| 52,441,920 | 52,445,308 | 3,388 | 78 | refused |
| 52,442,074 | 52,445,308 | 3,234 | 59 | set the numbers |
| 52,442,224 | 52,445,310 | 3,086 | 62 | refused |
| 52,442,378 | 52,445,309 | 2,931 | 70 | refused |
| 52,442,533 | 52,445,310 | 2,777 | 62 | refused |
The requests sit about 154 blocks apart, which at two seconds a block is the five-minute timeout doing exactly what it was written to do. The contract asked once, waited five minutes, asked again, and kept going until something answered. Five live requests for one round.
Then look at the fulfilment column. Everything landed inside three blocks after a stall of roughly an hour and fifty minutes, and the request that won was the second one. The first, oldest request landed in the same block as the winner, 44 log positions later, and was refused.
That is not a race against the clock. It is a race inside one Base block, settled at transaction index 59 against transaction index 78.
The losers do not get another go. The coordinator stores a commitment hash per request and clears it the moment it answers, regardless of how the callback went. Querying s_requestCommitments for a refused request at the block before its fulfilment returns a real hash; querying it today returns 32 zero bytes. The request is spent. The subscription was charged, the proof was verified and recorded, the number was handed over, and the game’s refusal changed none of that. There is no retry path, no refund and no way to ask again for the same number, because the request id is derived from the key hash and a seed that binds the key hash a second time. A number that gets refused is simply gone.
Which means the refusal is not a safety net that caught something. It is a receipt for a thing that already happened.
What the stall actually was
An oracle going quiet for 110 minutes sounds like an outage, and it was not one. In that same stretch of chain the coordinator fulfilled 144 other draws at ordinary depth. The only slow requests in the window belonged to Lotto39.
The release is easier to date than to explain. At block 52,445,307 the subscription received a SubscriptionFunded event taking its LINK balance from 2.610023 to 3.110023. The backlog cleared in the very next block.
A VRF node will not answer a request if the subscription cannot cover the worst case, and that reserve is set by the gas lane rather than by the actual gas price. Lotto39 uses Base’s 30 gwei lane with a 250,000 callback limit, so the gas-only reserve works out at 2.597279 LINK at the LINK/ETH rate on the day. The subscription held 2.610023. It was above the threshold by 0.49% throughout the stall, and the top-up moved it to 1.1974 times the requirement. The premium-inclusive version of the published formula comes to 3.895919 LINK, which the subscription never reached, before or after.
So neither documented threshold was crossed at block 52,445,307, and I cannot read a node operator’s internal policy from the chain. What is on the chain is this: a subscription sitting half a percent above its reserve was not served for 110 minutes, a 0.5 LINK top-up landed, and five queued requests were answered within three blocks. Running that close to a reserve requirement is the operational mistake underneath everything else here.
Four fair results, one of them arbitrary
This is the part worth sitting with.
A VRF proof guarantees that a given number was not chosen by anybody. It is a statement about one number. It says nothing whatsoever about which of several such numbers becomes a result.
Round 103 had four live requests. The randomness that set its numbers arrived at block 52,355,989, from the request made at 52,355,984, the newest of the four. The three older requests landed two, three and four blocks later and were refused.
Each refused fulfilment still emitted its output seed, and every delivered word on Base is rebuildable from that seed. Lotto39 derives its numbers with a documented selection-without-replacement loop over five words. So the alternatives are computable. Round 103 published:
7-9-18-22-30
The three sets that existed at the same instant, each backed by a valid VRF proof, each as unbiased as the one that counted:
- 3-10-13-18-36
- 13-17-20-28-38
- 19-31-37-38-39
Before quoting those, the derivation was checked rather than assumed. The five words stored on-chain for a round reproduce exactly from the event’s output seed via keccak256(abi.encode(seed, i)), all five matching. The selection loop, reimplemented, reproduces all 85 of the winning number sets the contract has ever published, with zero mismatches. The alternatives above are what the contract itself would have written had a different one of its own requests arrived first.
Nobody picked between them. Nobody could have predicted which would arrive first. But the choice was made by submission order, and submission order is the one part of this pipeline that a node operator controls.
I want to be careful here, because the gap between “a capability exists” and “someone used it” is the whole distance between analysis and accusation. I have no evidence of manipulation and I do not think any occurred. What I have is a measurement showing that when a game has several live requests for one round, the outcome is selected by whoever decides the order of the backlog, and that in both observed clusters the delivered order differed from the request order.
And then round 127 never drew
Round 127 got its numbers at block 52,445,308. It would have been 9-11-21-36-37.
It was never drawn. Reading the contract today, at round 185, roundRandomnessReady(127) is true, all five random words are sitting in storage, drawExecuted is false, and the published winning numbers are still 0, 0, 0, 0, 0. Across rounds 99 to 184 the contract emitted 86 RandomnessFulfilled events and 85 DrawExecuted events. Round 127 is the single gap.
The draw step runs in a later transaction, after the callback, on the keeper’s next pass. For round 127 that pass arrived after the processing window had closed, found the round no longer current, and moved on to 128. Round 127’s prize pool of 3.21 USDT never rolled forward either, which is why round 128 shows a pool of zero while 126 and 129 show 3.22 and 3.19.
In fairness to the operators of this contract: Lotto39 had zero tickets sold in every round in the window. Nobody lost a prize. The thirteen refused draws cost 0.00420812 LINK, about 8.5% of what the contract spent on randomness that week, which is roughly the price of a coffee in total. This is a demonstration, not a scandal.
It is a demonstration of something that scales, though. The mechanism that lost round 127 is indifferent to whether the pot was three dollars or three hundred thousand.
How common is this
Of the 62 contracts drawing randomness on Base in the window, 10 had at least one request open while an earlier request of their own was still unfulfilled. One of those ten is Chainlink’s own VRFV2PlusWrapper, where overlap is expected and harmless because each request belongs to a different end user. Excluding it leaves nine game contracts and 217 draws, 1.60% of everything the coordinator answered that week.
Two of those nine are unverified contracts running overlap rates above 50%. Whether their callbacks tolerate a second answer is not something anyone outside can check, which is its own answer.
What this means for a game you would actually trust
The fix is not clever. It is a rule about where state lives.
A VRF callback should do one thing: write the number down. It should not branch on anything that can change between the request and the delivery, because the whole point of asking an oracle is that you do not control when it answers. Lotto39’s callback is already close to this, and the comment above it explicitly says the heavy work is deferred. The guard that killed it is the one piece of conditional logic left in there.
Underneath that, a round should only ever be able to have one live request. If a timeout genuinely needs a re-request, the previous request id has to be unmapped first, so that a late answer is ignored rather than refused, and so that the game knows which one it is waiting for.
Satoshie’s raffles and coinflips take requests one at a time per draw. A draw holds exactly one outstanding request id, the callback writes the delivered words and nothing else, and no second request can be opened against a draw that already has one in flight. That removes the ambiguity rather than handling it: there is never more than one set of numbers eligible to become the result, so there is never an ordering decision for anybody to make. You can see how the games are put together at satosh.ie.
We also keep subscription balance well clear of the lane reserve, because the thing that started this entire sequence was not a bug. It was a balance sitting half a percent above a threshold.
Three questions worth asking any on-chain game:
- Can your contract have two live randomness requests open for the same round at once, and if the answers arrive out of order, which one sets the result?
- What does your callback do besides store the number, and what happens to the round if that extra logic reverts?
- Where does the draw actually get executed, what block does it read, and what happens if the transaction that executes it never runs at all?
The first two produce confident answers. The third is usually where you find a keeper nobody has looked at in months.
Honest limits
This is seven days of one chain, 13,590 draws, and a sample of one contract exhibiting the failure. Thirteen events is not a trend.
The alternative number sets are derived, not observed. They are what the contract’s own published code produces from output seeds that it did in fact receive, and the derivation reproduces all 85 real results, but no player ever saw them.
I cannot see a node operator’s queue, so the ordering within the backlog is described as measured, not as chosen. The reserve arithmetic uses the lane maximum and the LINK/ETH feed at the relevant block, and it does not explain the release cleanly, which is stated as a limit rather than smoothed over. And Lotto39 pays in LINK, which puts it in a minority: 13,396 of the window’s 13,589 draws were paid in ETH, where the threshold maths looks different.
The success field, though, is not a limit. It is a boolean, it is in every fulfilment event ever emitted, and it costs one line of code to watch. One game on Base spent this week discovering, thirteen times, that nobody was watching it.
📷 Photo by dylan nolte on Unsplash


