Timelock Recovery Drill: Test Admin Access Before Mainnet

AKZ7...apD2
21 Aug 2026
50

A timelock proves that a privileged operation must wait. A recovery drill proves that the right people can still propose, cancel and execute it when one admin path is unavailable.

A timelock can delay a dangerous call without providing any way to recover from a lost governance path. OpenZeppelin warns that a self-administered TimelockController and the contracts it controls can become locked indefinitely if the required proposer or executor accounts become unavailable.
That failure is discoverable before mainnet. Reproduce the deployed role topology on a fork, make the actor whose loss you want to survive unavailable to the test harness, and run one exact administrative operation from scheduling through its target postcondition. If the operation cannot start or finish, the result is not a partially successful drill. It is NOT READY.
This article uses OpenZeppelin Contracts v5.6.1 as the reference implementation. Other timelocks can define different role and timing semantics.

Trace the real authority before testing it

Start with the privileged function that the recovery path must reach. Depending on the system, the authority may be the target contract, a ProxyAdmin, a UUPS authorization function or a beacon owner. An address found in a proxy admin slot may itself be a contract, not the final human or multisig authority.
Trace the path in both directions:

  • target function to its owner or role admin.
  • owner or role admin to the timelock.
  • timelock to its DEFAULT_ADMIN_ROLE, PROPOSER_ROLE, EXECUTOR_ROLE and CANCELLER_ROLE holders.
  • each contract-held role to the signers and thresholds that can actually use it.

Base AccessControl does not enumerate role members on-chain. Reconstruct membership from RoleGranted and RoleRevoked events, then confirm every candidate address with hasRole. Deployment files and an organizational chart are leads, not proof of current authority.
A smart contract control specification that stops at "multisig plus timelock" is incomplete. It must identify the target, the controlling contract, every relevant role, the current members and the deployed implementation version.

Freeze one recovery operation

Choose a useful but harmless operation on a fork. One example is granting a replacement proposer role to a lab multisig that represents the recovery signer set. If cancellation is part of the intended path, test that role separately: in v5.6.1, proposers supplied to the constructor also receive CANCELLER_ROLE, but granting PROPOSER_ROLE later does not grant the canceller role automatically.
The operation record must preserve the encoded call, not just a ticket saying "rotate admin." For a single call, the OpenZeppelin v5.6.1 implementation derives the operation ID from five inputs:

  1. target address.
  2. ETH value.
  3. calldata.
  4. predecessor ID.
  5. salt.

Changing any one of them creates a different ID. Record the output of hashOperation before scheduling and confirm that the ID in CallScheduled matches it. Use a fresh salt for each completed drill because an executed ID remains Done and cannot be scheduled again in the same state. A canceled operation returns to Unset in this implementation and can be scheduled again.

Run the drill with the primary path unavailable

Fork the relevant chain at a recorded block and pin the deployed bytecode or library version. Do not give yourself an undeclared super-admin merely because the test framework can impersonate any address.
Make the primary actor unavailable to the test harness; do not revoke its on-chain role or otherwise change the captured topology. If a Safe, Governor, module or another contract holds a timelock role, do not impersonate that contract address. Submit through its actual signer threshold, module or proposal lifecycle. Otherwise, label the result as a downstream TimelockController test rather than an end-to-end recovery drill.

  1. Make the assumed-lost actor unavailable. Disable access to the primary proposer or signer set named in the scenario without changing its on-chain role. The drill must use the documented alternative path. If no alternative proposer can call schedule, stop here and record NOT READY.
  2. Prove the authorization boundary. Attempt the schedule from an unrelated address and retain the revert. Then schedule the exact tuple from the surviving proposer with a delay at least equal to getMinDelay().
  3. Check the first transition. Match the emitted operation ID, target, calldata, predecessor and delay to the frozen record. Confirm that the operation moved from Unset to Waiting.
  4. Reject early execution. Attempt execution before the timestamp. It must fail because the operation is not Ready. A UI countdown is not sufficient evidence.
  5. Exercise cancellation on a control operation. Schedule the same recovery class with a different salt, cancel it with the declared canceller and retain the Cancelled event. Confirm the state returned to Unset. This catches proposer rotations that forgot the separate canceller grant.
  6. Reach Ready honestly. Advance the fork clock or wait on a testnet for the configured delay. Read the state from the contract. If the operation has a nonzero predecessor, first prove that execution fails while the predecessor is not Done.
  7. Execute through the declared path. Use a surviving executor. If EXECUTOR_ROLE is granted to address(0), record that execution is intentionally open to anyone once the operation matures. Do not describe that as a private recovery key.
  8. Verify the postcondition. Confirm the target state or role membership. Match the CallExecuted event and final Done state. A successful transaction receipt is insufficient if it reached the wrong target or encoded the wrong role.

Add the completed record to the blockchain release evidence pack that controls mainnet readiness. The evidence should be reviewable without rerunning the fork.

Inject failures that change the result

The useful negative tests are the ones that distinguish an actual recovery path from a happy-path demo.

  • No surviving proposer. The repair cannot be scheduled. Adding an executor does not solve this failure.
  • No usable executor. The operation reaches Ready but cannot be executed unless execution is intentionally open.
  • Missing canceller grant. A later-added proposer can schedule but cannot cancel. Constructor behavior should not be assumed during role rotation.
  • Tuple mismatch. Different calldata, predecessor or salt produces a different operation ID. Reject the off-chain record rather than "correcting" it after execution.
  • Unfinished predecessor. The dependent operation must remain blocked until the predecessor is Done.
  • Target revert. The operation must not reach Done. In an atomic batch, one failed subcall should prevent the batch from completing.
  • Wrong control layer. The timelock call succeeds but does not change the authority that governs the deployed proxy or contract.
  • Version drift. The runbook matches a library example rather than the deployed bytecode. Stop and rebuild the test against the deployed implementation.

Do not generalize one implementation's timing behavior. In OpenZeppelin v5.6.1, a matured operation has no built-in grace-period expiry and remains Ready until execution or cancellation. Another timelock may expire it.

Keep a Recovery Drill Record

The final artifact can fit on one page if every field is exact:

  • chain, fork block, timelock address and implementation or bytecode hash.
  • target authority path and role-member evidence.
  • loss scenario and the actor deliberately made unavailable.
  • target, value, calldata, predecessor, salt and calculated operation ID.
  • getMinDelay() value, scheduled timestamp and expected ready time.
  • schedule, rejected early execution, cancellation-control and final execution receipts.
  • relevant events and observed Unset → Waiting → Ready → Done states.
  • target postcondition and reviewer sign-off.

Pass only when the declared alternative proposer and executor paths work, the canceller behavior matches policy, every negative test fails for the expected reason, and the target postcondition is proven. A missing path is a design finding, not a documentation task to close later.
A fork proves the bytecode, configuration and role topology captured at one block. It does not prove future signer availability or operational custody. Repeat the drill after any ownership transfer, role rotation, proxy change, timelock upgrade or delay-policy change, and before the same configuration reaches mainnet.

BULB: The Future of Social Media in Web3

Learn more

Enjoy this blog? Subscribe to Dmytro Nasyrov

0 Comments