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..2221d994 100644 --- a/.github/workflows/run-script.yaml +++ b/.github/workflows/run-script.yaml @@ -31,6 +31,8 @@ on: - 20260722-swap-remaining-vault-authorisers - 20260723-provision-additional-service-signer - 20260729-migrate-governance-to-timelock + - 20260813-timelock-rehearsal-schedule + - 20260813-timelock-rehearsal-cancel - 20260810-revoke-fireblocks-service-signer network: description: 'Network to author against (default: base)' diff --git a/docs/TIMELOCK.md b/docs/TIMELOCK.md index a1f38a89..c0887d6c 100644 --- a/docs/TIMELOCK.md +++ b/docs/TIMELOCK.md @@ -138,6 +138,37 @@ 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`: + +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` → +`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..5a6116a7 --- /dev/null +++ b/script/20260813-execute-timelock-operations.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 {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"; +import {LibTimelockRehearsal} from "../src/lib/LibTimelockRehearsal.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 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. +/// +/// @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 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 = LibTimelockRehearsal.payload(); + bytes32 id = LibTimelockRehearsal.operationId(timelock); + + 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), LibTimelockRehearsal.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-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/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 new file mode 100644 index 00000000..c0483264 --- /dev/null +++ b/test/script/20260813-timelock-rehearsal.t.sol @@ -0,0 +1,178 @@ +// 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 { + TimelockRehearsalSchedule, + RehearsalAlreadyScheduled +} from "../../script/20260813-timelock-rehearsal-schedule.s.sol"; +import {TimelockRehearsalCancel, RehearsalNotScheduled} from "../../script/20260813-timelock-rehearsal-cancel.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"; +import {LibTimelockRehearsal} from "../../src/lib/LibTimelockRehearsal.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 { + TimelockRehearsalSchedule internal scheduleScript; + TimelockRehearsalCancel internal cancelScript; + + function setUp() external { + vm.createSelectFork(LibRainDeploy.BASE); + scheduleScript = new TimelockRehearsalSchedule(); + cancelScript = new TimelockRehearsalCancel(); + } + + /// @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 { + scheduleScript.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(); + scheduleScript.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())); + cancelScript.run(); + } + + /// @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())); + scheduleScript.run(); + } + + /// @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(); + + _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. + cancelScript.run(); + assertFalse(timelock().isOperation(id), "cancel clears the operation"); + + scheduleScript.run(); + (,, SafeTx[] memory txs) = LibSafeOps.parseTxBuilderJson( + string.concat("out/20260813-timelock-rehearsal-schedule-", 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 + ); + } +}