Every Chainlink VRF request on Base carries a keyHash. It names the oracle key that will sign the proof, and by extension the gas lane, the price and the ceiling your callback is allowed to cost. Two keys are registered on Base. Over the last seven days one of them answered 76.66% of all draws and the other took the rest.
We have written about what those keys cost, who delivers against them, and who can reprice them at the coordinator. What nobody had measured is the other end of the wire: inside the game contract, who decides which key gets asked, and can that decision change after you have entered?
So I pulled every request on Base for a week and read the code behind each one.
TL;DR
- 12,878 VRF requests on Base over seven days (blocks 52,433,877 to 52,736,276) came from 59 distinct consumer addresses across two key hashes.
- 50 of those 59 contracts, carrying 96.20% of the draws, can have their randomness source changed after deployment by an owner, an admin role or a proxy upgrade.
- Exactly two contracts cannot. They account for 92 draws, 0.71%, and they share one design decision: both declined to inherit Chainlink’s own consumer base class.
- The strongest switch is
setCoordinator(address), inherited by default fromVRFConsumerBaseV2Plusand present in the deployed bytecode of 35 of 59 contracts (76.21% of draws). - Nobody has thrown any of these switches. All 40 readable coordinator pointers still name the canonical coordinator and none changed across 180 days of sampling. The risk here is unexercised capability, not a live abuse.
- Of the 23 verified contracts with an owner-settable
keyHash, eight emit no event at all when it changes. Those eight carry 86.65% of the week’s draws.
What I measured and how
I took every RandomWordsRequested log emitted by the VRF 2.5 coordinator at 0xd5d517abe5cf79b7e95ec98db0f0277788aff634 over blocks 52,433,877 to 52,736,276, which is the seven days to 17 October 2026. That is 12,878 requests. The event indexes the key hash, the subscription and the sender, so grouping it gives a complete census of who asked for randomness on Base this week and which key they asked for.
Then, for each of the 59 sender addresses, I fetched the verified source where it existed, the implementation source where the sender was a proxy, and the deployed runtime bytecode in every case. Thirty-one senders are verified. Twenty-seven are not, and one is not a contract at all, which I will come back to. For the unverified ones, searching the runtime bytecode for a function selector still proves a function exists, even when the source does not.
Finally I read each contract’s current configuration live, and read it again at four historical block heights going back 180 days, to find out whether any of this had ever moved.
The small switch: which key answers you
Twenty-three of the verified contracts hold the key hash in ordinary storage with a function that writes it. The access control is uniformly an owner or an admin role, the argument is uniformly a free bytes32, and in every case the change takes effect on the very next request. There is no delay anywhere, with one exception I will get to.
The names are familiar if you have deployed one of these: setKeyHash, setVrfConfig, setVRFParams, updateVRFConfig. The biggest consumer on Base this week, a contract called GridMining with 9,014 draws, 69.99% of the entire chain’s randomness, uses setVRFConfig behind onlyOwner and emits nothing when it fires.
That silence is the part worth sitting with. Eight of the 23 setters emit no event. If an owner re-lanes one of those contracts mid-raffle, there is no log to find. The only way to detect it is to read the storage slot before and after, which means you had to already suspect it, which means in practice nobody will.
The big switch: which coordinator answers you
The key hash is the small lever. The large one ships in the box.
Chainlink’s VRFConsumerBaseV2Plus, the base class almost every consumer inherits, includes this:
function setCoordinator(address _vrfCoordinator) external override onlyOwnerOrCoordinator {
if (_vrfCoordinator == address(0)) revert ZeroAddress();
s_vrfCoordinator = IVRFCoordinatorV2Plus(_vrfCoordinator);
emit CoordinatorSet(_vrfCoordinator);
}
And the fulfilment path trusts whatever that variable currently holds:
function rawFulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) external {
if (msg.sender != address(s_vrfCoordinator))
revert OnlyCoordinatorCanFulfill(msg.sender, address(s_vrfCoordinator));
fulfillRandomWords(requestId, randomWords);
}
Read those two together. The owner of a consumer contract can point s_vrfCoordinator at any address, including their own, and that address may then call rawFulfillRandomWords with numbers it chose. No proof is checked at the consumer. The proof is checked at the coordinator, and the owner has just replaced the coordinator.
This is not a vulnerability and it is not a criticism of Chainlink. It is the documented migration hook in IVRFMigratableConsumerV2Plus, and it exists so that consumers can be moved to a new coordinator version without redeploying every game on the chain. It is a reasonable piece of engineering. It is also, in 35 of the 59 contracts drawing randomness on Base this week, a function that converts the game’s owner key into unilateral control of the game’s outcomes.
A further nine senders are proxies. For those the question does not even arise: the implementation can be replaced wholesale, so every property of the code is a property of today’s implementation only. Between the inherited setter and the upgradeable proxies, 50 of 59 contracts and 96.20% of the week’s draws run on randomness whose source can be changed by somebody after you entered.
The two that cannot
| Category | Contracts | Draws | Share |
|---|---|---|---|
setCoordinator in bytecode |
35 | 9,814 | 76.21% |
| Upgradeable proxy | 9 | 2,538 | 19.71% |
| Owner-settable key hash only | 6 | 36 | 0.28% |
| Fixed at deployment | 2 | 92 | 0.71% |
| Unverified or not a contract | 7 | 398 | 3.09% |
The two are Lotto39 at 0xe7b38fd0dd070cead9c3a17ef0eea5f39f3bd4e0, with 88 draws, and a contract called Interregnum at 0x208c5f4a80a268678854cb95b26c8f063cfe26a2, with four. Both declare the coordinator address and the key hash as immutable, which puts them in bytecode rather than storage. Neither has a setter for either value. And crucially, neither inherits VRFConsumerBaseV2Plus. They hand-rolled the fulfilment guard against their own immutable coordinator instead:
if (msg.sender != address(COORDINATOR)) {
revert OnlyCoordinatorCanFulfill(msg.sender, address(COORDINATOR));
}
That is the whole difference. Two teams out of fifty-nine wrote six lines themselves rather than inheriting a base class, and in doing so they are the only two on Base whose randomness source is a fact about their deployment rather than a current setting.
I am not holding either up as a model of general quality. Lotto39 is the contract from last week’s post on failed fulfilments, where all 13 of the chain’s success = false deliveries belonged to it, 13.7% of its own draws. The contract with the worst delivery record on Base is also one of the only two that cannot be repointed. Those are different virtues, and conflating them is how fairness arguments go wrong.
The one address that did switch has no code
Fifty-eight of the 59 senders are contracts. One is not.
0x380fbf99a903253e14b9eb797807b4b92cd4b019 holds no code whatsoever. It is a plain private key that has been registered directly as a consumer on a subscription, and it called the coordinator three times this week. It is also the only address in the entire census that used both keys: the 30 gwei lane at block 52,660,229, then the 2 gwei lane twice, 281 and 411 blocks later. Nine minutes and twenty-two seconds separate the lane change.
So the single real lane switch on Base this week came from the one consumer where there was never any code to read in the first place. For a contract you can at least go and check what the setter is guarded by. For an address like this, the configuration is whatever a human types into the transaction, and the only thing you can audit is what already happened.
Seven thousand draws you cannot read the setting for
Reading these settings at all turned out to be the easier half of the problem. Thirty-seven of the 59 consumers expose a public getter for the key hash, which covers 92.72% of the draws. The other 22 expose nothing: no keyHash(), no s_keyHash(), no variant I tried. Those contracts carry 938 draws, 7.28% of the week. For every one of them, a player cannot establish by any call which key is about to answer their draw. The value is sitting in a storage slot whose position you would have to derive from source that, in most of these cases, was never published.
Fifteen of those unverified consumers are the same contract. They share a byte-identical creation prefix, were each used exactly once, and each carries setCoordinator in its runtime code. Somebody is deploying a template per draw. Whatever that pattern is for, it means the fifteen separate “audits” you might perform on them are one audit repeated, against source nobody has published.
There is also a middle case worth naming. One contract, RaffleDraw, holds its key hash in plain storage, writes it only in the constructor, and has no setter at all, so in practice the lane is pinned for the life of the deployment. It still inherits setCoordinator. Pinning the key hash is the visible half of the job, and on its own it buys you very little.
Nobody has actually done it
Here is the measurement that stops this being a scare story.
Forty of the 59 consumers expose a readable coordinator pointer. All forty name the canonical coordinator, and not one of them returns a different value at 7, 30, 90 or 180 days ago. Thirty-seven expose a readable key hash. Every single one is constant across all five sample heights. In 180 days of Base’s randomness, nobody has re-laned a live game and nobody has repointed a consumer.
Five sample points cannot rule out a change that was reverted between them, and I should say so plainly. But the direction is unambiguous: this is a universal, universally unexercised capability. Which is exactly why it is worth writing about now, when nothing has gone wrong, rather than after something has.
Who is actually holding the lever
A switch is only as serious as the hand on it, so I read the owner of each of the larger settable contracts and then checked what kind of account that owner is.
| Contract | Draws | Owner type |
|---|---|---|
| GridMining | 9,014 | Plain externally owned account |
| GameManager | 1,975 | EOA with an EIP-7702 delegation |
| Chainlink VRF wrapper | 529 | 4-of-9 Safe |
| CasinoCoreV2, Blackjack | 518 | 3-of-7 Safe |
| Coinflip, Roulette | 154 | EOA with an EIP-7702 delegation |
| MoonpotManager | 40 | 2-of-3 Safe |
Seventy percent of Base’s randomness this week came from a contract whose configuration is one signature from one ordinary private key. The 4-of-9 Safe on the Chainlink wrapper is the same multisig shape we found holding the coordinator’s own admin functions, which is reassuring at the oracle and tells you nothing about the game in front of it.
One contract got this completely right
The smallest consumer in the set, with a single draw all week, is the only one that treats its VRF configuration as a governed parameter rather than a setting.
MegaCryptoLottery keeps a mapping of numbered VRF configurations. Changing one requires proposeFutureVrfConfig, which publishes a hash of the proposal and an activation timestamp set to block.timestamp + CONFIG_DELAY, where CONFIG_DELAY is a two day constant. Only after that can activateFutureVrfConfig increment the version. And the part that matters most: every round records the configuration version it was opened under, and requests randomness against that stored version rather than the current one. There is even a drawEvidence view that returns, for any round, the coordinator address and config version that actually produced its result.
That design makes the question “which key answered my draw?” answerable by a stranger, for a round that closed months ago, without trusting anyone’s memory. It is not hard. It is roughly forty lines. One contract out of fifty-nine has it.
A correction to our own earlier post
When we measured confirmation settings across Base, we wrote that 68 of 69 consumer contracts hard-code a single number forever. That was a statement about observed behaviour, and it was accurate: the value never varied across requests. This week’s reading shows it was not a statement about capability. The same owner-only function that sets the key hash sets the confirmation count in almost every one of these contracts. “Never changed” and “cannot change” are different claims, and the first one quietly reads like the second.
That is the general lesson of this measurement. A parameter that has held steady for 180 days looks, in any dashboard or explorer, exactly like a parameter that is fixed. The only way to tell them apart is to read the code, and almost nobody does.
The good news: it is detectable afterwards
If an owner did repoint a consumer at a coordinator they control, the forgery would leave a specific hole. The game would emit its usual “randomness received” event, and the real coordinator would have no matching RandomWordsFulfilled log for that request. Anyone reconciling the game’s own events against the coordinator’s logs would see a result that no proof ever backed.
Nobody does that reconciliation by default. It is one query against a public endpoint and it should be a standard check, which is a reasonable thing for any player to demand of any game that calls itself provably fair.
What Satoshie commits to here
Our claim is deliberately narrow and it is a claim about deployment, not about intentions. A Satoshie raffle contract goes live before entries open with its ticket price, entry cap and prize structure fixed in that deployment for the life of the draw, and with the VRF coordinator named in public code before anyone stakes anything. The proof is verified on-chain before the payout callback can act, the winner is computed in that callback, and the payout happens in the same transaction. No admin key can reassign a result.
This week’s measurement adds a specific commitment to that list: the key that answers a draw is the key you could read when you entered it. If we ever need to move lanes, the honest way to do it is the way MegaCryptoLottery does it, with the version pinned at the round and the evidence published, not with a silent write to a storage slot between your entry and the draw.
The limits of this
Four things this measurement does not establish. First, seven days is a week of traffic, not a complete census of Base game contracts; a contract that drew nothing this week is absent from all of it. Second, the bytecode selector search proves a function exists, not that it is reachable or correctly guarded, so the 35 is a ceiling on the count rather than a proof about each one. Third, five historical sample points cannot exclude a change that was made and reverted between them. Fourth, and most importantly, nothing here is evidence that any operator has done anything wrong, and I found no sign that any of them has.
There is also a reading of this that cuts the other way. An owner who can repoint a coordinator is usually also an owner who can pause the game, drain the treasury or upgrade the whole contract. The randomness switch is rarely the most dangerous key in the building. What makes it worth naming is that it sits directly under the word “provable”, and the proof is the thing players are being asked to rely on.
Three questions worth asking any on-chain game
- Can your contract’s owner change the coordinator address? If the contract inherits
VRFConsumerBaseV2Plusand nothing overrides it, the answer is yes. - Does the key my draw used get recorded with my draw? Not the key the contract uses today, the key that was in force when the round opened.
- If the configuration changed tomorrow, what would I see? If the answer is an event, good. If the answer is a storage slot, you would see nothing.
The photograph at the top of this post is a set of points. The crossing exists whether or not anyone throws it, the train goes where the ironwork is currently set, and from inside the carriage the two outcomes are indistinguishable until you are somewhere else entirely. Fifty of fifty-nine contracts drawing randomness on Base this week have those points installed. Nobody has touched them. That is the current setting, not a property of the track.
📷 Photo by Mihai Lazăr on Unsplash


