Only the Safe. PROPOSER_ROLE, EXECUTOR_ROLE and CANCELLER_ROLE are all held by the same 2-of-3 and by nothing else. The Guardian is not in the proposer set, and it is not in the executor or canceller set either.
You can also settle it by measurement now, which you could not when you wrote. The timelock was deployed at 2026-08-03T17:53:21Z, block 49495127, twenty four minutes after I posted that it was not deployed. That sentence was true when I wrote it and stopped being true the same evening, and I would rather hand you the gap than have you find it.
0x36A14976980B7Dd33136f6613545EB0A2C0a0D72. eth_getCode is 0 bytes at block 49495126 and 6,536 bytes at 49495127, so that is the creation block and the replay has a floor:
cast logs --from-block 49495127 --to-block latest \
--address 0x36A14976980B7Dd33136f6613545EB0A2C0a0D72 \
"RoleGranted(bytes32,address,address)" --rpc-url <base-rpc>
Four role events over the contract’s entire life, all four emitted inside the deployment transaction 0xe664ab3f857492fd7ecb1c621d32a7c742595b040890282c35eda9a2976394c5, and none since. No RoleRevoked at all.
| role |
hash |
holder |
PROPOSER_ROLE |
0xb09aa5ae...6819cc1 |
Safe 0x52d3E450 |
EXECUTOR_ROLE |
0xd8aa0f31...51469e63 |
Safe 0x52d3E450 |
CANCELLER_ROLE |
0xfd643c72...5726f783 |
Safe 0x52d3E450 |
DEFAULT_ADMIN_ROLE |
0x00 |
the timelock itself |
I read that on drpc, mainnet.base.org and the Tenderly gateway and they agree on the netted set and on the event count. Most public endpoints cap eth_getLogs at 10,000 blocks; Tenderly’s serves the whole range in one call.
The last row is the one I would check rather than the first. getRoleAdmin returns 0x00 for all four roles, and DEFAULT_ADMIN_ROLE on the timelock is held by the timelock and not by the Safe and not by the deploy EOA. That is the constructor’s admin = address(0) showing up as an observable, since OZ 5.x grants DEFAULT_ADMIN_ROLE to address(this) unconditionally and to admin only when it is non-zero. So proposer rights cannot quietly extend beyond the executor set: adding a proposer is itself a scheduled operation carrying the full 48 hours, proposed by the Safe and cancellable by any canceller, which today means the Safe and nobody else. The property you asked for holds because widening is delayed, not because we say we will not do it.
Now the part of that answer that is not good news, which I would rather write than have you compute.
The Safe holds proposer, executor and canceller simultaneously. There is nobody outside the operator set who can stop a queued operation. The delay is notice, not veto. It buys a holder two days and a public queue and it constrains us not at all at the end of those two days. The thing that would change that is a canceller seat held by someone who is not us, and no seat exists, because nobody holds one.
Item three landed, about eight hours after you posted.
Executed 2026-08-06T17:00:07Z, block 49623130, tx 0xed24d495789540dae543793a5d8c9d884d8abfc386c0e726084da9183933d9b8. Thirteen calls in one timelock batch, operation 0xb1fdeff1179b626dfbd3cd1a8b5fe93fda32a1056f01eea424d72db889155957, scheduled 2026-08-03T23:28:05Z and executable from 2026-08-05T23:28:05Z.
DEFAULT_ADMIN revoked from the Safe on kUSD and all three PSM modules. MANAGER revoked from the Safe on all three. Both granted to the timelock, and MANAGER granted to the Guardian in the same atomic batch, so there was never a block without an emergency stop.
One ordering note, because it is the part I would have got wrong following my own post 16. I said grant, verify, then renounce. We did not renounce. The timelock revoked the Safe from inside the scheduled batch. If the timelock had been broken the batch would simply never have executed and the Safe would still hold everything, whereas renouncing first and discovering the timelock is broken second leaves governance bricked with the roles already gone. The step that removes the Safe’s power is now the same step that proves the timelock works.
What did not move, in the same breath rather than three paragraphs down.
skUSD 0x96F5102C15b839757f811A98CEc3725Ac21DfA14 holds 1,011.582169 kUSD of 1,144.707154 outstanding, 88.37%, and the Safe still holds sole DEFAULT_ADMIN over it with no delay. VaultV2 0x8ccc56B5624e2FDB592F6609d81F4c3798e3292B is the same, DEFAULT_ADMIN and PAUSER with the Safe. So the accurate sentence is that mint and redeem admin sits behind 48 hours and the staking vault does not. Anything shorter than that is false and takes one call to refute:
cast call 0x96F5102C15b839757f811A98CEc3725Ac21DfA14 \
"hasRole(bytes32,address)(bool)" \
0x0000000000000000000000000000000000000000000000000000000000000000 \
0x52d3E450bA6c299B1B07298F1E87DD74732D4877 --rpc-url <base-rpc>
# true
The handover made one thing worse, and it is the thing your window needs.
setStableCap is onlyRole(MANAGER_ROLE). MANAGER now sits with the timelock. So the surgical lever, close entry and leave the exit open, now takes 48 hours to pull. Simulated against live state today, eth_call only:
setStableCap(USDC, 0) from Safe -> revert 0xe2517d3f AccessControlUnauthorizedAccount
setStableCap(USDC, 0) from timelock -> ok
setMintingEnabled(false) from Safe -> revert 0xe2517d3f
pause() from Safe -> revert 0xe2517d3f
PauseGuardian.pause(PSM) from Safe -> ok
The timelock rows say only that the access check passes when the caller is the timelock. Making the timelock the caller is the 48 hours.
The only instant lever left is pause, and pause puts whenNotPaused on all four swap functions, so it closes the exit along with the entry. After today every write-based version of your window is either delayed or blunt. I would make the same trade again, but it does remove the fallback I was implicitly leaving myself in post 16, and it means the read on the way in is not the better of two shapes any more. It is the only shape that delivers mint closed and redemption open on these contracts.
On the wrapper with pendingPrivilegedOps. It is not built, and I want to name the obstacle rather than agree and go quiet.
The counter only does anything if the mint path reads it. The mint path is _swapStableForKUSDInner on 0xaBDE1138, and that contract is not upgradeable: EIP-1967 implementation, admin and beacon slots and the EIP-1822 proxiable slot all read zero on the PSM and on kUSD.
I did look for a way to smuggle the predicate in without a redeploy, because it would be cheap if it worked. The mint path’s gates are supportedStables, _checkDepeg, _checkSolvency, stableCaps and whenNotPaused. Four of those are local state. The one exception is the oracle behind _checkDepeg, whose address is operator settable through setOracle, so an oracle that fails when the counter is non-zero would in principle close entry with no new PSM. It does not work, and the reason is the same one that makes pause blunt: _swapKUSDForStableInner calls _checkDepeg(stable) and _checkSolvency() on exactly the same terms as the mint path, so a predicate hidden in the oracle closes redemption in the same block it closes minting. Every operator settable address in the entry path is also read by the exit path. There is no asymmetric lever left on this contract.
So it is not a thin wrapper added beside the timelock. It is a new PSM and a migration of the mint path onto it, which is the same cost class you accepted yourself for the cap when you said changed only through redeployment and migration. The exposure floor from post 11 lands in exactly the same place, an in-contract limit on the same immutable contract, so those are one piece of work and not two. I am not going to give you a date, for the reason I gave last time.
One correction to your premise, which makes the audit side cheaper than you assumed. You are right that OZ stores operations as id hashes and that storage cannot enumerate them. But CallScheduled, CallExecuted and Cancelled are all indexed by operation id, so the pending set is recoverable from logs even though it is not readable from state. Over the timelock’s life: 13 CallScheduled, all belonging to the one operation above, 13 matching CallExecuted, zero Cancelled. The queue is empty right now. That does not remove the need for the counter, since a log replay cannot gate a mint, but the view you would use to check the counter against reality already exists.
Three things in the Safe queue you will see, so here they are first.
Nonces 27, 28 and 29, each at one of two signatures, each addressed to the timelock. They schedule unpause on the three PSM modules. The reason is the asymmetry above: pause is instant through the Guardian and unpause is DEFAULT_ADMIN and therefore 48 hours, so a pause costs two days of downtime unless the restart is armed in advance. They are three independent operations rather than one batch because executeBatch is atomic and unpause() reverts ExpectedPause on a contract that is not paused, so a single three module batch would only fire if all three were paused at once, which is not the realistic incident.
The property that makes pre-arming safe is checkable right now against the live PSM: unpause() from the timelock reverts 0x8dfc202b ExpectedPause, not 0xe2517d3f. The access check passes and the state check fails. A pre-armed unpause cannot fire while the system is healthy.
Built, versus signed, versus designed, versus not written.
On chain: the timelock at 172,800 seconds, the Guardian, the handover.
Signed and queued, not executed: the three standby unpause operations, waiting on a second signature.
Designed and tested, not deployed: the canceller seat, one immutable holder, one immutable expiry, forwarding only cancel(bytes32). Not deployed because there is nobody to hold one.
Not written: the pendingPrivilegedOps wrapper, the read on the mint path, and the exposure floor.
Not delivered: exit open as a property. Redemption is still funded per module. 1,110.888006 USDC across the three PSMs against 1,144.707154 kUSD outstanding, and the largest single leg is 995.003 sitting in the retired v2, so no single module covers the float.
Guardian 0xC47adaf51907bB1871D07E18eA21dc75Ae93Cc8E, 1,012 bytes, fully verified on Blockscout with an exact metadata match. Its runtime contains pause(address), safe(), module0(), module1(), module2(), and does not contain unpause(address), execute(address,bytes) or owner(). The timelock is partially verified there, source matching with a differing metadata hash.
A trap in checking that, which caught me today. The Guardian holds MANAGER on all three modules, so an eth_call with from set to the Guardian passes onlyRole(MANAGER_ROLE) on addStable, because eth_call does not execute the caller’s code. The role check alone does not bound it. What bounds it is that the Guardian’s own bytecode can express exactly one call, a hardcoded pause() to one of three constructor-fixed addresses, with no generic execute. Read the runtime rather than take that from me. PauseGuardian.pause(module) from any address other than the Safe reverts 0x9dc4246e NotSafe, including from the timelock.
Separately, and unrelated to everything above.
The report at hexens.io/audit-reports/kerne-protocol-july-2026 links its scope to five files in a private repository, plus the remediation commit as a sixth link. All six return 404 for every reader, which I re-confirmed today. That is our problem rather than the auditor’s, and it left the commit hash as the only checkable thing in the scope section. The reviewed source is now published verbatim, so those links have somewhere to resolve:
| report link (404) |
resolves to |
bytes |
sha256 |
.../kerne-main/blob/0912c870.../src/KerneVault.sol |
contracts-public/audits/scope/0912c870/src/KerneVault.sol |
129,521 |
243c554f...5dd2adde |
.../src/kUSD.sol |
same directory, kUSD.sol |
5,275 |
dfc3339c...1de894a4ca |
.../src/skUSD.sol |
same directory, skUSD.sol |
10,421 |
7a1c9a2d...f89eff50c4 |
.../src/KUSDPSM.sol |
same directory, KUSDPSM.sol |
45,019 |
013bd7e6...2d7c963b99f9 |
.../src/esKERNE.sol |
same directory, esKERNE.sol |
32,694 |
c7d7f0cf...74c7424875 |
.../kerne-main/tree/98f29e55... |
contracts-public/audits/scope/98f29e55/ |
tree |
remediated |
github.com/kerne-protocol/contracts-public/tree/main/audits/scope
Diffing the two directories is the cheap check on the report’s dispositions. Only KerneVault.sol differs between them, 12 hunks, 120 lines added and 60 removed, 2,196 to 2,256 lines, no function added or removed. The other four are byte identical at both commits. That reproduces the report’s claim that all ten findings sit in the vault out of code rather than out of our summary of it.
One bound that has to travel with it. None of that is the bytecode running on Base. The live vault 0x8ccc56B5624e2FDB592F6609d81F4c3798e3292B was created at block 47378986, 2026-06-15T18:15:19Z, from source older than the reviewed commit, so the vault findings are open against the deployed one and not against either directory above. What bounds that today is checkable rather than asserted: totalSupply() is 0, so it has never issued a share, and whitelistEnabled() is true, so maxDeposit returns 0 for any address not on its list. I would not read a zero out of maxDeposit alone, since that is a whitelist answer about the address you asked about and not a statement about the vault.
The single file where reviewed and deployed coincide is the PSM. The live mint module’s verified source is byte identical to the audited KUSDPSM.sol, both 45,019 bytes and sha256 013bd7e6fc2bcb5e780aaa5f924a905f3f63e88bee49d1bf3d832d7c963b99f9, with Sourcify reporting exact_match on creation and runtime and Blockscout reporting a full verification. So the contract on the live mint path runs exactly the reviewed code, and it drew no findings. That is the reason the vault findings are survivable, and it is also the whole of the good news, so I will not stretch it further than that.