Back in August we published a three-tier framework for admin control in on-chain gaming, and the practical advice in it was blunt: ask for the contract address, and ask whether the admin key is a multisig. Tier one controls are fine. Tier two, anything that can reach funds or parameters, needs a multisig and a timelock and enough notice that users can leave. Tier three, anything that can reach an outcome, must not exist.
We applied that test to our own game contract and passed it. We had not published the same test applied to the contract our draws actually depend on. So this week I ran it on the Chainlink VRF 2.5 coordinator on Base, the contract that verifies every proof behind every Satoshie raffle and coinflip, and the answer is specific enough to be uncomfortable and specific enough to be useful.
Thirteen of its twenty-five state-changing functions are owner-only, and the owner is a four-of-nine Gnosis Safe with no timelock, no guard and no modules. One of those thirteen could raise the price of every draw on the chain by 59% in a single transaction with no notice to anyone.
TL;DR
- 13 of the coordinator’s 25 state-changing functions are owner-gated. Confirmed twice: by reading the verified source, and by calling all 25 from an address that owns nothing and recording which ones answer “Only callable by owner”.
- The owner is a 4-of-9 Gnosis Safe v1.3.0 at
0xcEf2893e396d530bdECB1F1467e69F7B994eC5Ad, nine EOA signers, 66 executed transactions, zero failures, zero modules and no guard. There is no timelock anywhere in the path. - The premium ceiling is 155%, not 60%. Simulated from the Safe’s own address: raising the native premium to 155 succeeds, 156 reverts. That turns this week’s chain-wide randomness bill from $128.22 into $204.35. The per-request flat fee is zero today and the only bound on it in the code is the width of a
uint32. - The record is restraint. Two
ConfigSetevents in 830 days, both in the first week, and the only difference between them is one gas constant. No proving key has ever been deregistered. No funds have ever been recovered. - The one power they have used is eviction. Five
ownerCancelSubscriptioncalls since January, the most recent eight days ago, on five subscriptions that had never once drawn. Every balance went back to its own owner. - 42.05% of the ETH in that contract is fee revenue, 7.4650 ETH against 10.2876 ETH of user balances, withdrawable in one call that emits no event. The accounting reconciles to the wei.
- Nothing in the thirteen can reach an outcome. The contract is not a proxy, and a proving key registered tomorrow cannot answer a request made today. Tier two, not tier three.
What I measured, and the probes that make it checkable
The coordinator is 0xd5D517aBE5cF79B7e95eC98dB0f0277788aFF634, deployed on 5 July 2024 at block 16,689,376, verified, thirty source files, solc 0.8.19. Blockscout reports no implementation address and no proxy type, which matters more than it sounds: the verification logic cannot be swapped out from under a pending draw.
Counting owner-only functions from source is a regex, and a regex is a guess. So I did it the other way as well. Take the ABI, filter to the 25 functions that are not view or pure, encode each one with zero-valued arguments, and eth_call every one of them from 0x00000000000000000000000000000000deadbeef, an address that owns nothing. Thirteen come back with execution reverted: Only callable by owner. The other twelve revert for ordinary reasons, InvalidSubscription and friends, or in the case of createSubscription succeed outright. The two methods agree on the same thirteen names. That is the whole list:
| What it reaches | Functions |
|---|---|
| Price | setConfig, setL1FeeCalculation |
| Liveness | registerProvingKey, deregisterProvingKey, ownerCancelSubscription |
| Exit | registerMigratableCoordinator, deregisterMigratableCoordinator |
| Money | withdraw, withdrawNative, recoverFunds, recoverNativeFunds |
| Succession | transferOwnership |
| Already spent | setLINKAndLINKNativeFeed |
That last one is a nice detail. It exists, it is owner-only, and it is dead: the body reverts with LinkAlreadySet if the LINK address is non-zero, which it has been since deployment. Twelve live powers, then, and one fossil.
The nine keys
The owner resolves to a Gnosis Safe proxy whose master copy is the L2 singleton, version 1.3.0. Reading it directly: getThreshold() returns 4, getOwners() returns nine addresses, every one of them an externally owned account. getModulesPaginated returns an empty array, so no module can bypass the threshold, and the guard storage slot is zero, so no guard inspects what the signers approve. The Safe’s nonce is 66, and a full-history log pull returns exactly 66 ExecutionSuccess events and zero ExecutionFailure events. The counts matching is the completeness check: if the log query had silently truncated, it would not have landed on the nonce.
The signer set has churned. The Safe was created on 4 December 2023 as a two-of-six, and on 14 January 2025 it removed three owners, added six and raised the threshold to four, all inside four minutes. Three weeks later it took ownership of the coordinator. Thirteen AddedOwner and ten RemovedOwner events later, the current nine settled on 26 March 2026.
Custody of the coordinator itself has moved twice. The deploying EOA held it for eight days, handed it to a different four-of-nine Safe on 13 July 2024, and that Safe handed it to the current one on 4 February 2025. Both handovers used the two-step transferOwnership and acceptOwnership pattern, which left 27.5 hours of public notice the first time and 7.1 days the second. Worth sitting with: the only power on this contract that announces itself before it lands is the one that hands over the keys. Repricing every draw on the chain is a single transaction with no announcement at all.
The ceiling nobody charges
Base’s live config is the one the docs quote: minimum request confirmations 0, max callback gas 2,500,000, staleness window 172,800 seconds, native premium 60%, LINK premium 50%, both flat fees zero. The interesting question is not what it is set to. It is what the code would let it be set to, and the only honest way to answer that is to try it.
So I simulated setConfig as eth_call from the Safe’s own address against current state. Raising the native premium to 155 succeeds. Raising it to 156 reverts with InvalidPremiumPercentage(156, 155), which is the constant PREMIUM_PERCENTAGE_MAX answering in its own words. The LINK premium takes 155 too. Setting fulfillmentFlatFeeNativePPM to 4,294,967,295 succeeds, because there is no cap on it other than the type. Setting the chain-wide minimum confirmations to 200 succeeds; 201 reverts. Setting the max callback gas limit to zero succeeds, which would break every consumer on Base at once. Every one of those calls from a non-owner address returns “Only callable by owner”.
Now price it against real traffic. Over the seven days to block 52,563,664 the coordinator delivered 14,037 fulfilments, 13,843 billed in ETH and 194 in LINK. The entire chain’s randomness bill for the week was 0.051164 ETH, $128.22, plus 0.0913 LINK. The median draw cost 0.000003966 ETH, about one cent.
| Change | The same week’s bill |
|---|---|
| As charged (60% native premium) | $128.22 |
| Premium at the 155% ceiling | $204.35, up 59.38% |
| Flat fee of 5 PPM, premium untouched | $304.11, 2.4 times |
| Flat fee of 1,000 PPM | $35,305, 275 times |
The premium ceiling is the boring half of that table. The flat fee is the half worth staring at, because a flat fee of five parts per million is 0.000005 ETH, about 1.25 cents, a number that looks like a rounding error and more than doubles the cost of randomness on this chain. It is zero today. It has been zero since the first block. The only thing standing between it and any other value is four signatures.
The record, which is the other half of the story
I pulled every administrative event the coordinator has emitted since deployment, by topic, across its full 35.8 million block history. Here is the complete list.
ConfigSet: two events. One at deployment, one four days later on 9 July 2024. The only field that differs between them is gasAfterPaymentCalculation, 56,000 down to 42,500. The premium pair has read 60 and 50 for 830 days. The flat fees have read zero for 830 days. The pricing config has been untouched for 826 days.
ProvingKeyRegistered: two, both on deployment day, the 2 gwei and 30 gwei lanes. ProvingKeyDeregistered: none, ever. L1FeeCalculationSet: two, the second on 10 July 2024 and untouched for 825 days since. FundsRecovered, NativeFundsRecovered and FallbackWeiPerUnitLinkUsed: none, the last of which is the price feed answering the question we asked about it last week.
CoordinatorRegistered: none. That one has a consequence. Migration to a future coordinator is gated on the owner having registered the target first, the s_migrationTargets array at storage slot 17 has length zero, and migrate() reverts CoordinatorNotRegistered for any address you hand it. There is no exit. The function has never been callable since the day the contract was deployed.
Two config changes in 830 days, both inside the first week, is a restraint record most protocols cannot show. It is also not a guarantee, and the difference between those two things is the entire point of writing any of this down.
The power they have actually used
Of the Safe’s 66 transactions, six touched the coordinator. One was accepting ownership. The other five were ownerCancelSubscription, inner selector 0xaa433aff, on five different third-party subscriptions: 8 January, 15 May, 26 May and 29 July this year, and 5 October, eight days ago, which is the most recent transaction this Safe has made of any kind. Across the five, 14.58 LINK and 0.1035 ETH went back to each subscription’s own owner. This is eviction, not confiscation, and the distinction is written into the function: the refund address is read from storage, not from calldata.
The question that matters for a game is whether a draw was in flight when the eviction landed. I pulled the complete request history of all five, and the answer is cleaner than I expected: not one of them had ever called for randomness. Five subscriptions created, funded, and closed by someone else without a single draw between them. These were dormant accounts being tidied up, and the money went home.
But the capability is not conditional on the account being dormant, and that is testable. I took a real draw from this afternoon, requested at block 52,561,027 at 17:10 UTC and answered four blocks later, and ran the state at block 52,561,028, with the request live and the randomness not yet delivered:
pendingRequestExists(subId)returns true.- The subscription’s own owner calling
cancelSubscriptionreverts with0xb42f66e8, which isPendingRequestExists(). - The Safe calling
ownerCancelSubscriptionon the same subscription, at the same block, succeeds. - The subscription owner calling
ownerCancelSubscriptiongets “Only callable by owner”.
The contract is not hiding this. The comment above the function says it outright: notably can be called even if there are pending requests, outstanding ones may fail on chain. They do fail, and the mechanism is worth knowing. Fulfilment decrements s_consumers[sender][subId].pendingReqCount, a deleted subscription has that counter zeroed, and decrementing zero on a uint64 underflows and reverts the entire fulfilment. A draw whose subscription is closed mid-flight cannot be answered afterwards by anyone, including the oracle that already has the proof.
So the user is blocked from closing an account with a draw in flight, and the administrator is not. For a raffle that has already taken entries, those are not the same risk at all.
Whose money is in there
The coordinator holds 17.752604861686331128 ETH, and s_totalNativeBalance reports the identical number to the wei. Two of the internal balances have no getter, so I read them out of storage slot 11, where s_withdrawableTokens and s_withdrawableNative sit packed as a pair of uint96 values. Then, at one pinned block so nothing drifts, I summed all 1,168 active subscriptions.
| At block 52,563,664 | ETH | LINK |
|---|---|---|
| Sum of 1,168 subscription balances | 10.287622480591135498 | 1,475.261988607982307230 |
| Withdrawable by the owner | 7.464982381095195630 | 834.812590304576235212 |
| Contract total | 17.752604861686331128 | 2,310.074578912558542442 |
| Residual | 0 | 0 |
Zero on both sides, to the wei. Which settles something I have had to hedge about in print before, when I noted that the coordinator’s internal total ran 36 to 42% above the sum of subscriptions and could only guess that the gap was unwithdrawn fees. It is unwithdrawn fees, exactly: 42.05% of the ETH and 36.14% of the LINK in that contract are revenue, roughly $18,700 and $10,800 at today’s prices. Neither withdraw nor withdrawNative emits any event, so the only way to notice about $29,500 leaving is to be watching the balance.
What the thirteen cannot do
This is the part that keeps the post honest, and it is also the part that lets us keep selling provable fairness with a straight face.
There is no owner function that writes a random word, re-rolls a result, or edits a delivered proof. The contract is not a proxy, so the verification code is the code. And registering a new proving key does not reach backwards: the request ID is keccak256(keyHash, preSeed) where the pre-seed is itself keccak256(keyHash, sender, subId, nonce), so a key registered tomorrow produces different request IDs and a proof under it would hit NoCorrespondingRequest against anything requested today. I had assumed the opposite before reading it and was wrong.
What the owner can do is price, liveness and exit. Make draws more expensive. Make a gas lane stop answering, which strands any request already on it with a NoSuchProvingKey revert. Close a subscription out from under a live draw. Hold the only door to a future coordinator shut, which it currently does by default. Under our own framework that is tier two across the board, and tier two is defensible. It is just not the same sentence as “no admin key”, and we should never have let the shorter sentence stand in for the longer one.
What changes at Satoshie
Three things, none of them dramatic.
We monitor the governance surface rather than the balance. Four topics on one address, ConfigSet, L1FeeCalculationSet, ProvingKeyDeregistered and OwnershipTransferRequested, plus a balance watch to catch the withdrawal that emits nothing. A 59% repricing should reach us as an alert, not as an unexplained burn rate three weeks later.
We treat the subscription as working capital, not a treasury. Holding a quarter’s randomness budget behind a parameter someone else controls is a position, not a convenience, and weekly top-ups cost a few cents in gas.
And the refund path gets the third failure mode written into its test suite. We have already argued twice that every draw needs a permissionless, contract-enforced timeout that anybody can trigger: once for the unfunded subscription, once for the callback that reverts on arrival. This is a third way for randomness never to land, and the first one where the trigger is a third party acting deliberately rather than a configuration going wrong. The answer is the same answer, which is the point. If your game’s response to “the randomness never came” depends on who stopped it, you have written five recovery paths where one would do.
The limits of this
A signer address with a transaction count of zero has not necessarily been idle. Safe signatures are gathered off chain and only the submitter pays gas, and in fact twelve different addresses submitted those 66 transactions, several of which are not current owners. I can count keys. I cannot count people, and nine addresses is not evidence of nine independent humans.
“No timelock” here means no on-chain delay, no Safe guard and no module. Chainlink may run an internal change process with notice periods I cannot see from the chain, and the absence of a delay in the contract is not the absence of a process. The fee figures are a single block’s snapshot and move every few seconds. And none of this is evidence of intent: the measured behaviour over 830 days is two config edits in the first week and five tidy-up evictions, which is a better record than most of the stack any of us depend on.
Three questions worth asking any on-chain game
Who owns the oracle contract your draws depend on? One owner() call, one eth_getCode on the answer, and if it is a Safe, getThreshold() and getOwners(). Thirty seconds, no API key.
What is the highest fee your coordinator’s code permits, not the one it charges? Find the premium ceiling constant and check whether the flat fee has a ceiling at all. On Base the answers are 155% and no.
If the subscription funding your draw were closed with your request in flight, what does your contract do? If the answer involves anyone deciding anything, it is not an answer.
Check it yourself
Everything above came from a public RPC and a public explorer API. Enumerate the owner-gated set by calling all 25 non-view ABI functions from any address you like and reading the revert strings. Simulate the repricing with eth_call from the owner address, which costs nothing and changes nothing. Pull the admin history by topic from Blockscout’s log endpoint across the full block range, and check your counts against the Safe’s nonce. Read storage slot 11 for the two withdrawable balances, then sum getSubscription over getActiveSubscriptionIds(0, 0) at the same pinned block and confirm the residual is zero before you believe any of it, including this.
📷 Photo by David Trinks on Unsplash


