From 093ec80d7a9036b8f34beb3c5280b5aff39a7a11 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Thu, 13 Aug 2026 12:16:27 +0000 Subject: [PATCH 1/2] ops(script): rehearse the timelock before governance moves to it --- .github/workflows/manual-broadcast.yaml | 1 + .github/workflows/run-script.yaml | 7 + docs/TIMELOCK.md | 29 +++ ...20260813-execute-timelock-operations.s.sol | 117 +++++++++ script/20260813-timelock-rehearsal.s.sol | 225 ++++++++++++++++++ test/script/20260813-timelock-rehearsal.t.sol | 174 ++++++++++++++ 6 files changed, 553 insertions(+) create mode 100644 script/20260813-execute-timelock-operations.s.sol create mode 100644 script/20260813-timelock-rehearsal.s.sol create mode 100644 test/script/20260813-timelock-rehearsal.t.sol diff --git a/.github/workflows/manual-broadcast.yaml b/.github/workflows/manual-broadcast.yaml index 279bdf3d..b88e508b 100644 --- a/.github/workflows/manual-broadcast.yaml +++ b/.github/workflows/manual-broadcast.yaml @@ -34,6 +34,7 @@ on: - 20260706-deploy-tokens-ethereum - 20260807-deploy-missing-tokens - 20260729-deploy-governance-timelock + - 20260813-execute-timelock-operations network: description: 'Network to broadcast against (default: base)' required: true diff --git a/.github/workflows/run-script.yaml b/.github/workflows/run-script.yaml index d2390995..8a3adeb9 100644 --- a/.github/workflows/run-script.yaml +++ b/.github/workflows/run-script.yaml @@ -31,6 +31,7 @@ on: - 20260722-swap-remaining-vault-authorisers - 20260723-provision-additional-service-signer - 20260729-migrate-governance-to-timelock + - 20260813-timelock-rehearsal - 20260810-revoke-fireblocks-service-signer network: description: 'Network to author against (default: base)' @@ -61,6 +62,12 @@ on: # JSON path argument this dispatcher can't supply and runs off-chain # on the signer's machine, not in CI. - 'run()' + # `20260813-timelock-rehearsal` stages. `run()` schedules the + # no-op, `cancel()` cancels it, `reschedule()` schedules it + # again. Execution is not a Safe action — the timelock's + # executor role is open — so it lives in manual-broadcast. + - 'cancel()' + - 'reschedule()' # Manually dispatches an operational script from `script/` and uploads any # JSON it writes to `out/` as a build artifact. # diff --git a/docs/TIMELOCK.md b/docs/TIMELOCK.md index a1f38a89..409e0d7a 100644 --- a/docs/TIMELOCK.md +++ b/docs/TIMELOCK.md @@ -138,6 +138,35 @@ The forcing function: `GovernanceTimelockMigration.t.sol` accepts Safe-or-timelock per surface until **2026-10-01T00:00:00Z**, then demands the timelock. An unfinished rollout red-lines cron past that date. +## Rehearsing the timelock + +The timelock can be exercised end-to-end BEFORE any governance is handed to it, +so signers see the real loop before it controls anything. The rehearsed +operation is `timelock.updateDelay(TIMELOCK_MIN_DELAY)` — re-setting the delay +to the value it already holds. It is a genuine no-op, it targets the timelock +rather than any production contract, and OZ rejects `updateDelay` from any +caller other than the timelock itself, so it can only happen via the full +schedule → delay → execute path. Rehearsing it therefore exercises the real +mechanism rather than a shortcut. + +Stages 1–3 are Safe actions, dispatched via `Actions → run-script` with +`script = 20260813-timelock-rehearsal` and `sig` selecting the stage: + +1. `run()` — schedule the no-op. +2. `cancel()` — cancel it. Proves the veto works and that the same operation id + becomes schedulable again afterwards. +3. `reschedule()` — schedule it again. + +Then wait out the delay and execute. Execution is **not** a Safe action: the +executor role is open, so anyone may execute. `Actions → manual-broadcast` → +`20260813-execute-timelock-operations` does it from the CI deploy key, which +holds no role on the timelock — if that succeeds, permissionless execution is +demonstrated rather than merely configured. + +Each stage refuses to author a bundle whose call would revert: cancelling +nothing, or scheduling something already scheduled, fails at authoring time +rather than in the Safe. + ## Operating under the timelock (future governance actions) Every admin action becomes two Safe transactions separated by ≥48h: diff --git a/script/20260813-execute-timelock-operations.s.sol b/script/20260813-execute-timelock-operations.s.sol new file mode 100644 index 00000000..50866db0 --- /dev/null +++ b/script/20260813-execute-timelock-operations.s.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Script} from "forge-std-1.16.1/src/Script.sol"; +import {console2} from "forge-std-1.16.1/src/console2.sol"; +import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; +import {TimelockController} from "@openzeppelin-contracts-5.6.1/governance/TimelockController.sol"; + +import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; +import {LibTimelockInvariants} from "../src/lib/LibTimelockInvariants.sol"; + +/// @notice The active chain's governance-timelock pin is unhydrated. +/// @param chainId The active chain id. +error ExecuteTimelockNotPinned(uint256 chainId); + +/// @notice The operation is not in a state this script can execute: it is +/// unknown, already done, or still waiting out its delay. +/// @param id The operation id. +/// @param pending Whether the timelock reports it pending. +/// @param ready Whether the timelock reports it ready. +/// @param done Whether the timelock reports it done. +error OperationNotExecutable(bytes32 id, bool pending, bool ready, bool done); + +/// @notice Execution is not open on this timelock, so the CI deploy key — +/// which holds no roles — cannot execute. Surfaced by name because the whole +/// point of this script is that it needs no privilege. +/// @param timelock The timelock inspected. +error ExecutionNotPermissionless(address timelock); + +/// @title ExecuteTimelockOperations +/// @notice **PENDING.** Executes a matured timelock operation from the CI +/// deploy key. +/// +/// This is deliberately NOT a Safe-routed script. The timelock grants +/// `EXECUTOR_ROLE` to `address(0)`, so once an operation's delay has run +/// ANYONE may execute it. Driving execution from the CI deploy key — a key +/// that holds no role on the timelock, the authoriser or any vault — is the +/// most direct demonstration that the property is real: if this succeeds, +/// execution is genuinely permissionless and the operator cannot censor a +/// matured operation. +/// +/// Dispatch via `Actions → manual-broadcast` with +/// `script = 20260813-execute-timelock-operations` and the target `network`. +/// +/// ## Which operation +/// +/// OZ's `TimelockController` stores only a timestamp per operation id; it +/// keeps no enumerable list, so "every outstanding proposal" cannot be read +/// from contract state alone — recovering it would mean indexing +/// `CallScheduled` logs. Rather than pretend otherwise, this script executes +/// operations it can RECONSTRUCT, and asserts their state before acting. +/// Today that is the rehearsal no-op from +/// `20260813-timelock-rehearsal`, whose parameters are fixed. A future +/// operation is added by appending its reconstruction here, which also keeps +/// the executor honest: it can only ever run something whose full calldata is +/// committed in this repo and therefore reviewable. +/// +/// @dev Pre-flight asserts the timelock's pinned configuration AND that +/// execution is open, so a timelock whose executor role had been closed +/// fails by name rather than as an opaque `AccessControl` revert. +contract ExecuteTimelockOperations is Script { + /// @notice Salt of the rehearsal operation. Must match + /// `TimelockRehearsal.REHEARSAL_SALT`; the rehearsal test asserts the two + /// derive the same id so they cannot drift apart silently. + bytes32 internal constant REHEARSAL_SALT = keccak256("st0x.timelock.rehearsal.20260813"); + + /// @notice The rehearsal's no-op payload: re-set the minimum delay to the + /// value it already holds. + /// @return The `updateDelay` calldata. + function rehearsalPayload() internal pure returns (bytes memory) { + return abi.encodeCall(TimelockController.updateDelay, (LibTimelockInvariants.TIMELOCK_MIN_DELAY)); + } + + /// @notice Execute the rehearsal operation on the active chain if it has + /// matured. Broadcasts from the CI deploy key, which holds no roles. + function run() external { + address safe = LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid); + address timelock = LibTimelockInvariants.timelockForChainId(block.chainid); + if (timelock == address(0)) revert ExecuteTimelockNotPinned(block.chainid); + LibTimelockInvariants.assertTimelockState(timelock, safe); + + // The property this script depends on, asserted rather than assumed. + if (!IAccessControl(timelock).hasRole(LibTimelockInvariants.TIMELOCK_EXECUTOR_ROLE, address(0))) { + revert ExecutionNotPermissionless(timelock); + } + + TimelockController controller = TimelockController(payable(timelock)); + bytes memory payload = rehearsalPayload(); + bytes32 id = controller.hashOperation(timelock, 0, payload, bytes32(0), REHEARSAL_SALT); + + bool pending = controller.isOperationPending(id); + bool ready = controller.isOperationReady(id); + bool done = controller.isOperationDone(id); + if (!ready) revert OperationNotExecutable(id, pending, ready, done); + + console2.log("Executing matured operation:", vm.toString(id)); + console2.log("Timelock:", vm.toString(timelock)); + console2.log("Chain:", block.chainid); + + vm.startBroadcast(); + address executor = msg.sender; + controller.execute(timelock, 0, payload, bytes32(0), REHEARSAL_SALT); + vm.stopBroadcast(); + + require(controller.isOperationDone(id), "ExecuteTimelockOperations: operation did not complete"); + + // The executing key holds no role — that is the point. + require( + !IAccessControl(timelock).hasRole(LibTimelockInvariants.TIMELOCK_EXECUTOR_ROLE, executor), + "ExecuteTimelockOperations: executor unexpectedly holds EXECUTOR_ROLE" + ); + + console2.log("Executed by:", vm.toString(executor)); + console2.log("That address holds no EXECUTOR_ROLE - execution is permissionless"); + } +} diff --git a/script/20260813-timelock-rehearsal.s.sol b/script/20260813-timelock-rehearsal.s.sol new file mode 100644 index 00000000..11aefd88 --- /dev/null +++ b/script/20260813-timelock-rehearsal.s.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Script} from "forge-std-1.16.1/src/Script.sol"; +import {console2} from "forge-std-1.16.1/src/console2.sol"; +import {TimelockController} from "@openzeppelin-contracts-5.6.1/governance/TimelockController.sol"; + +import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol"; +import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; +import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; +import {LibTimelockInvariants} from "../src/lib/LibTimelockInvariants.sol"; + +/// @notice The active chain's governance-timelock pin is unhydrated, so +/// there is nothing to rehearse against. +/// @param chainId The active chain id. +error RehearsalTimelockNotPinned(uint256 chainId); + +/// @notice The rehearsal operation is already scheduled, so scheduling it +/// again would revert inside the Safe transaction. Cancel it first, or +/// execute it. +/// @param id The operation id already pending. +error RehearsalAlreadyScheduled(bytes32 id); + +/// @notice The rehearsal operation is not scheduled, so there is nothing to +/// cancel. +/// @param id The operation id that is not pending. +error RehearsalNotScheduled(bytes32 id); + +/// @notice The scheduled rehearsal did not leave the timelock's minimum +/// delay unchanged. The rehearsal is a no-op by construction — it re-sets +/// the delay to the value it already holds — so a change means the operation +/// being rehearsed is not the one this script believes it is. +/// @param expected The delay before the rehearsal executed. +/// @param actual The delay after. +error RehearsalChangedMinDelay(uint256 expected, uint256 actual); + +/// @title TimelockRehearsal +/// @notice **PENDING.** Authors the Safe bundles that rehearse the +/// governance timelock end-to-end BEFORE any governance is handed to it: +/// schedule a no-op, cancel it, then schedule it again. Each entrypoint +/// emits its own Safe Tx Builder JSON, so the rehearsal is driven entirely +/// from the Safe UI exactly as real governance will be. +/// +/// The operation rehearsed is `timelock.updateDelay(TIMELOCK_MIN_DELAY)` — +/// setting the delay to the value it ALREADY holds. That is deliberate on +/// three counts: +/// +/// 1. It is a genuine no-op. Even executed, nothing changes. +/// 2. OZ's `updateDelay` reverts unless the caller is the timelock itself, +/// so it CANNOT be performed any way other than through the full +/// schedule → delay → execute loop. Rehearsing it therefore exercises the +/// real path rather than a shortcut. +/// 3. It targets the timelock, not any production contract, so a rehearsal +/// left half-finished cannot touch vaults, beacons or the authoriser. +/// +/// Dispatch via `Actions → run-script` with +/// `script = 20260813-timelock-rehearsal`, the target `network`, and `sig` +/// selecting the stage: +/// +/// - `run()` — schedule the no-op (stage 1) +/// - `cancel()` — cancel it (stage 2) +/// - `reschedule()` — schedule it again (stage 3) +/// +/// Execution is NOT a Safe action: the timelock grants `EXECUTOR_ROLE` to +/// `address(0)`, so anyone may execute once the delay has run. +/// `20260813-execute-timelock-operations` does that from the CI deploy key, +/// which proves permissionlessness with a key that holds no roles at all. +/// +/// @dev Every stage pre-flights the Safe's pinned policy and the timelock's +/// pinned configuration, simulates the call as the Safe, asserts the +/// resulting operation state, and emits the artifact. `cancel()` and +/// `reschedule()` additionally assert the state they depend on, so a stage +/// dispatched out of order refuses to author rather than emitting a bundle +/// that would revert in the Safe. +contract TimelockRehearsal is Script { + /// @notice Salt distinguishing the rehearsal operation from any real + /// governance action. Fixed so every stage — and the executor script — + /// derives the same operation id without passing state between runs. + bytes32 internal constant REHEARSAL_SALT = keccak256("st0x.timelock.rehearsal.20260813"); + + /// @notice The active chain's governance timelock, asserted pinned and + /// in its expected configuration. + /// @return timelock The chain's timelock. + /// @return safe The chain's token-owner Safe. + function preflight() internal view returns (address timelock, IGnosisSafe safe) { + safe = IGnosisSafe(LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid)); + timelock = LibTimelockInvariants.timelockForChainId(block.chainid); + if (timelock == address(0)) revert RehearsalTimelockNotPinned(block.chainid); + LibTimelockInvariants.assertTimelockState(timelock, address(safe)); + } + + /// @notice The no-op call the rehearsal schedules: re-set the timelock's + /// minimum delay to the value it already holds. + /// @return The `updateDelay` calldata. + function rehearsalPayload() internal pure returns (bytes memory) { + return abi.encodeCall(TimelockController.updateDelay, (LibTimelockInvariants.TIMELOCK_MIN_DELAY)); + } + + /// @notice The rehearsal operation's id on the active chain. Shared by + /// every stage and by the executor script, so all four agree on which + /// operation they are talking about. + /// @param timelock The chain's timelock. + /// @return The operation id. + function rehearsalId(address timelock) internal view returns (bytes32) { + return TimelockController(payable(timelock)) + .hashOperation(timelock, 0, rehearsalPayload(), bytes32(0), REHEARSAL_SALT); + } + + /// @notice Stage 1 — schedule the no-op. + function run() external { + _authorSchedule("out/20260813-timelock-rehearsal-schedule-", "ST0x timelock rehearsal: schedule a no-op"); + } + + /// @notice Stage 3 — schedule the no-op again after the cancellation. + /// Identical operation to `run()`: `cancel` clears the timestamp, so the + /// same id becomes schedulable again, which is itself the property worth + /// rehearsing. + function reschedule() external { + _authorSchedule("out/20260813-timelock-rehearsal-reschedule-", "ST0x timelock rehearsal: re-schedule the no-op"); + } + + /// @notice Stage 2 — cancel the scheduled no-op. + function cancel() external { + (address timelock, IGnosisSafe safe) = preflight(); + bytes32 id = rehearsalId(timelock); + + TimelockController controller = TimelockController(payable(timelock)); + if (!controller.isOperationPending(id)) revert RehearsalNotScheduled(id); + + SafeTx[] memory txs = new SafeTx[](1); + txs[0] = SafeTx({to: timelock, value: 0, data: abi.encodeCall(TimelockController.cancel, (id)), operation: 0}); + + uint256 nonce = safe.nonce(); + bytes32 safeTxHash = LibSafeOps.computeSafeTxHashViaSafe(safe, txs[0], nonce); + + LibSafeOps.simulateExternalCall(safe, txs[0].to, txs[0].data); + + // Post-state: the operation is gone, so it is neither pending nor + // executable, and the same id is schedulable again. + require(!controller.isOperationPending(id), "TimelockRehearsal: cancel did not clear the operation"); + require(!controller.isOperation(id), "TimelockRehearsal: operation still registered after cancel"); + + _emit( + "out/20260813-timelock-rehearsal-cancel-", + "ST0x timelock rehearsal: cancel the no-op", + safe, + txs, + safeTxHash, + nonce, + id + ); + } + + /// @notice Author a schedule bundle under the supplied artifact prefix. + /// @param pathPrefix Artifact path prefix; the chain id and `.json` are + /// appended. + /// @param bundleName Human-readable name shown in the Safe Tx Builder. + function _authorSchedule(string memory pathPrefix, string memory bundleName) internal { + (address timelock, IGnosisSafe safe) = preflight(); + bytes32 id = rehearsalId(timelock); + + TimelockController controller = TimelockController(payable(timelock)); + if (controller.isOperation(id)) revert RehearsalAlreadyScheduled(id); + + SafeTx[] memory txs = new SafeTx[](1); + txs[0] = SafeTx({ + to: timelock, + value: 0, + data: abi.encodeCall( + TimelockController.schedule, + (timelock, 0, rehearsalPayload(), bytes32(0), REHEARSAL_SALT, LibTimelockInvariants.TIMELOCK_MIN_DELAY) + ), + operation: 0 + }); + + uint256 nonce = safe.nonce(); + bytes32 safeTxHash = LibSafeOps.computeSafeTxHashViaSafe(safe, txs[0], nonce); + + uint256 delayBefore = controller.getMinDelay(); + LibSafeOps.simulateExternalCall(safe, txs[0].to, txs[0].data); + + // Post-state: pending, and NOT executable until the delay has run. + require(controller.isOperationPending(id), "TimelockRehearsal: schedule did not register the operation"); + require(!controller.isOperationReady(id), "TimelockRehearsal: operation ready before the delay elapsed"); + + // Prove the whole loop on the fork: warp past the delay and execute + // as an arbitrary address with no roles, which is what permissionless + // execution means, then confirm the no-op changed nothing. + vm.warp(block.timestamp + LibTimelockInvariants.TIMELOCK_MIN_DELAY); + require(controller.isOperationReady(id), "TimelockRehearsal: operation not ready after the delay"); + vm.prank(address(uint160(uint256(keccak256("st0x.rehearsal.random-executor"))))); + controller.execute(timelock, 0, rehearsalPayload(), bytes32(0), REHEARSAL_SALT); + require(controller.isOperationDone(id), "TimelockRehearsal: execution did not complete the operation"); + uint256 delayAfter = controller.getMinDelay(); + if (delayAfter != delayBefore) revert RehearsalChangedMinDelay(delayBefore, delayAfter); + + _emit(pathPrefix, bundleName, safe, txs, safeTxHash, nonce, id); + console2.log("Loop proven on fork: schedule -> 48h -> execute BY A ROLELESS ADDRESS -> delay unchanged"); + } + + /// @notice Write the Tx Builder JSON and log what a signer cross-checks. + function _emit( + string memory pathPrefix, + string memory bundleName, + IGnosisSafe safe, + SafeTx[] memory txs, + bytes32 safeTxHash, + uint256 nonce, + bytes32 id + ) internal { + string memory artifactPath = string.concat(pathPrefix, vm.toString(block.chainid), ".json"); + string memory json = LibSafeOps.emitTxBuilderJson(address(safe), block.chainid, bundleName, txs); + vm.writeFile(artifactPath, json); + + console2.log("==== TX BUILDER JSON BEGIN ===="); + console2.log(json); + console2.log("==== TX BUILDER JSON END ===="); + console2.log("Artifact:", artifactPath); + console2.log("SafeTxHash:", vm.toString(safeTxHash)); + console2.log("Nonce:", nonce); + console2.log("Operation id:", vm.toString(id)); + console2.log("Chain:", block.chainid); + } +} diff --git a/test/script/20260813-timelock-rehearsal.t.sol b/test/script/20260813-timelock-rehearsal.t.sol new file mode 100644 index 00000000..7c4e4657 --- /dev/null +++ b/test/script/20260813-timelock-rehearsal.t.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; +import {TimelockController} from "@openzeppelin-contracts-5.6.1/governance/TimelockController.sol"; + +import { + TimelockRehearsal, + RehearsalAlreadyScheduled, + RehearsalNotScheduled +} from "../../script/20260813-timelock-rehearsal.s.sol"; +import { + ExecuteTimelockOperations, + OperationNotExecutable +} from "../../script/20260813-execute-timelock-operations.s.sol"; +import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; +import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol"; +import {LibTimelockInvariants} from "../../src/lib/LibTimelockInvariants.sol"; + +/// @title TimelockRehearsalTest +/// @notice Live Base fork coverage for the pre-handover rehearsal: schedule +/// a no-op, cancel it, schedule it again, and execute it from an address +/// holding no roles. +/// @dev Unpinned head fork so the assertions run against the live timelock +/// and Safe, the same state a real dispatch authors from. +contract TimelockRehearsalTest is Test { + TimelockRehearsal internal rehearsal; + + function setUp() external { + vm.createSelectFork(LibRainDeploy.BASE); + rehearsal = new TimelockRehearsal(); + } + + /// @notice The active chain's timelock, as the scripts resolve it. + function timelock() internal view returns (TimelockController) { + return TimelockController(payable(LibTimelockInvariants.timelockForChainId(block.chainid))); + } + + /// @notice Read the operation id out of an emitted artifact by + /// re-deriving it the way the script does, so the test does not restate + /// the operation shape independently. + function rehearsalOperationId() internal view returns (bytes32) { + address tl = address(timelock()); + return timelock() + .hashOperation( + tl, + 0, + abi.encodeCall(TimelockController.updateDelay, (LibTimelockInvariants.TIMELOCK_MIN_DELAY)), + bytes32(0), + keccak256("st0x.timelock.rehearsal.20260813") + ); + } + + /// @notice Scheduling authors a one-transaction bundle targeting the + /// timelock, and the run's own fork proof shows the operation matures + /// and executes without changing the delay. + function testScheduleAuthorsABundle() external { + rehearsal.run(); + + (uint256 chainId, address firstTarget, SafeTx[] memory txs) = LibSafeOps.parseTxBuilderJson( + string.concat("out/20260813-timelock-rehearsal-schedule-", vm.toString(block.chainid), ".json") + ); + assertEq(chainId, LibSafeInvariants.BASE_CHAIN_ID); + assertEq(firstTarget, address(timelock()), "the rehearsal targets the timelock itself"); + assertEq(txs.length, 1, "scheduling is a single call"); + assertEq( + txs[0].data, + abi.encodeCall( + TimelockController.schedule, + ( + address(timelock()), + 0, + abi.encodeCall(TimelockController.updateDelay, (LibTimelockInvariants.TIMELOCK_MIN_DELAY)), + bytes32(0), + keccak256("st0x.timelock.rehearsal.20260813"), + LibTimelockInvariants.TIMELOCK_MIN_DELAY + ) + ) + ); + } + + /// @notice The rehearsal is a no-op by construction: the operation it + /// schedules sets the delay to the value already held, so executing it + /// leaves `getMinDelay()` unchanged. + function testRehearsalIsANoOp() external { + uint256 before = timelock().getMinDelay(); + rehearsal.run(); + assertEq(timelock().getMinDelay(), before, "the rehearsal must not change the delay"); + assertEq(before, LibTimelockInvariants.TIMELOCK_MIN_DELAY); + } + + /// @notice Cancelling refuses while nothing is scheduled, rather than + /// emitting a bundle that would revert inside the Safe. + function testCancelRefusesWhenNothingScheduled() external { + vm.expectRevert(abi.encodeWithSelector(RehearsalNotScheduled.selector, rehearsalOperationId())); + rehearsal.cancel(); + } + + /// @notice Scheduling refuses when the operation is already registered, + /// for the same reason. + function testScheduleRefusesWhenAlreadyScheduled() external { + // Put the operation on-chain via the Safe, then ask the script to + // schedule it again. + _scheduleAsSafe(); + vm.expectRevert(abi.encodeWithSelector(RehearsalAlreadyScheduled.selector, rehearsalOperationId())); + rehearsal.run(); + } + + /// @notice The full stage sequence: schedule, cancel, re-schedule. After + /// the cancel the SAME id is schedulable again — the property the + /// rehearsal exists to demonstrate. + function testScheduleCancelReschedule() external { + bytes32 id = rehearsalOperationId(); + + _scheduleAsSafe(); + assertTrue(timelock().isOperationPending(id), "scheduled"); + + // `cancel()` simulates the Safe call against the fork, so the + // operation is genuinely cleared here — no second cancel needed. + rehearsal.cancel(); + assertFalse(timelock().isOperation(id), "cancel clears the operation"); + + rehearsal.reschedule(); + (,, SafeTx[] memory txs) = LibSafeOps.parseTxBuilderJson( + string.concat("out/20260813-timelock-rehearsal-reschedule-", vm.toString(block.chainid), ".json") + ); + assertEq(txs.length, 1); + assertEq(txs[0].to, address(timelock())); + } + + /// @notice The executor script and the rehearsal script derive the SAME + /// operation id. They hold the salt and payload separately, so a drift + /// between them would leave the executor unable to execute what the + /// rehearsal scheduled — with no other test catching it. + function testExecutorTargetsTheRehearsalOperation() external { + _scheduleAsSafe(); + vm.warp(block.timestamp + LibTimelockInvariants.TIMELOCK_MIN_DELAY); + + // A roleless address executes: this is what the broadcast script does + // from the CI deploy key. + ExecuteTimelockOperations executor = new ExecuteTimelockOperations(); + executor.run(); + + assertTrue(timelock().isOperationDone(rehearsalOperationId()), "executor completed the rehearsal operation"); + } + + /// @notice The executor refuses while the operation is still inside its + /// delay window, rather than reverting opaquely inside the timelock. + function testExecutorRefusesBeforeTheDelay() external { + _scheduleAsSafe(); + ExecuteTimelockOperations executor = new ExecuteTimelockOperations(); + vm.expectRevert( + abi.encodeWithSelector(OperationNotExecutable.selector, rehearsalOperationId(), true, false, false) + ); + executor.run(); + } + + /// @notice Schedule the rehearsal operation on-chain as the Safe. + function _scheduleAsSafe() internal { + address tl = address(timelock()); + vm.prank(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + timelock() + .schedule( + tl, + 0, + abi.encodeCall(TimelockController.updateDelay, (LibTimelockInvariants.TIMELOCK_MIN_DELAY)), + bytes32(0), + keccak256("st0x.timelock.rehearsal.20260813"), + LibTimelockInvariants.TIMELOCK_MIN_DELAY + ); + } +} From 6e89b307ee04a181b37c3ea389d270a8da161e93 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Fri, 14 Aug 2026 10:46:22 +0000 Subject: [PATCH 2/2] refactor(script): split the rehearsal into per-stage scripts --- .github/workflows/run-script.yaml | 9 +- docs/TIMELOCK.md | 16 +- ...20260813-execute-timelock-operations.s.sol | 24 +- .../20260813-timelock-rehearsal-cancel.s.sol | 84 +++++++ ...20260813-timelock-rehearsal-schedule.s.sol | 107 +++++++++ script/20260813-timelock-rehearsal.s.sol | 225 ------------------ src/lib/LibTimelockRehearsal.sol | 68 ++++++ test/script/20260813-timelock-rehearsal.t.sol | 36 +-- 8 files changed, 297 insertions(+), 272 deletions(-) create mode 100644 script/20260813-timelock-rehearsal-cancel.s.sol create mode 100644 script/20260813-timelock-rehearsal-schedule.s.sol delete mode 100644 script/20260813-timelock-rehearsal.s.sol create mode 100644 src/lib/LibTimelockRehearsal.sol diff --git a/.github/workflows/run-script.yaml b/.github/workflows/run-script.yaml index 8a3adeb9..2221d994 100644 --- a/.github/workflows/run-script.yaml +++ b/.github/workflows/run-script.yaml @@ -31,7 +31,8 @@ on: - 20260722-swap-remaining-vault-authorisers - 20260723-provision-additional-service-signer - 20260729-migrate-governance-to-timelock - - 20260813-timelock-rehearsal + - 20260813-timelock-rehearsal-schedule + - 20260813-timelock-rehearsal-cancel - 20260810-revoke-fireblocks-service-signer network: description: 'Network to author against (default: base)' @@ -62,12 +63,6 @@ on: # JSON path argument this dispatcher can't supply and runs off-chain # on the signer's machine, not in CI. - 'run()' - # `20260813-timelock-rehearsal` stages. `run()` schedules the - # no-op, `cancel()` cancels it, `reschedule()` schedules it - # again. Execution is not a Safe action — the timelock's - # executor role is open — so it lives in manual-broadcast. - - 'cancel()' - - 'reschedule()' # Manually dispatches an operational script from `script/` and uploads any # JSON it writes to `out/` as a build artifact. # diff --git a/docs/TIMELOCK.md b/docs/TIMELOCK.md index 409e0d7a..c0887d6c 100644 --- a/docs/TIMELOCK.md +++ b/docs/TIMELOCK.md @@ -149,13 +149,15 @@ caller other than the timelock itself, so it can only happen via the full schedule → delay → execute path. Rehearsing it therefore exercises the real mechanism rather than a shortcut. -Stages 1–3 are Safe actions, dispatched via `Actions → run-script` with -`script = 20260813-timelock-rehearsal` and `sig` selecting the stage: - -1. `run()` — schedule the no-op. -2. `cancel()` — cancel it. Proves the veto works and that the same operation id - becomes schedulable again afterwards. -3. `reschedule()` — schedule it again. +Stages 1–3 are Safe actions, dispatched via `Actions → run-script`: + +1. `20260813-timelock-rehearsal-schedule` — schedule the no-op. +2. `20260813-timelock-rehearsal-cancel` — cancel it. Proves the veto works and + that the same operation id becomes schedulable again afterwards. +3. `20260813-timelock-rehearsal-schedule` again — re-dispatching the same script + IS the re-propose stage. `cancel` deregisters the operation, so the identical + one becomes schedulable again; there is no separate operation, so there is no + separate script. Then wait out the delay and execute. Execution is **not** a Safe action: the executor role is open, so anyone may execute. `Actions → manual-broadcast` → diff --git a/script/20260813-execute-timelock-operations.s.sol b/script/20260813-execute-timelock-operations.s.sol index 50866db0..5a6116a7 100644 --- a/script/20260813-execute-timelock-operations.s.sol +++ b/script/20260813-execute-timelock-operations.s.sol @@ -9,6 +9,7 @@ import {TimelockController} from "@openzeppelin-contracts-5.6.1/governance/Timel import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; import {LibTimelockInvariants} from "../src/lib/LibTimelockInvariants.sol"; +import {LibTimelockRehearsal} from "../src/lib/LibTimelockRehearsal.sol"; /// @notice The active chain's governance-timelock pin is unhydrated. /// @param chainId The active chain id. @@ -50,8 +51,9 @@ error ExecutionNotPermissionless(address timelock); /// from contract state alone — recovering it would mean indexing /// `CallScheduled` logs. Rather than pretend otherwise, this script executes /// operations it can RECONSTRUCT, and asserts their state before acting. -/// Today that is the rehearsal no-op from -/// `20260813-timelock-rehearsal`, whose parameters are fixed. A future +/// Today that is the rehearsal no-op defined in `LibTimelockRehearsal`, +/// whose parameters are fixed and shared with the scripts that schedule and +/// cancel it, so the three cannot drift onto different ids. A future /// operation is added by appending its reconstruction here, which also keeps /// the executor honest: it can only ever run something whose full calldata is /// committed in this repo and therefore reviewable. @@ -60,18 +62,6 @@ error ExecutionNotPermissionless(address timelock); /// execution is open, so a timelock whose executor role had been closed /// fails by name rather than as an opaque `AccessControl` revert. contract ExecuteTimelockOperations is Script { - /// @notice Salt of the rehearsal operation. Must match - /// `TimelockRehearsal.REHEARSAL_SALT`; the rehearsal test asserts the two - /// derive the same id so they cannot drift apart silently. - bytes32 internal constant REHEARSAL_SALT = keccak256("st0x.timelock.rehearsal.20260813"); - - /// @notice The rehearsal's no-op payload: re-set the minimum delay to the - /// value it already holds. - /// @return The `updateDelay` calldata. - function rehearsalPayload() internal pure returns (bytes memory) { - return abi.encodeCall(TimelockController.updateDelay, (LibTimelockInvariants.TIMELOCK_MIN_DELAY)); - } - /// @notice Execute the rehearsal operation on the active chain if it has /// matured. Broadcasts from the CI deploy key, which holds no roles. function run() external { @@ -86,8 +76,8 @@ contract ExecuteTimelockOperations is Script { } TimelockController controller = TimelockController(payable(timelock)); - bytes memory payload = rehearsalPayload(); - bytes32 id = controller.hashOperation(timelock, 0, payload, bytes32(0), REHEARSAL_SALT); + bytes memory payload = LibTimelockRehearsal.payload(); + bytes32 id = LibTimelockRehearsal.operationId(timelock); bool pending = controller.isOperationPending(id); bool ready = controller.isOperationReady(id); @@ -100,7 +90,7 @@ contract ExecuteTimelockOperations is Script { vm.startBroadcast(); address executor = msg.sender; - controller.execute(timelock, 0, payload, bytes32(0), REHEARSAL_SALT); + controller.execute(timelock, 0, payload, bytes32(0), LibTimelockRehearsal.REHEARSAL_SALT); vm.stopBroadcast(); require(controller.isOperationDone(id), "ExecuteTimelockOperations: operation did not complete"); diff --git a/script/20260813-timelock-rehearsal-cancel.s.sol b/script/20260813-timelock-rehearsal-cancel.s.sol new file mode 100644 index 00000000..0efb7cea --- /dev/null +++ b/script/20260813-timelock-rehearsal-cancel.s.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Script} from "forge-std-1.16.1/src/Script.sol"; +import {console2} from "forge-std-1.16.1/src/console2.sol"; +import {TimelockController} from "@openzeppelin-contracts-5.6.1/governance/TimelockController.sol"; + +import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol"; +import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; +import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; +import {LibTimelockInvariants} from "../src/lib/LibTimelockInvariants.sol"; +import {LibTimelockRehearsal} from "../src/lib/LibTimelockRehearsal.sol"; + +/// @notice The active chain's governance-timelock pin is unhydrated, so +/// there is nothing to rehearse against. +/// @param chainId The active chain id. +error CancelTimelockNotPinned(uint256 chainId); + +/// @notice The rehearsal operation is not pending, so there is nothing to +/// cancel and the bundle would revert inside the Safe. +/// @param id The operation id that is not pending. +error RehearsalNotScheduled(bytes32 id); + +/// @title TimelockRehearsalCancel +/// @notice **PENDING.** Authors the Safe bundle that CANCELS the scheduled +/// timelock rehearsal — see `LibTimelockRehearsal` for the operation. +/// +/// This is the stage that demonstrates the veto: a scheduled operation can be +/// stopped inside its window, and OZ's `cancel` has no open-role path, so +/// vetoing stays privileged even though execution is permissionless. After it +/// lands, the identical operation becomes schedulable again — re-dispatch +/// `20260813-timelock-rehearsal-schedule` for the re-propose stage. +/// +/// Dispatch via `Actions → run-script` with +/// `script = 20260813-timelock-rehearsal-cancel` and the target `network`. +/// +/// @dev Refuses unless the operation is actually pending, so cancelling +/// nothing fails at authoring time rather than in the Safe. Post-state +/// asserts the operation is fully deregistered, which is what makes the +/// re-propose stage possible. +contract TimelockRehearsalCancel is Script { + /// @notice Author the cancel bundle for the active chain. + function run() external { + IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid)); + address timelock = LibTimelockInvariants.timelockForChainId(block.chainid); + if (timelock == address(0)) revert CancelTimelockNotPinned(block.chainid); + LibTimelockInvariants.assertTimelockState(timelock, address(safe)); + + TimelockController controller = TimelockController(payable(timelock)); + bytes32 id = LibTimelockRehearsal.operationId(timelock); + if (!controller.isOperationPending(id)) revert RehearsalNotScheduled(id); + + SafeTx[] memory txs = new SafeTx[](1); + txs[0] = SafeTx({to: timelock, value: 0, data: LibTimelockRehearsal.cancelCalldata(timelock), operation: 0}); + + uint256 nonce = safe.nonce(); + bytes32 safeTxHash = LibSafeOps.computeSafeTxHashViaSafe(safe, txs[0], nonce); + + LibSafeOps.simulateExternalCall(safe, txs[0].to, txs[0].data); + + // Fully deregistered: not pending, and not known to the timelock at + // all — which is what lets the same operation be scheduled again. + require(!controller.isOperationPending(id), "TimelockRehearsalCancel: still pending after cancel"); + require(!controller.isOperation(id), "TimelockRehearsalCancel: still registered after cancel"); + + string memory artifactPath = + string.concat("out/20260813-timelock-rehearsal-cancel-", vm.toString(block.chainid), ".json"); + string memory json = LibSafeOps.emitTxBuilderJson( + address(safe), block.chainid, "ST0x timelock rehearsal: cancel the no-op", txs + ); + vm.writeFile(artifactPath, json); + + console2.log("==== TX BUILDER JSON BEGIN ===="); + console2.log(json); + console2.log("==== TX BUILDER JSON END ===="); + console2.log("Artifact:", artifactPath); + console2.log("SafeTxHash:", vm.toString(safeTxHash)); + console2.log("Nonce:", nonce); + console2.log("Operation id:", vm.toString(id)); + console2.log("Chain:", block.chainid); + console2.log("Cancelled: the same operation is schedulable again - re-dispatch the schedule script"); + } +} diff --git a/script/20260813-timelock-rehearsal-schedule.s.sol b/script/20260813-timelock-rehearsal-schedule.s.sol new file mode 100644 index 00000000..129dc469 --- /dev/null +++ b/script/20260813-timelock-rehearsal-schedule.s.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Script} from "forge-std-1.16.1/src/Script.sol"; +import {console2} from "forge-std-1.16.1/src/console2.sol"; +import {TimelockController} from "@openzeppelin-contracts-5.6.1/governance/TimelockController.sol"; + +import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol"; +import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; +import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; +import {LibTimelockInvariants} from "../src/lib/LibTimelockInvariants.sol"; +import {LibTimelockRehearsal} from "../src/lib/LibTimelockRehearsal.sol"; + +/// @notice The active chain's governance-timelock pin is unhydrated, so +/// there is nothing to rehearse against. +/// @param chainId The active chain id. +error RehearsalTimelockNotPinned(uint256 chainId); + +/// @notice The rehearsal operation is already registered on the timelock, so +/// scheduling it again would revert inside the Safe transaction. Cancel it +/// (or execute it) first. +/// @param id The operation id already registered. +error RehearsalAlreadyScheduled(bytes32 id); + +/// @notice Executing the scheduled rehearsal changed the timelock's minimum +/// delay. The rehearsal is a no-op by construction, so a change means the +/// operation is not the one this script believes it is. +/// @param expected The delay before execution. +/// @param actual The delay after. +error RehearsalChangedMinDelay(uint256 expected, uint256 actual); + +/// @title TimelockRehearsalSchedule +/// @notice **PENDING.** Authors the Safe bundle that SCHEDULES the timelock +/// rehearsal no-op — see `LibTimelockRehearsal` for what the operation is and +/// why it was chosen. +/// +/// Dispatch via `Actions → run-script` with +/// `script = 20260813-timelock-rehearsal-schedule` and the target `network`. +/// +/// Re-dispatch this same script for the "re-propose" stage: `cancel` clears +/// the timestamp, so the identical operation becomes schedulable again, and +/// that recovery is itself the property worth rehearsing. No separate +/// re-schedule script exists because there is no separate operation. +/// +/// Execution is NOT a Safe action: the timelock grants `EXECUTOR_ROLE` to +/// `address(0)`, so anyone may execute once the delay has run. +/// `20260813-execute-timelock-operations` does that from the CI deploy key. +/// +/// @dev Pre-flight asserts the Safe's pinned policy and the timelock's pinned +/// configuration, and refuses if the operation is already registered — so a +/// stage dispatched out of order fails here rather than in the Safe. The run +/// then proves the whole loop on the fork: warp past the delay, execute as an +/// address holding no roles, and confirm the delay is unchanged. +contract TimelockRehearsalSchedule is Script { + /// @notice Author the schedule bundle for the active chain. + function run() external { + IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid)); + address timelock = LibTimelockInvariants.timelockForChainId(block.chainid); + if (timelock == address(0)) revert RehearsalTimelockNotPinned(block.chainid); + LibTimelockInvariants.assertTimelockState(timelock, address(safe)); + + TimelockController controller = TimelockController(payable(timelock)); + bytes32 id = LibTimelockRehearsal.operationId(timelock); + if (controller.isOperation(id)) revert RehearsalAlreadyScheduled(id); + + SafeTx[] memory txs = new SafeTx[](1); + txs[0] = SafeTx({to: timelock, value: 0, data: LibTimelockRehearsal.scheduleCalldata(timelock), operation: 0}); + + uint256 nonce = safe.nonce(); + bytes32 safeTxHash = LibSafeOps.computeSafeTxHashViaSafe(safe, txs[0], nonce); + + uint256 delayBefore = controller.getMinDelay(); + LibSafeOps.simulateExternalCall(safe, txs[0].to, txs[0].data); + + // Pending, and NOT executable until the delay has run. + require(controller.isOperationPending(id), "TimelockRehearsalSchedule: operation not registered"); + require(!controller.isOperationReady(id), "TimelockRehearsalSchedule: ready before the delay elapsed"); + + // Prove the loop on the fork, executing as an address with no roles — + // which is what permissionless execution means. + vm.warp(block.timestamp + LibTimelockInvariants.TIMELOCK_MIN_DELAY); + require(controller.isOperationReady(id), "TimelockRehearsalSchedule: not ready after the delay"); + vm.prank(address(uint160(uint256(keccak256("st0x.rehearsal.roleless-executor"))))); + controller.execute(timelock, 0, LibTimelockRehearsal.payload(), bytes32(0), LibTimelockRehearsal.REHEARSAL_SALT); + require(controller.isOperationDone(id), "TimelockRehearsalSchedule: execution did not complete"); + uint256 delayAfter = controller.getMinDelay(); + if (delayAfter != delayBefore) revert RehearsalChangedMinDelay(delayBefore, delayAfter); + + string memory artifactPath = + string.concat("out/20260813-timelock-rehearsal-schedule-", vm.toString(block.chainid), ".json"); + string memory json = LibSafeOps.emitTxBuilderJson( + address(safe), block.chainid, "ST0x timelock rehearsal: schedule the no-op", txs + ); + vm.writeFile(artifactPath, json); + + console2.log("==== TX BUILDER JSON BEGIN ===="); + console2.log(json); + console2.log("==== TX BUILDER JSON END ===="); + console2.log("Artifact:", artifactPath); + console2.log("SafeTxHash:", vm.toString(safeTxHash)); + console2.log("Nonce:", nonce); + console2.log("Operation id:", vm.toString(id)); + console2.log("Chain:", block.chainid); + console2.log("Loop proven on fork: schedule -> 48h -> execute BY A ROLELESS ADDRESS -> delay unchanged"); + } +} diff --git a/script/20260813-timelock-rehearsal.s.sol b/script/20260813-timelock-rehearsal.s.sol deleted file mode 100644 index 11aefd88..00000000 --- a/script/20260813-timelock-rehearsal.s.sol +++ /dev/null @@ -1,225 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity =0.8.25; - -import {Script} from "forge-std-1.16.1/src/Script.sol"; -import {console2} from "forge-std-1.16.1/src/console2.sol"; -import {TimelockController} from "@openzeppelin-contracts-5.6.1/governance/TimelockController.sol"; - -import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol"; -import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; -import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; -import {LibTimelockInvariants} from "../src/lib/LibTimelockInvariants.sol"; - -/// @notice The active chain's governance-timelock pin is unhydrated, so -/// there is nothing to rehearse against. -/// @param chainId The active chain id. -error RehearsalTimelockNotPinned(uint256 chainId); - -/// @notice The rehearsal operation is already scheduled, so scheduling it -/// again would revert inside the Safe transaction. Cancel it first, or -/// execute it. -/// @param id The operation id already pending. -error RehearsalAlreadyScheduled(bytes32 id); - -/// @notice The rehearsal operation is not scheduled, so there is nothing to -/// cancel. -/// @param id The operation id that is not pending. -error RehearsalNotScheduled(bytes32 id); - -/// @notice The scheduled rehearsal did not leave the timelock's minimum -/// delay unchanged. The rehearsal is a no-op by construction — it re-sets -/// the delay to the value it already holds — so a change means the operation -/// being rehearsed is not the one this script believes it is. -/// @param expected The delay before the rehearsal executed. -/// @param actual The delay after. -error RehearsalChangedMinDelay(uint256 expected, uint256 actual); - -/// @title TimelockRehearsal -/// @notice **PENDING.** Authors the Safe bundles that rehearse the -/// governance timelock end-to-end BEFORE any governance is handed to it: -/// schedule a no-op, cancel it, then schedule it again. Each entrypoint -/// emits its own Safe Tx Builder JSON, so the rehearsal is driven entirely -/// from the Safe UI exactly as real governance will be. -/// -/// The operation rehearsed is `timelock.updateDelay(TIMELOCK_MIN_DELAY)` — -/// setting the delay to the value it ALREADY holds. That is deliberate on -/// three counts: -/// -/// 1. It is a genuine no-op. Even executed, nothing changes. -/// 2. OZ's `updateDelay` reverts unless the caller is the timelock itself, -/// so it CANNOT be performed any way other than through the full -/// schedule → delay → execute loop. Rehearsing it therefore exercises the -/// real path rather than a shortcut. -/// 3. It targets the timelock, not any production contract, so a rehearsal -/// left half-finished cannot touch vaults, beacons or the authoriser. -/// -/// Dispatch via `Actions → run-script` with -/// `script = 20260813-timelock-rehearsal`, the target `network`, and `sig` -/// selecting the stage: -/// -/// - `run()` — schedule the no-op (stage 1) -/// - `cancel()` — cancel it (stage 2) -/// - `reschedule()` — schedule it again (stage 3) -/// -/// Execution is NOT a Safe action: the timelock grants `EXECUTOR_ROLE` to -/// `address(0)`, so anyone may execute once the delay has run. -/// `20260813-execute-timelock-operations` does that from the CI deploy key, -/// which proves permissionlessness with a key that holds no roles at all. -/// -/// @dev Every stage pre-flights the Safe's pinned policy and the timelock's -/// pinned configuration, simulates the call as the Safe, asserts the -/// resulting operation state, and emits the artifact. `cancel()` and -/// `reschedule()` additionally assert the state they depend on, so a stage -/// dispatched out of order refuses to author rather than emitting a bundle -/// that would revert in the Safe. -contract TimelockRehearsal is Script { - /// @notice Salt distinguishing the rehearsal operation from any real - /// governance action. Fixed so every stage — and the executor script — - /// derives the same operation id without passing state between runs. - bytes32 internal constant REHEARSAL_SALT = keccak256("st0x.timelock.rehearsal.20260813"); - - /// @notice The active chain's governance timelock, asserted pinned and - /// in its expected configuration. - /// @return timelock The chain's timelock. - /// @return safe The chain's token-owner Safe. - function preflight() internal view returns (address timelock, IGnosisSafe safe) { - safe = IGnosisSafe(LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid)); - timelock = LibTimelockInvariants.timelockForChainId(block.chainid); - if (timelock == address(0)) revert RehearsalTimelockNotPinned(block.chainid); - LibTimelockInvariants.assertTimelockState(timelock, address(safe)); - } - - /// @notice The no-op call the rehearsal schedules: re-set the timelock's - /// minimum delay to the value it already holds. - /// @return The `updateDelay` calldata. - function rehearsalPayload() internal pure returns (bytes memory) { - return abi.encodeCall(TimelockController.updateDelay, (LibTimelockInvariants.TIMELOCK_MIN_DELAY)); - } - - /// @notice The rehearsal operation's id on the active chain. Shared by - /// every stage and by the executor script, so all four agree on which - /// operation they are talking about. - /// @param timelock The chain's timelock. - /// @return The operation id. - function rehearsalId(address timelock) internal view returns (bytes32) { - return TimelockController(payable(timelock)) - .hashOperation(timelock, 0, rehearsalPayload(), bytes32(0), REHEARSAL_SALT); - } - - /// @notice Stage 1 — schedule the no-op. - function run() external { - _authorSchedule("out/20260813-timelock-rehearsal-schedule-", "ST0x timelock rehearsal: schedule a no-op"); - } - - /// @notice Stage 3 — schedule the no-op again after the cancellation. - /// Identical operation to `run()`: `cancel` clears the timestamp, so the - /// same id becomes schedulable again, which is itself the property worth - /// rehearsing. - function reschedule() external { - _authorSchedule("out/20260813-timelock-rehearsal-reschedule-", "ST0x timelock rehearsal: re-schedule the no-op"); - } - - /// @notice Stage 2 — cancel the scheduled no-op. - function cancel() external { - (address timelock, IGnosisSafe safe) = preflight(); - bytes32 id = rehearsalId(timelock); - - TimelockController controller = TimelockController(payable(timelock)); - if (!controller.isOperationPending(id)) revert RehearsalNotScheduled(id); - - SafeTx[] memory txs = new SafeTx[](1); - txs[0] = SafeTx({to: timelock, value: 0, data: abi.encodeCall(TimelockController.cancel, (id)), operation: 0}); - - uint256 nonce = safe.nonce(); - bytes32 safeTxHash = LibSafeOps.computeSafeTxHashViaSafe(safe, txs[0], nonce); - - LibSafeOps.simulateExternalCall(safe, txs[0].to, txs[0].data); - - // Post-state: the operation is gone, so it is neither pending nor - // executable, and the same id is schedulable again. - require(!controller.isOperationPending(id), "TimelockRehearsal: cancel did not clear the operation"); - require(!controller.isOperation(id), "TimelockRehearsal: operation still registered after cancel"); - - _emit( - "out/20260813-timelock-rehearsal-cancel-", - "ST0x timelock rehearsal: cancel the no-op", - safe, - txs, - safeTxHash, - nonce, - id - ); - } - - /// @notice Author a schedule bundle under the supplied artifact prefix. - /// @param pathPrefix Artifact path prefix; the chain id and `.json` are - /// appended. - /// @param bundleName Human-readable name shown in the Safe Tx Builder. - function _authorSchedule(string memory pathPrefix, string memory bundleName) internal { - (address timelock, IGnosisSafe safe) = preflight(); - bytes32 id = rehearsalId(timelock); - - TimelockController controller = TimelockController(payable(timelock)); - if (controller.isOperation(id)) revert RehearsalAlreadyScheduled(id); - - SafeTx[] memory txs = new SafeTx[](1); - txs[0] = SafeTx({ - to: timelock, - value: 0, - data: abi.encodeCall( - TimelockController.schedule, - (timelock, 0, rehearsalPayload(), bytes32(0), REHEARSAL_SALT, LibTimelockInvariants.TIMELOCK_MIN_DELAY) - ), - operation: 0 - }); - - uint256 nonce = safe.nonce(); - bytes32 safeTxHash = LibSafeOps.computeSafeTxHashViaSafe(safe, txs[0], nonce); - - uint256 delayBefore = controller.getMinDelay(); - LibSafeOps.simulateExternalCall(safe, txs[0].to, txs[0].data); - - // Post-state: pending, and NOT executable until the delay has run. - require(controller.isOperationPending(id), "TimelockRehearsal: schedule did not register the operation"); - require(!controller.isOperationReady(id), "TimelockRehearsal: operation ready before the delay elapsed"); - - // Prove the whole loop on the fork: warp past the delay and execute - // as an arbitrary address with no roles, which is what permissionless - // execution means, then confirm the no-op changed nothing. - vm.warp(block.timestamp + LibTimelockInvariants.TIMELOCK_MIN_DELAY); - require(controller.isOperationReady(id), "TimelockRehearsal: operation not ready after the delay"); - vm.prank(address(uint160(uint256(keccak256("st0x.rehearsal.random-executor"))))); - controller.execute(timelock, 0, rehearsalPayload(), bytes32(0), REHEARSAL_SALT); - require(controller.isOperationDone(id), "TimelockRehearsal: execution did not complete the operation"); - uint256 delayAfter = controller.getMinDelay(); - if (delayAfter != delayBefore) revert RehearsalChangedMinDelay(delayBefore, delayAfter); - - _emit(pathPrefix, bundleName, safe, txs, safeTxHash, nonce, id); - console2.log("Loop proven on fork: schedule -> 48h -> execute BY A ROLELESS ADDRESS -> delay unchanged"); - } - - /// @notice Write the Tx Builder JSON and log what a signer cross-checks. - function _emit( - string memory pathPrefix, - string memory bundleName, - IGnosisSafe safe, - SafeTx[] memory txs, - bytes32 safeTxHash, - uint256 nonce, - bytes32 id - ) internal { - string memory artifactPath = string.concat(pathPrefix, vm.toString(block.chainid), ".json"); - string memory json = LibSafeOps.emitTxBuilderJson(address(safe), block.chainid, bundleName, txs); - vm.writeFile(artifactPath, json); - - console2.log("==== TX BUILDER JSON BEGIN ===="); - console2.log(json); - console2.log("==== TX BUILDER JSON END ===="); - console2.log("Artifact:", artifactPath); - console2.log("SafeTxHash:", vm.toString(safeTxHash)); - console2.log("Nonce:", nonce); - console2.log("Operation id:", vm.toString(id)); - console2.log("Chain:", block.chainid); - } -} diff --git a/src/lib/LibTimelockRehearsal.sol b/src/lib/LibTimelockRehearsal.sol new file mode 100644 index 00000000..1ace187b --- /dev/null +++ b/src/lib/LibTimelockRehearsal.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {TimelockController} from "@openzeppelin-contracts-5.6.1/governance/TimelockController.sol"; + +import {LibTimelockInvariants} from "./LibTimelockInvariants.sol"; + +/// @title LibTimelockRehearsal +/// @notice THE definition of the timelock rehearsal operation — the single +/// place the schedule script, the cancel script and the executor all read it +/// from. +/// +/// The rehearsal exists so the governance timelock can be exercised +/// end-to-end BEFORE any governance is handed to it: schedule, cancel, +/// schedule again, then execute once the delay has run. +/// +/// The operation is `timelock.updateDelay(TIMELOCK_MIN_DELAY)` — re-setting +/// the delay to the value it ALREADY holds. Chosen on three counts: +/// +/// 1. It is a genuine no-op. Even executed, nothing changes. +/// 2. OZ's `updateDelay` reverts unless the caller is the timelock itself, so +/// it CANNOT be performed except through the full schedule → delay → +/// execute loop. Rehearsing it exercises the real mechanism, not a +/// shortcut. +/// 3. It targets the timelock, never a production contract, so a rehearsal +/// abandoned half-way cannot touch vaults, beacons or the authoriser. +/// +/// @dev Centralised deliberately. The scripts derive an operation id from +/// `(target, value, payload, predecessor, salt)`; if any one of them held its +/// own copy and drifted, the executor would be unable to execute what the +/// schedule script scheduled, and nothing else would catch it — the ids would +/// simply never match. +library LibTimelockRehearsal { + /// @notice Salt distinguishing the rehearsal from any real governance + /// operation, so the two can never collide on an id. + bytes32 internal constant REHEARSAL_SALT = keccak256("st0x.timelock.rehearsal.20260813"); + + /// @notice The no-op call: re-set the minimum delay to its current value. + /// @return The `updateDelay` calldata. + function payload() internal pure returns (bytes memory) { + return abi.encodeCall(TimelockController.updateDelay, (LibTimelockInvariants.TIMELOCK_MIN_DELAY)); + } + + /// @notice The rehearsal operation's id on the supplied timelock. + /// @param timelock The chain's governance timelock. + /// @return The operation id. + function operationId(address timelock) internal view returns (bytes32) { + return TimelockController(payable(timelock)).hashOperation(timelock, 0, payload(), bytes32(0), REHEARSAL_SALT); + } + + /// @notice Calldata for the Safe to schedule the rehearsal. + /// @param timelock The chain's governance timelock. + /// @return The `schedule` calldata. + function scheduleCalldata(address timelock) internal pure returns (bytes memory) { + return abi.encodeCall( + TimelockController.schedule, + (timelock, 0, payload(), bytes32(0), REHEARSAL_SALT, LibTimelockInvariants.TIMELOCK_MIN_DELAY) + ); + } + + /// @notice Calldata for the Safe to cancel the rehearsal. + /// @param timelock The chain's governance timelock. + /// @return The `cancel` calldata. + function cancelCalldata(address timelock) internal view returns (bytes memory) { + return abi.encodeCall(TimelockController.cancel, (operationId(timelock))); + } +} diff --git a/test/script/20260813-timelock-rehearsal.t.sol b/test/script/20260813-timelock-rehearsal.t.sol index 7c4e4657..c0483264 100644 --- a/test/script/20260813-timelock-rehearsal.t.sol +++ b/test/script/20260813-timelock-rehearsal.t.sol @@ -7,10 +7,10 @@ import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; import {TimelockController} from "@openzeppelin-contracts-5.6.1/governance/TimelockController.sol"; import { - TimelockRehearsal, - RehearsalAlreadyScheduled, - RehearsalNotScheduled -} from "../../script/20260813-timelock-rehearsal.s.sol"; + TimelockRehearsalSchedule, + RehearsalAlreadyScheduled +} from "../../script/20260813-timelock-rehearsal-schedule.s.sol"; +import {TimelockRehearsalCancel, RehearsalNotScheduled} from "../../script/20260813-timelock-rehearsal-cancel.s.sol"; import { ExecuteTimelockOperations, OperationNotExecutable @@ -18,6 +18,7 @@ import { import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol"; import {LibTimelockInvariants} from "../../src/lib/LibTimelockInvariants.sol"; +import {LibTimelockRehearsal} from "../../src/lib/LibTimelockRehearsal.sol"; /// @title TimelockRehearsalTest /// @notice Live Base fork coverage for the pre-handover rehearsal: schedule @@ -26,11 +27,13 @@ import {LibTimelockInvariants} from "../../src/lib/LibTimelockInvariants.sol"; /// @dev Unpinned head fork so the assertions run against the live timelock /// and Safe, the same state a real dispatch authors from. contract TimelockRehearsalTest is Test { - TimelockRehearsal internal rehearsal; + TimelockRehearsalSchedule internal scheduleScript; + TimelockRehearsalCancel internal cancelScript; function setUp() external { vm.createSelectFork(LibRainDeploy.BASE); - rehearsal = new TimelockRehearsal(); + scheduleScript = new TimelockRehearsalSchedule(); + cancelScript = new TimelockRehearsalCancel(); } /// @notice The active chain's timelock, as the scripts resolve it. @@ -57,7 +60,7 @@ contract TimelockRehearsalTest is Test { /// timelock, and the run's own fork proof shows the operation matures /// and executes without changing the delay. function testScheduleAuthorsABundle() external { - rehearsal.run(); + scheduleScript.run(); (uint256 chainId, address firstTarget, SafeTx[] memory txs) = LibSafeOps.parseTxBuilderJson( string.concat("out/20260813-timelock-rehearsal-schedule-", vm.toString(block.chainid), ".json") @@ -86,7 +89,7 @@ contract TimelockRehearsalTest is Test { /// leaves `getMinDelay()` unchanged. function testRehearsalIsANoOp() external { uint256 before = timelock().getMinDelay(); - rehearsal.run(); + scheduleScript.run(); assertEq(timelock().getMinDelay(), before, "the rehearsal must not change the delay"); assertEq(before, LibTimelockInvariants.TIMELOCK_MIN_DELAY); } @@ -95,7 +98,7 @@ contract TimelockRehearsalTest is Test { /// emitting a bundle that would revert inside the Safe. function testCancelRefusesWhenNothingScheduled() external { vm.expectRevert(abi.encodeWithSelector(RehearsalNotScheduled.selector, rehearsalOperationId())); - rehearsal.cancel(); + cancelScript.run(); } /// @notice Scheduling refuses when the operation is already registered, @@ -105,12 +108,13 @@ contract TimelockRehearsalTest is Test { // schedule it again. _scheduleAsSafe(); vm.expectRevert(abi.encodeWithSelector(RehearsalAlreadyScheduled.selector, rehearsalOperationId())); - rehearsal.run(); + scheduleScript.run(); } - /// @notice The full stage sequence: schedule, cancel, re-schedule. After - /// the cancel the SAME id is schedulable again — the property the - /// rehearsal exists to demonstrate. + /// @notice The full stage sequence: schedule, cancel, schedule again. + /// After the cancel the SAME id is schedulable again — which is why the + /// re-propose stage is a re-dispatch of the schedule script rather than a + /// separate script: there is no separate operation. function testScheduleCancelReschedule() external { bytes32 id = rehearsalOperationId(); @@ -119,12 +123,12 @@ contract TimelockRehearsalTest is Test { // `cancel()` simulates the Safe call against the fork, so the // operation is genuinely cleared here — no second cancel needed. - rehearsal.cancel(); + cancelScript.run(); assertFalse(timelock().isOperation(id), "cancel clears the operation"); - rehearsal.reschedule(); + scheduleScript.run(); (,, SafeTx[] memory txs) = LibSafeOps.parseTxBuilderJson( - string.concat("out/20260813-timelock-rehearsal-reschedule-", vm.toString(block.chainid), ".json") + string.concat("out/20260813-timelock-rehearsal-schedule-", vm.toString(block.chainid), ".json") ); assertEq(txs.length, 1); assertEq(txs[0].to, address(timelock()));