From 8675266d02d524b78c9362bd3203afd34c81f8c9 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 29 Jul 2026 06:54:53 +0000 Subject: [PATCH 01/17] feat(lib): LibTimelockInvariants + authoriser grant-map admin-holder parameterisation New src/lib/LibTimelockInvariants.sol: constants and invariant assertions for the ST0x governance timelock (unmodified pre-audited OZ TimelockController from the version-locked 5.6.1 soldeer dep): - TIMELOCK_MIN_DELAY = 48 hours, role-hash mirrors pinned against the live OZ getters. - Per-chain address pins (Base + Ethereum), placeholders until the deploy broadcast executes and a pin PR hydrates them; timelockForChainId with a typed revert on unsupported chains (no cross-chain fallback). - timelockInitCode/expectedTimelockAddress: the exact creation code the deploy broadcasts (Safe as sole proposer+canceller+executor, admin = 0 so the timelock self-administers from birth and the deploy key is never granted anything) and its Zoltu CREATE2 address, derived in-source and validated against the real factory bytecode in tests. - assertTimelockState: codehash + minDelay pins, Safe role set, timelock self-administration, and negative checks (Safe must not hold root admin, no OZ open-role zero-address grants). - TIMELOCK_CANCELLER placeholder for a future dedicated canceller principal; asserted once hydrated. LibAuthoriserInvariants: expectedGrants/assertExpectedGrants gain an adminHolder parameterisation so the post-timelock-migration grant map (the seven _ADMIN roles on the timelock, operational roles unchanged) is expressible through the same single master map; existing overloads are exact collapses with adminHolder = Safe. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RquYKmoEVSfuVHy8kwn1yT --- src/lib/LibAuthoriserInvariants.sol | 60 +++- src/lib/LibTimelockInvariants.sol | 299 ++++++++++++++++++ test/src/lib/LibAuthoriserInvariants.t.sol | 62 ++++ .../lib/LibAuthoriserInvariantsHarness.sol | 4 + test/src/lib/LibTimelockInvariants.t.sol | 218 +++++++++++++ test/src/lib/LibTimelockInvariantsHarness.sol | 19 ++ 6 files changed, 653 insertions(+), 9 deletions(-) create mode 100644 src/lib/LibTimelockInvariants.sol create mode 100644 test/src/lib/LibTimelockInvariants.t.sol create mode 100644 test/src/lib/LibTimelockInvariantsHarness.sol diff --git a/src/lib/LibAuthoriserInvariants.sol b/src/lib/LibAuthoriserInvariants.sol index 931868aa..75b85007 100644 --- a/src/lib/LibAuthoriserInvariants.sol +++ b/src/lib/LibAuthoriserInvariants.sol @@ -113,20 +113,44 @@ library LibAuthoriserInvariants { /// grantee slots. /// @return grants The `(role, grantee)` pairs for that chain. function expectedGrants(address tokenOwnerSafe) internal pure returns (RoleGrant[] memory grants) { + grants = expectedGrants(tokenOwnerSafe, tokenOwnerSafe); + } + + /// @notice The `(role, grantee)` map parameterised on BOTH the chain's + /// token-owner Safe AND the holder of the seven `_ADMIN` roles. Before + /// the governance-timelock migration the Safe is the admin holder (the + /// two-arg call sites above collapse the parameters); after the + /// migration the seven `_ADMIN` roles sit on the governance timelock + /// while the Safe keeps its three direct action roles. This overload is + /// the single map both states are expressed through, so the migration + /// script's post-state and the post-migration invariant surface assert + /// the same structure the pre-migration consumers do. + /// @param tokenOwnerSafe The chain's token-owner Safe filling the + /// operational Safe grantee slots. + /// @param adminHolder The holder of the seven `_ADMIN` roles (the Safe + /// pre-migration, the governance timelock post-migration). + /// @return grants The `(role, grantee)` pairs for that chain. + function expectedGrants(address tokenOwnerSafe, address adminHolder) + internal + pure + returns (RoleGrant[] memory grants) + { grants = new RoleGrant[](16); - // Init grants (block 41715184 on Base) — Safe receives every `_ADMIN`. - grants[0] = RoleGrant(keccak256("DEPOSIT_ADMIN"), tokenOwnerSafe); - grants[1] = RoleGrant(keccak256("WITHDRAW_ADMIN"), tokenOwnerSafe); - grants[2] = RoleGrant(keccak256("CERTIFY_ADMIN"), tokenOwnerSafe); - grants[3] = RoleGrant(keccak256("CONFISCATE_SHARES_ADMIN"), tokenOwnerSafe); - grants[4] = RoleGrant(keccak256("CONFISCATE_RECEIPT_ADMIN"), tokenOwnerSafe); + // Init grants (block 41715184 on Base) — the admin holder receives + // every `_ADMIN` (the Safe at init; the governance timelock once the + // timelock migration executes). + grants[0] = RoleGrant(keccak256("DEPOSIT_ADMIN"), adminHolder); + grants[1] = RoleGrant(keccak256("WITHDRAW_ADMIN"), adminHolder); + grants[2] = RoleGrant(keccak256("CERTIFY_ADMIN"), adminHolder); + grants[3] = RoleGrant(keccak256("CONFISCATE_SHARES_ADMIN"), adminHolder); + grants[4] = RoleGrant(keccak256("CONFISCATE_RECEIPT_ADMIN"), adminHolder); // The two corporate-action admins the 0.1.1 impl adds. On Base the // clone-deploy broadcast transferred them to the Safe alongside the // other five and renounced them from the deploy key. - grants[5] = RoleGrant(keccak256("SCHEDULE_CORPORATE_ACTION_ADMIN"), tokenOwnerSafe); - grants[6] = RoleGrant(keccak256("CANCEL_CORPORATE_ACTION_ADMIN"), tokenOwnerSafe); + grants[5] = RoleGrant(keccak256("SCHEDULE_CORPORATE_ACTION_ADMIN"), adminHolder); + grants[6] = RoleGrant(keccak256("CANCEL_CORPORATE_ACTION_ADMIN"), adminHolder); // Service EOA provisioned at blocks 41797262, 41797281, 41797297 (Base). grants[7] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_1C66); @@ -173,6 +197,21 @@ library LibAuthoriserInvariants { /// @param tokenOwnerSafe The chain's token-owner Safe filling the Safe /// grantee slots. function assertExpectedGrants(address authoriser, address tokenOwnerSafe) internal view { + assertExpectedGrants(authoriser, tokenOwnerSafe, tokenOwnerSafe); + } + + /// @notice Assert every `(role, grantee)` pair from + /// `expectedGrants(tokenOwnerSafe, adminHolder)` is held on the supplied + /// authoriser, and that no named principal — the Safe, the admin holder, + /// the service signer — holds `DEFAULT_ADMIN_ROLE`. This is the + /// post-timelock-migration assertion surface: the migration script's + /// post-state and the migration-window invariants call it with the + /// governance timelock as `adminHolder`. + /// @param authoriser The authoriser to validate. + /// @param tokenOwnerSafe The chain's token-owner Safe filling the + /// operational Safe grantee slots. + /// @param adminHolder The holder of the seven `_ADMIN` roles. + function assertExpectedGrants(address authoriser, address tokenOwnerSafe, address adminHolder) internal view { IAccessControl acl = IAccessControl(authoriser); // No pinned grantee holds DEFAULT_ADMIN_ROLE: the hierarchy admins each // action role by its own `_ADMIN`, so a root-admin holder would @@ -180,13 +219,16 @@ library LibAuthoriserInvariants { if (acl.hasRole(DEFAULT_ADMIN_ROLE, tokenOwnerSafe)) { revert UnexpectedDefaultAdmin(authoriser, tokenOwnerSafe); } + if (acl.hasRole(DEFAULT_ADMIN_ROLE, adminHolder)) { + revert UnexpectedDefaultAdmin(authoriser, adminHolder); + } if (acl.hasRole(DEFAULT_ADMIN_ROLE, GRANTEE_SERVICE_1C66)) { revert UnexpectedDefaultAdmin(authoriser, GRANTEE_SERVICE_1C66); } if (acl.hasRole(DEFAULT_ADMIN_ROLE, GRANTEE_SERVICE_3D0C)) { revert UnexpectedDefaultAdmin(authoriser, GRANTEE_SERVICE_3D0C); } - RoleGrant[] memory grants = expectedGrants(tokenOwnerSafe); + RoleGrant[] memory grants = expectedGrants(tokenOwnerSafe, adminHolder); for (uint256 i = 0; i < grants.length; i++) { if (!acl.hasRole(grants[i].role, grants[i].grantee)) { revert ExpectedGrantMissing(authoriser, grants[i].role, grants[i].grantee); diff --git a/src/lib/LibTimelockInvariants.sol b/src/lib/LibTimelockInvariants.sol new file mode 100644 index 00000000..fc409c7c --- /dev/null +++ b/src/lib/LibTimelockInvariants.sol @@ -0,0 +1,299 @@ +// 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 {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; +import {LibSafeInvariants} from "./LibSafeInvariants.sol"; + +/// @notice No ST0x governance timelock address is pinned for the active +/// chain. Deliberately a typed revert rather than a silent fallback to +/// another chain's timelock: asserting or migrating against the wrong +/// chain's timelock is the catastrophic failure this selector exists to +/// prevent. +/// @param chainId The chain id with no pinned governance timelock. +error UnsupportedChainForGovernanceTimelock(uint256 chainId); + +/// @notice The governance timelock has no runtime code. Either the pin is +/// stale, the deploy never ran on this chain, or the address was computed +/// from different constructor arguments than the deployed instance. +/// @param timelock The timelock address that has no code. +error TimelockNotDeployed(address timelock); + +/// @notice The runtime codehash at the timelock address does not match the +/// codehash of the audited OZ `TimelockController` this repo compiles +/// against. The contract at the pin is not the expected timelock. +/// @param timelock The timelock address inspected. +/// @param expected The expected `TimelockController` runtime codehash. +/// @param actual The codehash observed on-chain. +error TimelockCodehashMismatch(address timelock, bytes32 expected, bytes32 actual); + +/// @notice The timelock's `getMinDelay()` does not match the pinned +/// `TIMELOCK_MIN_DELAY`. Either the deploy used different constructor +/// arguments or a scheduled `updateDelay` has executed without the pin +/// being updated in the same operational window. +/// @param timelock The timelock inspected. +/// @param expected The pinned minimum delay in seconds. +/// @param actual The minimum delay reported by the live timelock. +error TimelockMinDelayMismatch(address timelock, uint256 expected, uint256 actual); + +/// @notice An account that must hold a role on the governance timelock does +/// not hold it. Surfaces the exact `(role, account)` pair that broke the +/// invariant. +/// @param timelock The timelock inspected. +/// @param role The role that should be held. +/// @param account The account that should hold the role. +error TimelockMissingRole(address timelock, bytes32 role, address account); + +/// @notice An account holds a role on the governance timelock that the +/// pinned configuration does not sanction — e.g. the zero address holding +/// `EXECUTOR_ROLE` (which OZ treats as "execution open to everyone") or the +/// proposer Safe holding `DEFAULT_ADMIN_ROLE` (an instant-bypass escalation +/// path around the delay). +/// @param timelock The timelock inspected. +/// @param role The role that must not be held. +/// @param account The account that must not hold the role. +error TimelockUnexpectedRole(address timelock, bytes32 role, address account); + +/// @title LibTimelockInvariants +/// @notice Reusable constants and invariant assertions for the ST0x +/// governance timelock: an UNMODIFIED, pre-audited OpenZeppelin +/// `TimelockController` (from the version-locked soldeer dependency this +/// repo compiles against) that sits between the token-owner Safe and the +/// privileged surfaces it governs. Post-migration the timelock is the +/// `owner()` of every production receipt vault and the sole holder of the +/// authoriser's seven `_ADMIN` roles, so every ownership action and every +/// grant-map change must be scheduled, wait out `TIMELOCK_MIN_DELAY`, and +/// only then execute. +/// +/// Role model (pinned by `assertTimelockState`): +/// - The chain's token-owner Safe holds `PROPOSER_ROLE`, `CANCELLER_ROLE` +/// and `EXECUTOR_ROLE` — it schedules, can cancel during the window, and +/// executes once the delay elapses. +/// - The timelock holds `DEFAULT_ADMIN_ROLE` on itself (OZ +/// self-administration): role changes — e.g. provisioning the dedicated +/// canceller once `TIMELOCK_CANCELLER` is decided — must themselves go +/// through the delay. +/// - Nobody else holds anything. In particular the deploy key holds no role +/// (the deploy passes `admin = address(0)`, so the constructor grants the +/// deployer nothing — there is nothing to revoke), and the zero address +/// holds no role (OZ's "open role" semantics stay disabled). +/// +/// @dev The timelock is deployed via the Zoltu deterministic factory, so its +/// address is a pure function of its creation code — computable in-source by +/// `expectedTimelockAddress`. The per-chain ADDRESS pins below are the +/// audit-trail constants scripts and invariants target; each is hydrated by +/// a post-deploy pin PR and must equal the derived address (a structure test +/// pins that equality once hydrated). Constructor arguments embed the +/// chain's Safe, so the timelock address is a per-chain deploy artifact — +/// same policy, different address per chain, exactly like the Safe itself. +library LibTimelockInvariants { + /// @notice The timelock's minimum delay in seconds: every governance + /// operation (vault ownership actions, authoriser grant-map changes) + /// must wait at least this long between `schedule` and `execute`. + /// 48 hours: long enough for depositors and integrating protocols to + /// observe a pending admin action and exit, short enough for routine + /// operations to remain practical. Changing it post-deploy is itself a + /// timelocked operation (`updateDelay` is self-administered) and must + /// update this pin in the same operational window. + uint256 internal constant TIMELOCK_MIN_DELAY = 48 hours; + + /// @notice OZ `TimelockController` proposer role. Mirrored from the OZ + /// source as a constant so invariant call sites don't need a deployed + /// instance to name the role; `LibTimelockInvariantsTest` pins each + /// mirror against the live getter. + bytes32 internal constant TIMELOCK_PROPOSER_ROLE = keccak256("PROPOSER_ROLE"); + + /// @notice OZ `TimelockController` executor role. + bytes32 internal constant TIMELOCK_EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE"); + + /// @notice OZ `TimelockController` canceller role. + bytes32 internal constant TIMELOCK_CANCELLER_ROLE = keccak256("CANCELLER_ROLE"); + + /// @notice OZ `AccessControl` root admin role — held by the timelock on + /// itself (self-administration) and nobody else. + bytes32 internal constant TIMELOCK_DEFAULT_ADMIN_ROLE = bytes32(0); + + /// @notice The ST0x governance timelock on **Base**. + /// + /// **PLACEHOLDER** (`address(0)`) until the + /// `20260729-deploy-governance-timelock` broadcast executes on Base and + /// the post-execution pin PR hydrates this constant with the logged + /// address (which must equal `expectedTimelockAddress(Base Safe)`). + address internal constant STOX_GOVERNANCE_TIMELOCK = address(0); + + /// @notice The ST0x governance timelock on **Ethereum mainnet**. + /// + /// **PLACEHOLDER** (`address(0)`) until the + /// `20260729-deploy-governance-timelock` broadcast executes on Ethereum + /// and the post-execution pin PR hydrates this constant. A distinct + /// per-chain address: the constructor embeds the chain's Safe, so the + /// Zoltu-derived address differs per chain by construction. + address internal constant STOX_GOVERNANCE_TIMELOCK_ETHEREUM = address(0); + + /// @notice **PLACEHOLDER** for a dedicated canceller principal (a + /// separate key or Safe that can veto a scheduled operation during the + /// delay window without being able to propose or execute). Undecided: + /// until it is chosen the token-owner Safe holds `CANCELLER_ROLE` (the + /// OZ constructor grants it alongside `PROPOSER_ROLE`). Provisioning a + /// dedicated canceller later is itself a timelocked operation — schedule + /// `grantRole(CANCELLER_ROLE, canceller)` on the timelock — and hydrates + /// this constant in the same operational window. `assertTimelockState` + /// starts asserting the grant once this pin is non-zero. + address internal constant TIMELOCK_CANCELLER = address(0); + + /// @notice The ST0x governance timelock address for the active chain, + /// selected by chain id — `address(0)` until that chain's timelock is + /// deployed and the pin hydrated. Reverts for any chain without a pin + /// rather than falling back to another chain's timelock. + /// @param chainId The active chain id (`block.chainid`). + /// @return timelock The chain's governance timelock pin. + function timelockForChainId(uint256 chainId) internal pure returns (address timelock) { + if (chainId == LibSafeInvariants.BASE_CHAIN_ID) { + return STOX_GOVERNANCE_TIMELOCK; + } + if (chainId == LibSafeInvariants.ETHEREUM_CHAIN_ID) { + return STOX_GOVERNANCE_TIMELOCK_ETHEREUM; + } + revert UnsupportedChainForGovernanceTimelock(chainId); + } + + /// @notice The exact creation code the governance timelock deploy + /// broadcasts: the audited OZ `TimelockController` creation bytecode + /// (compiled from the version-locked soldeer dependency under this + /// repo's single compiler profile) with the pinned constructor + /// arguments appended — `TIMELOCK_MIN_DELAY`, the supplied Safe as sole + /// proposer (which also makes it canceller) and sole executor, and + /// `admin = address(0)` so the timelock is self-administered from birth + /// and the deploy key is never granted anything. + /// @param proposerExecutorSafe The chain's token-owner Safe. + /// @return The creation code including constructor arguments. + function timelockInitCode(address proposerExecutorSafe) internal pure returns (bytes memory) { + address[] memory proposers = new address[](1); + proposers[0] = proposerExecutorSafe; + address[] memory executors = new address[](1); + executors[0] = proposerExecutorSafe; + return abi.encodePacked( + type(TimelockController).creationCode, abi.encode(TIMELOCK_MIN_DELAY, proposers, executors, address(0)) + ); + } + + /// @notice The deterministic address `timelockInitCode` deploys to via + /// the Zoltu factory: `CREATE2` with the factory as deployer and a zero + /// salt (the factory hardcodes it), so the address is a pure function + /// of the creation code. The deploy script asserts the landed address + /// equals this; once the per-chain pin is hydrated a structure test + /// pins `pin == expectedTimelockAddress(chain's Safe)`. + /// @param proposerExecutorSafe The chain's token-owner Safe. + /// @return The deterministic timelock address for that Safe. + function expectedTimelockAddress(address proposerExecutorSafe) internal pure returns (address) { + bytes32 initCodeHash = keccak256(timelockInitCode(proposerExecutorSafe)); + // forge-lint: disable-next-line(unsafe-typecast) + return address( + uint160( + uint256(keccak256(abi.encodePacked(hex"ff", LibRainDeploy.ZOLTU_FACTORY, bytes32(0), initCodeHash))) + ) + ); + } + + /// @notice The runtime codehash every deployed governance timelock must + /// carry: the hash of the OZ `TimelockController` runtime bytecode this + /// repo compiles. Derived in-source (the contract has no immutables, so + /// `type(...).runtimeCode` is the exact deployed shape) rather than + /// pinned as a literal, so a dependency or compiler-settings change + /// surfaces as a codehash mismatch against the live deployment instead + /// of silently moving the expectation. + /// @return The expected `TimelockController` runtime codehash. + function timelockRuntimeCodehash() internal pure returns (bytes32) { + return keccak256(type(TimelockController).runtimeCode); + } + + /// @notice Assert the governance timelock at `timelock` is in its pinned + /// state: deployed with the expected `TimelockController` runtime + /// codehash, `getMinDelay() == TIMELOCK_MIN_DELAY`, the Safe holds + /// proposer + canceller + executor, the timelock self-administers, and + /// no unsanctioned holder exists on the checked surface — the Safe does + /// not hold root admin, and the zero address holds nothing (OZ's "open + /// role" semantics stay disabled). Reverts with a typed error on the + /// first failure; returns silently otherwise. + /// @dev The negative checks are targeted assertions over the named + /// principals, not an exhaustive holder scan (a plain `AccessControl` + /// cannot enumerate members) — the same bound `LibAuthoriserInvariants` + /// documents for the authoriser's grant map. Once `TIMELOCK_CANCELLER` + /// is hydrated the dedicated canceller's grant is asserted too. + /// @param timelock The governance timelock to validate. + /// @param proposerExecutorSafe The chain's token-owner Safe expected to + /// hold the proposer / canceller / executor roles. + function assertTimelockState(address timelock, address proposerExecutorSafe) internal view { + if (timelock.code.length == 0) revert TimelockNotDeployed(timelock); + bytes32 expectedCodehash = timelockRuntimeCodehash(); + bytes32 actualCodehash = timelock.codehash; + if (actualCodehash != expectedCodehash) { + revert TimelockCodehashMismatch(timelock, expectedCodehash, actualCodehash); + } + + uint256 actualMinDelay = TimelockController(payable(timelock)).getMinDelay(); + if (actualMinDelay != TIMELOCK_MIN_DELAY) { + revert TimelockMinDelayMismatch(timelock, TIMELOCK_MIN_DELAY, actualMinDelay); + } + + IAccessControl acl = IAccessControl(timelock); + + // The Safe drives every stage of the operation lifecycle: schedule, + // cancel (until a dedicated canceller is provisioned), execute. + _assertHasRole(acl, timelock, TIMELOCK_PROPOSER_ROLE, proposerExecutorSafe); + _assertHasRole(acl, timelock, TIMELOCK_CANCELLER_ROLE, proposerExecutorSafe); + _assertHasRole(acl, timelock, TIMELOCK_EXECUTOR_ROLE, proposerExecutorSafe); + + // Self-administration: role changes go through the delay. + _assertHasRole(acl, timelock, TIMELOCK_DEFAULT_ADMIN_ROLE, timelock); + + // The Safe must NOT hold root admin — that would let it re-grant + // roles instantly, bypassing the delay the timelock exists to + // impose. + if (acl.hasRole(TIMELOCK_DEFAULT_ADMIN_ROLE, proposerExecutorSafe)) { + revert TimelockUnexpectedRole(timelock, TIMELOCK_DEFAULT_ADMIN_ROLE, proposerExecutorSafe); + } + + // OZ treats a zero-address grantee as "role open to everyone". + // Every lifecycle role must stay closed: open-proposer or + // open-canceller is an obvious takeover, and open-executor would + // let anyone execute (acceptable in some designs, but not the + // pinned one — execution stays with the Safe). + _assertNotOpenRole(acl, timelock, TIMELOCK_PROPOSER_ROLE); + _assertNotOpenRole(acl, timelock, TIMELOCK_CANCELLER_ROLE); + _assertNotOpenRole(acl, timelock, TIMELOCK_EXECUTOR_ROLE); + _assertNotOpenRole(acl, timelock, TIMELOCK_DEFAULT_ADMIN_ROLE); + + // Once the dedicated canceller is decided and its pin hydrated, it + // must hold CANCELLER_ROLE (provisioned via a timelocked + // `grantRole`). + if (TIMELOCK_CANCELLER != address(0)) { + _assertHasRole(acl, timelock, TIMELOCK_CANCELLER_ROLE, TIMELOCK_CANCELLER); + } + } + + /// @notice Revert `TimelockMissingRole` unless `account` holds `role`. + /// @param acl The timelock's `IAccessControl` surface. + /// @param timelock The timelock address (surfaced in the revert). + /// @param role The role to check. + /// @param account The account that must hold the role. + function _assertHasRole(IAccessControl acl, address timelock, bytes32 role, address account) private view { + if (!acl.hasRole(role, account)) { + revert TimelockMissingRole(timelock, role, account); + } + } + + /// @notice Revert `TimelockUnexpectedRole` if the zero address holds + /// `role` — OZ's `onlyRoleOrOpenRole` treats that as the role being + /// open to every caller. + /// @param acl The timelock's `IAccessControl` surface. + /// @param timelock The timelock address (surfaced in the revert). + /// @param role The role that must not be open. + function _assertNotOpenRole(IAccessControl acl, address timelock, bytes32 role) private view { + if (acl.hasRole(role, address(0))) { + revert TimelockUnexpectedRole(timelock, role, address(0)); + } + } +} diff --git a/test/src/lib/LibAuthoriserInvariants.t.sol b/test/src/lib/LibAuthoriserInvariants.t.sol index e0288be8..cca25957 100644 --- a/test/src/lib/LibAuthoriserInvariants.t.sol +++ b/test/src/lib/LibAuthoriserInvariants.t.sol @@ -6,6 +6,8 @@ import {Test} from "forge-std-1.16.1/src/Test.sol"; import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; import { LibAuthoriserInvariants, + RoleGrant, + ExpectedGrantMissing, UnexpectedDefaultAdmin, AuthoriserImplCodehashMismatch } from "../../../src/lib/LibAuthoriserInvariants.sol"; @@ -79,4 +81,64 @@ contract LibAuthoriserInvariantsTest is Test { ); harness.callAssertExpectedGrants(clone); } + + /// @notice The admin-holder parameterisation: the seven `_ADMIN` entries + /// track `adminHolder`, the six operational entries stay split between + /// the service signer and the Safe, and the narrower overloads are exact + /// collapses of the widest one (so no consumer can drift from the single + /// map). + function testExpectedGrantsAdminHolderParameterisation() external pure { + address safe = address(0x5AFE); + address timelock = address(0x7135); + RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(safe, timelock); + assertEq(grants.length, 13); + for (uint256 i = 0; i < 7; i++) { + assertEq(grants[i].grantee, timelock, "admin entries must track adminHolder"); + } + for (uint256 i = 7; i < 10; i++) { + assertEq(grants[i].grantee, LibAuthoriserInvariants.GRANTEE_SERVICE_1C66); + } + for (uint256 i = 10; i < 13; i++) { + assertEq(grants[i].grantee, safe, "operational Safe entries must track the Safe"); + } + + // The two-arg overload is the adminHolder == Safe collapse. + RoleGrant[] memory collapsed = LibAuthoriserInvariants.expectedGrants(safe); + RoleGrant[] memory widened = LibAuthoriserInvariants.expectedGrants(safe, safe); + assertEq(collapsed.length, widened.length); + for (uint256 i = 0; i < collapsed.length; i++) { + assertEq(collapsed[i].role, widened[i].role); + assertEq(collapsed[i].grantee, widened[i].grantee); + } + } + + /// @notice `assertExpectedGrants(authoriser, safe, adminHolder)` passes + /// once the live clone's seven `_ADMIN` roles are mocked onto the admin + /// holder — the exact post-timelock-migration shape — and pinpoints the + /// first missing `_ADMIN` grant when the mock is absent. + function testAssertExpectedGrantsWithDistinctAdminHolder() external { + selectBaseFork(); + address clone = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + address safe = LibAuthoriserInvariants.GRANTEE_TOKEN_OWNER_SAFE; + address timelock = address(0x7135); + LibAuthoriserInvariantsHarness harness = new LibAuthoriserInvariantsHarness(); + + // Without the timelock holding anything, the first `_ADMIN` entry is + // reported missing for the timelock. + RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(safe, timelock); + vm.expectRevert(abi.encodeWithSelector(ExpectedGrantMissing.selector, clone, grants[0].role, timelock)); + harness.callAssertExpectedGrants(clone, safe, timelock); + + // Mock the seven `_ADMIN` grants onto the timelock — the operational + // entries are already live on the fork — and the full assertion + // passes. + for (uint256 i = 0; i < 7; i++) { + vm.mockCall( + clone, + abi.encodeWithSelector(IAccessControl.hasRole.selector, grants[i].role, timelock), + abi.encode(true) + ); + } + harness.callAssertExpectedGrants(clone, safe, timelock); + } } diff --git a/test/src/lib/LibAuthoriserInvariantsHarness.sol b/test/src/lib/LibAuthoriserInvariantsHarness.sol index 01e7bba0..73eae56b 100644 --- a/test/src/lib/LibAuthoriserInvariantsHarness.sol +++ b/test/src/lib/LibAuthoriserInvariantsHarness.sol @@ -16,4 +16,8 @@ contract LibAuthoriserInvariantsHarness { function callAssertExpectedGrants(address authoriser) external view { LibAuthoriserInvariants.assertExpectedGrants(authoriser); } + + function callAssertExpectedGrants(address authoriser, address tokenOwnerSafe, address adminHolder) external view { + LibAuthoriserInvariants.assertExpectedGrants(authoriser, tokenOwnerSafe, adminHolder); + } } diff --git a/test/src/lib/LibTimelockInvariants.t.sol b/test/src/lib/LibTimelockInvariants.t.sol new file mode 100644 index 00000000..de9cf537 --- /dev/null +++ b/test/src/lib/LibTimelockInvariants.t.sol @@ -0,0 +1,218 @@ +// 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 {TimelockController} from "@openzeppelin-contracts-5.6.1/governance/TimelockController.sol"; +import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; +import { + LibTimelockInvariants, + UnsupportedChainForGovernanceTimelock, + TimelockNotDeployed, + TimelockCodehashMismatch, + TimelockMinDelayMismatch, + TimelockMissingRole, + TimelockUnexpectedRole +} from "../../../src/lib/LibTimelockInvariants.sol"; +import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; +import {LibTimelockInvariantsHarness} from "./LibTimelockInvariantsHarness.sol"; + +/// @title LibTimelockInvariantsTest +/// @notice Local (non-fork) coverage for `LibTimelockInvariants`: the Zoltu +/// address derivation is validated against the REAL factory bytecode (etched +/// locally), the state assertion is exercised green against a +/// pinned-configuration deploy and red against every deviation class — +/// missing code, alien code, wrong delay, missing role, open role, and a +/// root-admin escalation path. +contract LibTimelockInvariantsTest is Test { + /// @notice Stand-in for a chain's token-owner Safe. The invariants only + /// read role membership for this address, so a plain EOA-style address + /// is sufficient locally. + address internal constant SAFE = address(0x5aFe00000000000000000000000000000000aAaa); + + LibTimelockInvariantsHarness internal harness; + + function setUp() external { + harness = new LibTimelockInvariantsHarness(); + } + + /// @notice Deploy a timelock through the real (etched) Zoltu factory + /// from the pinned init code, exactly as the deploy broadcast does. + function deployPinnedTimelock() internal returns (address) { + LibRainDeploy.etchZoltuFactory(vm); + return LibRainDeploy.deployZoltu(LibTimelockInvariants.timelockInitCode(SAFE)); + } + + /// @notice The in-source CREATE2 derivation must land exactly where the + /// real Zoltu factory deploys the same init code — this is the equality + /// the deploy script's address assertion and the future pin PR both + /// stand on. + function testExpectedAddressMatchesRealZoltuFactory() external { + address deployed = deployPinnedTimelock(); + assertEq(deployed, LibTimelockInvariants.expectedTimelockAddress(SAFE)); + } + + /// @notice Distinct Safes must derive distinct timelock addresses — the + /// constructor arguments are part of the init code, so per-chain Safes + /// produce per-chain timelocks by construction. + function testExpectedAddressVariesWithSafe() external pure { + assertNotEq( + LibTimelockInvariants.expectedTimelockAddress(SAFE), + LibTimelockInvariants.expectedTimelockAddress(address(0xBEEF)) + ); + } + + /// @notice The full state assertion passes against a fresh + /// pinned-configuration deploy. + function testAssertTimelockStatePasses() external { + address timelock = deployPinnedTimelock(); + LibTimelockInvariants.assertTimelockState(timelock, SAFE); + } + + /// @notice The role-hash mirrors match the live OZ getters, so invariant + /// call sites can name roles without a deployed instance. + function testRoleMirrorsMatchLiveGetters() external { + TimelockController timelock = TimelockController(payable(deployPinnedTimelock())); + assertEq(LibTimelockInvariants.TIMELOCK_PROPOSER_ROLE, timelock.PROPOSER_ROLE()); + assertEq(LibTimelockInvariants.TIMELOCK_EXECUTOR_ROLE, timelock.EXECUTOR_ROLE()); + assertEq(LibTimelockInvariants.TIMELOCK_CANCELLER_ROLE, timelock.CANCELLER_ROLE()); + assertEq(LibTimelockInvariants.TIMELOCK_DEFAULT_ADMIN_ROLE, timelock.DEFAULT_ADMIN_ROLE()); + } + + /// @notice The pinned delay is 48 hours and the pinned deploy carries it. + function testMinDelayPin() external { + TimelockController timelock = TimelockController(payable(deployPinnedTimelock())); + assertEq(LibTimelockInvariants.TIMELOCK_MIN_DELAY, 48 hours); + assertEq(timelock.getMinDelay(), LibTimelockInvariants.TIMELOCK_MIN_DELAY); + } + + /// @notice An address without code is rejected before any call into it. + function testAssertRejectsUndeployed() external { + address missing = LibTimelockInvariants.expectedTimelockAddress(SAFE); + vm.expectRevert(abi.encodeWithSelector(TimelockNotDeployed.selector, missing)); + harness.callAssertTimelockState(missing, SAFE); + } + + /// @notice Alien bytecode at the timelock address is rejected by the + /// codehash pin before any role read is trusted. + function testAssertRejectsWrongCodehash() external { + address timelock = deployPinnedTimelock(); + vm.etch(timelock, hex"600160005260206000f3"); + vm.expectRevert( + abi.encodeWithSelector( + TimelockCodehashMismatch.selector, + timelock, + LibTimelockInvariants.timelockRuntimeCodehash(), + timelock.codehash + ) + ); + harness.callAssertTimelockState(timelock, SAFE); + } + + /// @notice A timelock deployed with a different delay is rejected: the + /// runtime bytecode is identical (delay is storage, not code), so only + /// the `getMinDelay` pin catches it. + function testAssertRejectsWrongMinDelay() external { + address[] memory principals = new address[](1); + principals[0] = SAFE; + TimelockController wrongDelay = new TimelockController(1 hours, principals, principals, address(0)); + vm.expectRevert( + abi.encodeWithSelector( + TimelockMinDelayMismatch.selector, + address(wrongDelay), + LibTimelockInvariants.TIMELOCK_MIN_DELAY, + 1 hours + ) + ); + harness.callAssertTimelockState(address(wrongDelay), SAFE); + } + + /// @notice A timelock whose proposer is not the Safe is rejected with + /// the exact missing `(role, account)` pair. + function testAssertRejectsMissingProposerRole() external { + address[] memory otherProposer = new address[](1); + otherProposer[0] = address(0xBEEF); + address[] memory executors = new address[](1); + executors[0] = SAFE; + TimelockController wrongProposer = + new TimelockController(LibTimelockInvariants.TIMELOCK_MIN_DELAY, otherProposer, executors, address(0)); + vm.expectRevert( + abi.encodeWithSelector( + TimelockMissingRole.selector, address(wrongProposer), LibTimelockInvariants.TIMELOCK_PROPOSER_ROLE, SAFE + ) + ); + harness.callAssertTimelockState(address(wrongProposer), SAFE); + } + + /// @notice A zero-address executor grant — OZ's "execution open to + /// everyone" switch — is rejected. Simulated through the timelock's own + /// self-administration path, proving the pinned deploy COULD drift here + /// only via a (timelocked) governance action that this invariant would + /// then flag. + function testAssertRejectsOpenExecutorRole() external { + address timelock = deployPinnedTimelock(); + vm.prank(timelock); + IAccessControl(timelock).grantRole(LibTimelockInvariants.TIMELOCK_EXECUTOR_ROLE, address(0)); + vm.expectRevert( + abi.encodeWithSelector( + TimelockUnexpectedRole.selector, timelock, LibTimelockInvariants.TIMELOCK_EXECUTOR_ROLE, address(0) + ) + ); + harness.callAssertTimelockState(timelock, SAFE); + } + + /// @notice A Safe holding `DEFAULT_ADMIN_ROLE` is rejected — root admin + /// on the proposer would let it re-grant roles instantly, bypassing the + /// delay entirely. Simulated by deploying with the optional constructor + /// admin set to the Safe. + function testAssertRejectsSafeAsRootAdmin() external { + address[] memory principals = new address[](1); + principals[0] = SAFE; + TimelockController adminned = + new TimelockController(LibTimelockInvariants.TIMELOCK_MIN_DELAY, principals, principals, SAFE); + vm.expectRevert( + abi.encodeWithSelector( + TimelockUnexpectedRole.selector, + address(adminned), + LibTimelockInvariants.TIMELOCK_DEFAULT_ADMIN_ROLE, + SAFE + ) + ); + harness.callAssertTimelockState(address(adminned), SAFE); + } + + /// @notice Chain selection returns the per-chain pin for the supported + /// chains and reverts (never falls back) for anything else. + function testTimelockForChainId() external { + assertEq( + LibTimelockInvariants.timelockForChainId(LibSafeInvariants.BASE_CHAIN_ID), + LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK + ); + assertEq( + LibTimelockInvariants.timelockForChainId(LibSafeInvariants.ETHEREUM_CHAIN_ID), + LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK_ETHEREUM + ); + vm.expectRevert(abi.encodeWithSelector(UnsupportedChainForGovernanceTimelock.selector, uint256(31337))); + harness.callTimelockForChainId(31337); + } + + /// @notice The hydration contract for the per-chain pins: while a pin is + /// a placeholder it must be zero; once hydrated it must equal the + /// derived Zoltu address for that chain's Safe. Both directions are + /// asserted here so the post-deploy pin PR turns this from the + /// placeholder branch to the equality branch with no test change. + function testPinsMatchDerivedAddressesOnceHydrated() external pure { + address basePin = LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK; + if (basePin != address(0)) { + assertEq(basePin, LibTimelockInvariants.expectedTimelockAddress(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE)); + } + address ethereumPin = LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK_ETHEREUM; + if (ethereumPin != address(0)) { + assertEq( + ethereumPin, + LibTimelockInvariants.expectedTimelockAddress(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM) + ); + } + } +} diff --git a/test/src/lib/LibTimelockInvariantsHarness.sol b/test/src/lib/LibTimelockInvariantsHarness.sol new file mode 100644 index 00000000..d733142d --- /dev/null +++ b/test/src/lib/LibTimelockInvariantsHarness.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibTimelockInvariants} from "../../../src/lib/LibTimelockInvariants.sol"; + +/// @notice External-call harness around `LibTimelockInvariants`'s internal +/// asserts so `vm.expectRevert` can catch their typed errors — +/// library-internal reverts inline, and `expectRevert` only sees reverts from +/// a lower call depth than the cheatcode itself. +contract LibTimelockInvariantsHarness { + function callAssertTimelockState(address timelock, address proposerExecutorSafe) external view { + LibTimelockInvariants.assertTimelockState(timelock, proposerExecutorSafe); + } + + function callTimelockForChainId(uint256 chainId) external pure returns (address) { + return LibTimelockInvariants.timelockForChainId(chainId); + } +} From 7b26cf6dbf94b903468912f9b122337e7b79a26b Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 10 Aug 2026 08:47:58 +0000 Subject: [PATCH 02/17] feat(lib): HyperEVM governance-timelock pin and chain arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HyperEVM carries 29 live production tokens, so it is governed on the same terms as Base and Ethereum rather than as a follow-up. Adds the per-chain timelock pin placeholder and the timelockForChainId arm, plus the derived-address assertion for it and an explicit test that all three pinned chains resolve. Note the HyperEVM Safe shares Ethereum's address, and the timelock constructor embeds only that Safe, so the Zoltu-derived timelock address is identical on those two chains. That is correct — same policy, same init code, same CREATE2 address — and the pins stay separate constants because the deploy is per-chain. --- src/lib/LibTimelockInvariants.sol | 20 ++++++++++++++++++ test/src/lib/LibTimelockInvariants.t.sol | 27 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/lib/LibTimelockInvariants.sol b/src/lib/LibTimelockInvariants.sol index fc409c7c..a02cb14e 100644 --- a/src/lib/LibTimelockInvariants.sol +++ b/src/lib/LibTimelockInvariants.sol @@ -132,6 +132,23 @@ library LibTimelockInvariants { /// Zoltu-derived address differs per chain by construction. address internal constant STOX_GOVERNANCE_TIMELOCK_ETHEREUM = address(0); + /// @notice The ST0x governance timelock on **HyperEVM**. + /// + /// **PLACEHOLDER** (`address(0)`) until the + /// `20260729-deploy-governance-timelock` broadcast executes on HyperEVM + /// and the post-execution pin PR hydrates this constant. HyperEVM + /// carries 29 live production tokens, so it is governed on exactly the + /// same terms as Base and Ethereum — not a follow-up. + /// @dev HyperEVM's token-owner Safe shares Ethereum's ADDRESS + /// (`STOX_TOKEN_OWNER_SAFE_HYPEREVM == STOX_TOKEN_OWNER_SAFE_ETHEREUM`), + /// and the timelock's constructor embeds only that Safe — so the + /// Zoltu-derived address is IDENTICAL on the two chains. That is + /// correct and expected (same policy, same init code, same CREATE2 + /// address), but it means the two pins will read the same value once + /// hydrated; they are kept as separate constants because the DEPLOY is + /// per-chain and either could diverge if a chain's Safe ever moves. + address internal constant STOX_GOVERNANCE_TIMELOCK_HYPEREVM = address(0); + /// @notice **PLACEHOLDER** for a dedicated canceller principal (a /// separate key or Safe that can veto a scheduled operation during the /// delay window without being able to propose or execute). Undecided: @@ -156,6 +173,9 @@ library LibTimelockInvariants { if (chainId == LibSafeInvariants.ETHEREUM_CHAIN_ID) { return STOX_GOVERNANCE_TIMELOCK_ETHEREUM; } + if (chainId == LibSafeInvariants.HYPEREVM_CHAIN_ID) { + return STOX_GOVERNANCE_TIMELOCK_HYPEREVM; + } revert UnsupportedChainForGovernanceTimelock(chainId); } diff --git a/test/src/lib/LibTimelockInvariants.t.sol b/test/src/lib/LibTimelockInvariants.t.sol index de9cf537..a4396237 100644 --- a/test/src/lib/LibTimelockInvariants.t.sol +++ b/test/src/lib/LibTimelockInvariants.t.sol @@ -214,5 +214,32 @@ contract LibTimelockInvariantsTest is Test { LibTimelockInvariants.expectedTimelockAddress(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM) ); } + address hyperevmPin = LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK_HYPEREVM; + if (hyperevmPin != address(0)) { + assertEq( + hyperevmPin, + LibTimelockInvariants.expectedTimelockAddress(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_HYPEREVM) + ); + } + } + + /// @notice Every chain with a pinned token-owner Safe resolves through + /// `timelockForChainId` rather than reverting. The chain-coverage guard + /// in `GovernanceTimelockMigration.t.sol` enforces this against the + /// live Safe map; this pins the three chains explicitly so a dropped + /// arm fails here too, next to the constants it would have dropped. + function testEveryPinnedChainResolves() external pure { + assertEq( + LibTimelockInvariants.timelockForChainId(LibSafeInvariants.BASE_CHAIN_ID), + LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK + ); + assertEq( + LibTimelockInvariants.timelockForChainId(LibSafeInvariants.ETHEREUM_CHAIN_ID), + LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK_ETHEREUM + ); + assertEq( + LibTimelockInvariants.timelockForChainId(LibSafeInvariants.HYPEREVM_CHAIN_ID), + LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK_HYPEREVM + ); } } From 218be4b036176724c09820d16364824111fa4541 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 10 Aug 2026 10:00:18 +0000 Subject: [PATCH 03/17] fix(test): pin the grant map at 16 entries after the restack merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin-holder parameterisation was written against a 13-entry map; the multichain stack underneath now carries the three additional service-signer rows from #280, so the merged map is 16. The structure test still asserted 13 and failed the restacked branch. Widens it to 16 and asserts the new rows explicitly: the additional signer's three action roles are OPERATIONAL, so they must track the signer regardless of who holds the _ADMIN slice — the property that keeps the timelock migration from ever moving them. --- test/src/lib/LibAuthoriserInvariants.t.sol | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/src/lib/LibAuthoriserInvariants.t.sol b/test/src/lib/LibAuthoriserInvariants.t.sol index cca25957..7a374725 100644 --- a/test/src/lib/LibAuthoriserInvariants.t.sol +++ b/test/src/lib/LibAuthoriserInvariants.t.sol @@ -91,7 +91,7 @@ contract LibAuthoriserInvariantsTest is Test { address safe = address(0x5AFE); address timelock = address(0x7135); RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(safe, timelock); - assertEq(grants.length, 13); + assertEq(grants.length, 16); for (uint256 i = 0; i < 7; i++) { assertEq(grants[i].grantee, timelock, "admin entries must track adminHolder"); } @@ -101,6 +101,16 @@ contract LibAuthoriserInvariantsTest is Test { for (uint256 i = 10; i < 13; i++) { assertEq(grants[i].grantee, safe, "operational Safe entries must track the Safe"); } + // The additional service signer's three action roles are operational, + // not admin: they must track the signer regardless of who holds the + // `_ADMIN` slice, so the timelock migration never moves them. + for (uint256 i = 13; i < 16; i++) { + assertEq( + grants[i].grantee, + LibAuthoriserInvariants.GRANTEE_SERVICE_3D0C, + "additional service signer entries must be independent of adminHolder" + ); + } // The two-arg overload is the adminHolder == Safe collapse. RoleGrant[] memory collapsed = LibAuthoriserInvariants.expectedGrants(safe); From 2ef05bf412a7e348a07149a1b9672ed4eee23c6c Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 10 Aug 2026 10:25:45 +0000 Subject: [PATCH 04/17] pin(timelock): hydrate the deployed Base governance timelock Deployed by 20260729-deploy-governance-timelock (Base, run 31378549555) at 0xA34b2968A8B480440B218B6768Ad700FC81379b3. Verified on-chain: 48h min delay, timelock self-administers, Safe holds proposer/canceller/executor, deploy key holds nothing. Equals expectedTimelockAddress(Base Safe), which the structure test asserts. --- src/lib/LibTimelockInvariants.sol | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/lib/LibTimelockInvariants.sol b/src/lib/LibTimelockInvariants.sol index a02cb14e..1872236f 100644 --- a/src/lib/LibTimelockInvariants.sol +++ b/src/lib/LibTimelockInvariants.sol @@ -115,13 +115,12 @@ library LibTimelockInvariants { /// itself (self-administration) and nobody else. bytes32 internal constant TIMELOCK_DEFAULT_ADMIN_ROLE = bytes32(0); - /// @notice The ST0x governance timelock on **Base**. - /// - /// **PLACEHOLDER** (`address(0)`) until the - /// `20260729-deploy-governance-timelock` broadcast executes on Base and - /// the post-execution pin PR hydrates this constant with the logged - /// address (which must equal `expectedTimelockAddress(Base Safe)`). - address internal constant STOX_GOVERNANCE_TIMELOCK = address(0); + /// @notice The ST0x governance timelock on **Base**. Deployed by the + /// `20260729-deploy-governance-timelock` broadcast; equals + /// `expectedTimelockAddress(STOX_TOKEN_OWNER_SAFE)`, which + /// `testPinsMatchDerivedAddressesOnceHydrated` asserts. + /// https://basescan.org/address/0xa34b2968a8b480440b218b6768ad700fc81379b3 + address internal constant STOX_GOVERNANCE_TIMELOCK = address(0xA34b2968A8B480440B218B6768Ad700FC81379b3); /// @notice The ST0x governance timelock on **Ethereum mainnet**. /// From 33665655827a58129a1205695ade2fe0a62cd386 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 10 Aug 2026 11:17:55 +0000 Subject: [PATCH 05/17] revert(pin): unpin the Base timelock - optimizer change moved the address The 0xA34b... deployment was broadcast when this branch still compiled at optimizer_runs=5000. Commit 0a8c62c lowered it to 2000 (so the H01 fix fits EIP-170), which changes TimelockController's creation bytecode and therefore its CREATE2 address: current source derives 0xdb4b2187... instead. Pinning an address the source can no longer reproduce is worse than not pinning, so the pin goes back to address(0) until a deploy runs against settled compiler settings. The 0xA34b... instance holds no power (nothing was migrated to it) and is simply abandoned. TimelockPinMismatch, added alongside the multi-chain deploy, is what caught this. --- src/lib/LibTimelockInvariants.sol | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/lib/LibTimelockInvariants.sol b/src/lib/LibTimelockInvariants.sol index 1872236f..c04f9db6 100644 --- a/src/lib/LibTimelockInvariants.sol +++ b/src/lib/LibTimelockInvariants.sol @@ -115,12 +115,8 @@ library LibTimelockInvariants { /// itself (self-administration) and nobody else. bytes32 internal constant TIMELOCK_DEFAULT_ADMIN_ROLE = bytes32(0); - /// @notice The ST0x governance timelock on **Base**. Deployed by the - /// `20260729-deploy-governance-timelock` broadcast; equals - /// `expectedTimelockAddress(STOX_TOKEN_OWNER_SAFE)`, which - /// `testPinsMatchDerivedAddressesOnceHydrated` asserts. - /// https://basescan.org/address/0xa34b2968a8b480440b218b6768ad700fc81379b3 - address internal constant STOX_GOVERNANCE_TIMELOCK = address(0xA34b2968A8B480440B218B6768Ad700FC81379b3); + /// @notice The ST0x governance timelock on **Base**. + address internal constant STOX_GOVERNANCE_TIMELOCK = address(0); /// @notice The ST0x governance timelock on **Ethereum mainnet**. /// From f415edb872cfc2b00b038668290a6c928f05cd9b Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 11 Aug 2026 08:14:39 +0000 Subject: [PATCH 06/17] fix(lib): reject retained Safe _ADMIN copies under a distinct admin holder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assertExpectedGrants(authoriser, safe, adminHolder) asserted the seven _ADMIN roles onto the admin holder but accepted the Safe RETAINING its own copies alongside — a state where the Safe can grant or revoke action roles with zero delay, which is exactly the bypass the timelock migration exists to close. The gap mattered because the post-execution flip PR repoints the durable cron invariants at this overload while retiring the migration-window suite's exclusive-holder check, leaving the dual-holder state unasserted. Now, whenever the admin holder is distinct from the Safe, every map entry assigned to the admin holder is also asserted NOT held by the Safe, with a typed UnexpectedRetainedAdminGrant revert. The admin slice is identified structurally (grantee == adminHolder) rather than by a hardcoded count. The two-arg collapse (adminHolder == Safe) is unaffected. Resolves the open CodeRabbit thread on this surface; the dual-holder state the test previously blessed is now the rejection case. Co-Authored-By: Claude Fable 5 --- src/lib/LibAuthoriserInvariants.sol | 29 ++++++++++++++++++-- test/src/lib/LibAuthoriserInvariants.t.sol | 31 +++++++++++++++++----- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/lib/LibAuthoriserInvariants.sol b/src/lib/LibAuthoriserInvariants.sol index 75b85007..18aa38fb 100644 --- a/src/lib/LibAuthoriserInvariants.sol +++ b/src/lib/LibAuthoriserInvariants.sol @@ -36,6 +36,16 @@ error UnexpectedDefaultAdmin(address authoriser, address holder); /// @param actual The codehash observed on-chain. error AuthoriserImplCodehashMismatch(address authoriser, bytes32 expected, bytes32 actual); +/// @notice The token-owner Safe still holds an `_ADMIN` role that the map +/// assigns to a distinct admin holder. The `_ADMIN` slice must be held +/// EXCLUSIVELY: a Safe that keeps a copy can grant or revoke action roles +/// directly, bypassing the delay the admin holder (the governance timelock) +/// exists to impose. +/// @param authoriser The authoriser inspected. +/// @param role The `_ADMIN` role the Safe unexpectedly retains. +/// @param holder The Safe retaining it. +error UnexpectedRetainedAdminGrant(address authoriser, bytes32 role, address holder); + /// @title LibAuthoriserInvariants /// @notice Reusable invariants for the ST0x production authoriser on Base: /// the grantee constants and the single master `(role, grantee)` map every @@ -202,8 +212,11 @@ library LibAuthoriserInvariants { /// @notice Assert every `(role, grantee)` pair from /// `expectedGrants(tokenOwnerSafe, adminHolder)` is held on the supplied - /// authoriser, and that no named principal — the Safe, the admin holder, - /// the service signer — holds `DEFAULT_ADMIN_ROLE`. This is the + /// authoriser, that no named principal — the Safe, the admin holder, + /// the service signer — holds `DEFAULT_ADMIN_ROLE`, and that when the + /// admin holder is distinct from the Safe, the Safe retains NO `_ADMIN` + /// entry (exclusive holding — a retained copy would let the Safe mutate + /// the grant map without the admin holder's delay). This is the /// post-timelock-migration assertion surface: the migration script's /// post-state and the migration-window invariants call it with the /// governance timelock as `adminHolder`. @@ -234,6 +247,18 @@ library LibAuthoriserInvariants { revert ExpectedGrantMissing(authoriser, grants[i].role, grants[i].grantee); } } + // Exclusive `_ADMIN` holding: with a distinct admin holder, a Safe + // that retains any admin entry can grant or revoke action roles + // directly, bypassing the delay the admin holder exists to impose. + // The admin slice is identified structurally (the entries the map + // assigns to the admin holder) rather than by a hardcoded count. + if (adminHolder != tokenOwnerSafe) { + for (uint256 i = 0; i < grants.length; i++) { + if (grants[i].grantee == adminHolder && acl.hasRole(grants[i].role, tokenOwnerSafe)) { + revert UnexpectedRetainedAdminGrant(authoriser, grants[i].role, tokenOwnerSafe); + } + } + } } /// @notice Full authoriser-side invariant bundle against the current diff --git a/test/src/lib/LibAuthoriserInvariants.t.sol b/test/src/lib/LibAuthoriserInvariants.t.sol index 7a374725..f17a98d7 100644 --- a/test/src/lib/LibAuthoriserInvariants.t.sol +++ b/test/src/lib/LibAuthoriserInvariants.t.sol @@ -9,6 +9,7 @@ import { RoleGrant, ExpectedGrantMissing, UnexpectedDefaultAdmin, + UnexpectedRetainedAdminGrant, AuthoriserImplCodehashMismatch } from "../../../src/lib/LibAuthoriserInvariants.sol"; import {LibProdDeployV4} from "../../../src/generated/LibProdDeployV4.sol"; @@ -122,10 +123,12 @@ contract LibAuthoriserInvariantsTest is Test { } } - /// @notice `assertExpectedGrants(authoriser, safe, adminHolder)` passes - /// once the live clone's seven `_ADMIN` roles are mocked onto the admin - /// holder — the exact post-timelock-migration shape — and pinpoints the - /// first missing `_ADMIN` grant when the mock is absent. + /// @notice `assertExpectedGrants(authoriser, safe, adminHolder)` demands + /// the exact post-timelock-migration shape: it pinpoints the first + /// missing `_ADMIN` grant while the admin holder holds nothing, rejects + /// the dual-holder state where the Safe retains an `_ADMIN` copy + /// alongside the admin holder (an instant delay bypass), and passes only + /// once the seven `_ADMIN` roles sit exclusively on the admin holder. function testAssertExpectedGrantsWithDistinctAdminHolder() external { selectBaseFork(); address clone = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; @@ -139,9 +142,10 @@ contract LibAuthoriserInvariantsTest is Test { vm.expectRevert(abi.encodeWithSelector(ExpectedGrantMissing.selector, clone, grants[0].role, timelock)); harness.callAssertExpectedGrants(clone, safe, timelock); - // Mock the seven `_ADMIN` grants onto the timelock — the operational - // entries are already live on the fork — and the full assertion - // passes. + // Mock the seven `_ADMIN` grants onto the timelock. The live fork's + // Safe still holds its `_ADMIN` copies, so this is the dual-holder + // state — the Safe could still mutate the grant map without the + // timelock's delay — and the assertion must reject it. for (uint256 i = 0; i < 7; i++) { vm.mockCall( clone, @@ -149,6 +153,19 @@ contract LibAuthoriserInvariantsTest is Test { abi.encode(true) ); } + vm.expectRevert(abi.encodeWithSelector(UnexpectedRetainedAdminGrant.selector, clone, grants[0].role, safe)); + harness.callAssertExpectedGrants(clone, safe, timelock); + + // Mock the Safe's seven `_ADMIN` copies away — the renounces landing + // — and the full assertion passes: exclusive admin holding, with the + // operational entries already live on the fork. + for (uint256 i = 0; i < 7; i++) { + vm.mockCall( + clone, + abi.encodeWithSelector(IAccessControl.hasRole.selector, grants[i].role, safe), + abi.encode(false) + ); + } harness.callAssertExpectedGrants(clone, safe, timelock); } } From 1b6759726c650a04ff0b0104d7fbc5a32884e813 Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 11 Aug 2026 08:22:49 +0000 Subject: [PATCH 07/17] style: forge fmt collapse on the renounce mockCall Co-Authored-By: Claude Fable 5 --- test/src/lib/LibAuthoriserInvariants.t.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/src/lib/LibAuthoriserInvariants.t.sol b/test/src/lib/LibAuthoriserInvariants.t.sol index f17a98d7..7a7d02d1 100644 --- a/test/src/lib/LibAuthoriserInvariants.t.sol +++ b/test/src/lib/LibAuthoriserInvariants.t.sol @@ -161,9 +161,7 @@ contract LibAuthoriserInvariantsTest is Test { // operational entries already live on the fork. for (uint256 i = 0; i < 7; i++) { vm.mockCall( - clone, - abi.encodeWithSelector(IAccessControl.hasRole.selector, grants[i].role, safe), - abi.encode(false) + clone, abi.encodeWithSelector(IAccessControl.hasRole.selector, grants[i].role, safe), abi.encode(false) ); } harness.callAssertExpectedGrants(clone, safe, timelock); From 0f09c0a2919cf6acdd3606afd6b5d4817bc8fac8 Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 11 Aug 2026 08:48:43 +0000 Subject: [PATCH 08/17] fix(lib): slice retained-admin check positionally, not by grantee address Matching map entries by grantee == adminHolder mis-slices when the admin holder aliases another grantee (e.g. a service signer as adminHolder would drag its operational DEPOSIT/WITHDRAW/CERTIFY entries into the check and spuriously flag the Safe's legitimate copies). The admin slice is the map's LEADING entries, a position the parameterisation test already pins, so the check now walks grants[0..ADMIN_ROLE_COUNT) with the count as a lib constant. Co-Authored-By: Claude Fable 5 --- src/lib/LibAuthoriserInvariants.sol | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/lib/LibAuthoriserInvariants.sol b/src/lib/LibAuthoriserInvariants.sol index 18aa38fb..ea6a74cf 100644 --- a/src/lib/LibAuthoriserInvariants.sol +++ b/src/lib/LibAuthoriserInvariants.sol @@ -82,6 +82,12 @@ library LibAuthoriserInvariants { /// reverts `UnexpectedDefaultAdmin` if any pinned grantee holds it. bytes32 internal constant DEFAULT_ADMIN_ROLE = bytes32(0); + /// @notice The number of `_ADMIN` roles in the grant map — its LEADING + /// slice, so `expectedGrants(...)[0..ADMIN_ROLE_COUNT)` are exactly the + /// entries that track the admin holder. The slice's position and length + /// are pinned by `testExpectedGrantsAdminHolderParameterisation`. + uint256 internal constant ADMIN_ROLE_COUNT = 7; + /// @notice The ST0x token-owner Safe — holds every `_ADMIN` role on the /// production authoriser and was later granted DEPOSIT, WITHDRAW and /// CERTIFY as a privileged operator. Identical to @@ -250,11 +256,12 @@ library LibAuthoriserInvariants { // Exclusive `_ADMIN` holding: with a distinct admin holder, a Safe // that retains any admin entry can grant or revoke action roles // directly, bypassing the delay the admin holder exists to impose. - // The admin slice is identified structurally (the entries the map - // assigns to the admin holder) rather than by a hardcoded count. + // The slice is positional (the map's leading `ADMIN_ROLE_COUNT` + // entries) rather than matched by grantee address, which would + // mis-slice if the admin holder aliased another grantee. if (adminHolder != tokenOwnerSafe) { - for (uint256 i = 0; i < grants.length; i++) { - if (grants[i].grantee == adminHolder && acl.hasRole(grants[i].role, tokenOwnerSafe)) { + for (uint256 i = 0; i < ADMIN_ROLE_COUNT; i++) { + if (acl.hasRole(grants[i].role, tokenOwnerSafe)) { revert UnexpectedRetainedAdminGrant(authoriser, grants[i].role, tokenOwnerSafe); } } From b0f438f7ca2f5fc3efd503cd10e67c4ffc04b131 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 29 Jul 2026 06:54:53 +0000 Subject: [PATCH 09/17] feat(lib): LibTimelockInvariants + authoriser grant-map admin-holder parameterisation New src/lib/LibTimelockInvariants.sol: constants and invariant assertions for the ST0x governance timelock (unmodified pre-audited OZ TimelockController from the version-locked 5.6.1 soldeer dep): - TIMELOCK_MIN_DELAY = 48 hours, role-hash mirrors pinned against the live OZ getters. - Per-chain address pins (Base + Ethereum), placeholders until the deploy broadcast executes and a pin PR hydrates them; timelockForChainId with a typed revert on unsupported chains (no cross-chain fallback). - timelockInitCode/expectedTimelockAddress: the exact creation code the deploy broadcasts (Safe as sole proposer+canceller+executor, admin = 0 so the timelock self-administers from birth and the deploy key is never granted anything) and its Zoltu CREATE2 address, derived in-source and validated against the real factory bytecode in tests. - assertTimelockState: codehash + minDelay pins, Safe role set, timelock self-administration, and negative checks (Safe must not hold root admin, no OZ open-role zero-address grants). - TIMELOCK_CANCELLER placeholder for a future dedicated canceller principal; asserted once hydrated. LibAuthoriserInvariants: expectedGrants/assertExpectedGrants gain an adminHolder parameterisation so the post-timelock-migration grant map (the seven _ADMIN roles on the timelock, operational roles unchanged) is expressible through the same single master map; existing overloads are exact collapses with adminHolder = Safe. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RquYKmoEVSfuVHy8kwn1yT --- src/lib/LibAuthoriserInvariants.sol | 60 +++- src/lib/LibTimelockInvariants.sol | 299 ++++++++++++++++++ test/src/lib/LibAuthoriserInvariants.t.sol | 62 ++++ .../lib/LibAuthoriserInvariantsHarness.sol | 4 + test/src/lib/LibTimelockInvariants.t.sol | 218 +++++++++++++ test/src/lib/LibTimelockInvariantsHarness.sol | 19 ++ 6 files changed, 653 insertions(+), 9 deletions(-) create mode 100644 src/lib/LibTimelockInvariants.sol create mode 100644 test/src/lib/LibTimelockInvariants.t.sol create mode 100644 test/src/lib/LibTimelockInvariantsHarness.sol diff --git a/src/lib/LibAuthoriserInvariants.sol b/src/lib/LibAuthoriserInvariants.sol index 931868aa..75b85007 100644 --- a/src/lib/LibAuthoriserInvariants.sol +++ b/src/lib/LibAuthoriserInvariants.sol @@ -113,20 +113,44 @@ library LibAuthoriserInvariants { /// grantee slots. /// @return grants The `(role, grantee)` pairs for that chain. function expectedGrants(address tokenOwnerSafe) internal pure returns (RoleGrant[] memory grants) { + grants = expectedGrants(tokenOwnerSafe, tokenOwnerSafe); + } + + /// @notice The `(role, grantee)` map parameterised on BOTH the chain's + /// token-owner Safe AND the holder of the seven `_ADMIN` roles. Before + /// the governance-timelock migration the Safe is the admin holder (the + /// two-arg call sites above collapse the parameters); after the + /// migration the seven `_ADMIN` roles sit on the governance timelock + /// while the Safe keeps its three direct action roles. This overload is + /// the single map both states are expressed through, so the migration + /// script's post-state and the post-migration invariant surface assert + /// the same structure the pre-migration consumers do. + /// @param tokenOwnerSafe The chain's token-owner Safe filling the + /// operational Safe grantee slots. + /// @param adminHolder The holder of the seven `_ADMIN` roles (the Safe + /// pre-migration, the governance timelock post-migration). + /// @return grants The `(role, grantee)` pairs for that chain. + function expectedGrants(address tokenOwnerSafe, address adminHolder) + internal + pure + returns (RoleGrant[] memory grants) + { grants = new RoleGrant[](16); - // Init grants (block 41715184 on Base) — Safe receives every `_ADMIN`. - grants[0] = RoleGrant(keccak256("DEPOSIT_ADMIN"), tokenOwnerSafe); - grants[1] = RoleGrant(keccak256("WITHDRAW_ADMIN"), tokenOwnerSafe); - grants[2] = RoleGrant(keccak256("CERTIFY_ADMIN"), tokenOwnerSafe); - grants[3] = RoleGrant(keccak256("CONFISCATE_SHARES_ADMIN"), tokenOwnerSafe); - grants[4] = RoleGrant(keccak256("CONFISCATE_RECEIPT_ADMIN"), tokenOwnerSafe); + // Init grants (block 41715184 on Base) — the admin holder receives + // every `_ADMIN` (the Safe at init; the governance timelock once the + // timelock migration executes). + grants[0] = RoleGrant(keccak256("DEPOSIT_ADMIN"), adminHolder); + grants[1] = RoleGrant(keccak256("WITHDRAW_ADMIN"), adminHolder); + grants[2] = RoleGrant(keccak256("CERTIFY_ADMIN"), adminHolder); + grants[3] = RoleGrant(keccak256("CONFISCATE_SHARES_ADMIN"), adminHolder); + grants[4] = RoleGrant(keccak256("CONFISCATE_RECEIPT_ADMIN"), adminHolder); // The two corporate-action admins the 0.1.1 impl adds. On Base the // clone-deploy broadcast transferred them to the Safe alongside the // other five and renounced them from the deploy key. - grants[5] = RoleGrant(keccak256("SCHEDULE_CORPORATE_ACTION_ADMIN"), tokenOwnerSafe); - grants[6] = RoleGrant(keccak256("CANCEL_CORPORATE_ACTION_ADMIN"), tokenOwnerSafe); + grants[5] = RoleGrant(keccak256("SCHEDULE_CORPORATE_ACTION_ADMIN"), adminHolder); + grants[6] = RoleGrant(keccak256("CANCEL_CORPORATE_ACTION_ADMIN"), adminHolder); // Service EOA provisioned at blocks 41797262, 41797281, 41797297 (Base). grants[7] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_1C66); @@ -173,6 +197,21 @@ library LibAuthoriserInvariants { /// @param tokenOwnerSafe The chain's token-owner Safe filling the Safe /// grantee slots. function assertExpectedGrants(address authoriser, address tokenOwnerSafe) internal view { + assertExpectedGrants(authoriser, tokenOwnerSafe, tokenOwnerSafe); + } + + /// @notice Assert every `(role, grantee)` pair from + /// `expectedGrants(tokenOwnerSafe, adminHolder)` is held on the supplied + /// authoriser, and that no named principal — the Safe, the admin holder, + /// the service signer — holds `DEFAULT_ADMIN_ROLE`. This is the + /// post-timelock-migration assertion surface: the migration script's + /// post-state and the migration-window invariants call it with the + /// governance timelock as `adminHolder`. + /// @param authoriser The authoriser to validate. + /// @param tokenOwnerSafe The chain's token-owner Safe filling the + /// operational Safe grantee slots. + /// @param adminHolder The holder of the seven `_ADMIN` roles. + function assertExpectedGrants(address authoriser, address tokenOwnerSafe, address adminHolder) internal view { IAccessControl acl = IAccessControl(authoriser); // No pinned grantee holds DEFAULT_ADMIN_ROLE: the hierarchy admins each // action role by its own `_ADMIN`, so a root-admin holder would @@ -180,13 +219,16 @@ library LibAuthoriserInvariants { if (acl.hasRole(DEFAULT_ADMIN_ROLE, tokenOwnerSafe)) { revert UnexpectedDefaultAdmin(authoriser, tokenOwnerSafe); } + if (acl.hasRole(DEFAULT_ADMIN_ROLE, adminHolder)) { + revert UnexpectedDefaultAdmin(authoriser, adminHolder); + } if (acl.hasRole(DEFAULT_ADMIN_ROLE, GRANTEE_SERVICE_1C66)) { revert UnexpectedDefaultAdmin(authoriser, GRANTEE_SERVICE_1C66); } if (acl.hasRole(DEFAULT_ADMIN_ROLE, GRANTEE_SERVICE_3D0C)) { revert UnexpectedDefaultAdmin(authoriser, GRANTEE_SERVICE_3D0C); } - RoleGrant[] memory grants = expectedGrants(tokenOwnerSafe); + RoleGrant[] memory grants = expectedGrants(tokenOwnerSafe, adminHolder); for (uint256 i = 0; i < grants.length; i++) { if (!acl.hasRole(grants[i].role, grants[i].grantee)) { revert ExpectedGrantMissing(authoriser, grants[i].role, grants[i].grantee); diff --git a/src/lib/LibTimelockInvariants.sol b/src/lib/LibTimelockInvariants.sol new file mode 100644 index 00000000..fc409c7c --- /dev/null +++ b/src/lib/LibTimelockInvariants.sol @@ -0,0 +1,299 @@ +// 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 {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; +import {LibSafeInvariants} from "./LibSafeInvariants.sol"; + +/// @notice No ST0x governance timelock address is pinned for the active +/// chain. Deliberately a typed revert rather than a silent fallback to +/// another chain's timelock: asserting or migrating against the wrong +/// chain's timelock is the catastrophic failure this selector exists to +/// prevent. +/// @param chainId The chain id with no pinned governance timelock. +error UnsupportedChainForGovernanceTimelock(uint256 chainId); + +/// @notice The governance timelock has no runtime code. Either the pin is +/// stale, the deploy never ran on this chain, or the address was computed +/// from different constructor arguments than the deployed instance. +/// @param timelock The timelock address that has no code. +error TimelockNotDeployed(address timelock); + +/// @notice The runtime codehash at the timelock address does not match the +/// codehash of the audited OZ `TimelockController` this repo compiles +/// against. The contract at the pin is not the expected timelock. +/// @param timelock The timelock address inspected. +/// @param expected The expected `TimelockController` runtime codehash. +/// @param actual The codehash observed on-chain. +error TimelockCodehashMismatch(address timelock, bytes32 expected, bytes32 actual); + +/// @notice The timelock's `getMinDelay()` does not match the pinned +/// `TIMELOCK_MIN_DELAY`. Either the deploy used different constructor +/// arguments or a scheduled `updateDelay` has executed without the pin +/// being updated in the same operational window. +/// @param timelock The timelock inspected. +/// @param expected The pinned minimum delay in seconds. +/// @param actual The minimum delay reported by the live timelock. +error TimelockMinDelayMismatch(address timelock, uint256 expected, uint256 actual); + +/// @notice An account that must hold a role on the governance timelock does +/// not hold it. Surfaces the exact `(role, account)` pair that broke the +/// invariant. +/// @param timelock The timelock inspected. +/// @param role The role that should be held. +/// @param account The account that should hold the role. +error TimelockMissingRole(address timelock, bytes32 role, address account); + +/// @notice An account holds a role on the governance timelock that the +/// pinned configuration does not sanction — e.g. the zero address holding +/// `EXECUTOR_ROLE` (which OZ treats as "execution open to everyone") or the +/// proposer Safe holding `DEFAULT_ADMIN_ROLE` (an instant-bypass escalation +/// path around the delay). +/// @param timelock The timelock inspected. +/// @param role The role that must not be held. +/// @param account The account that must not hold the role. +error TimelockUnexpectedRole(address timelock, bytes32 role, address account); + +/// @title LibTimelockInvariants +/// @notice Reusable constants and invariant assertions for the ST0x +/// governance timelock: an UNMODIFIED, pre-audited OpenZeppelin +/// `TimelockController` (from the version-locked soldeer dependency this +/// repo compiles against) that sits between the token-owner Safe and the +/// privileged surfaces it governs. Post-migration the timelock is the +/// `owner()` of every production receipt vault and the sole holder of the +/// authoriser's seven `_ADMIN` roles, so every ownership action and every +/// grant-map change must be scheduled, wait out `TIMELOCK_MIN_DELAY`, and +/// only then execute. +/// +/// Role model (pinned by `assertTimelockState`): +/// - The chain's token-owner Safe holds `PROPOSER_ROLE`, `CANCELLER_ROLE` +/// and `EXECUTOR_ROLE` — it schedules, can cancel during the window, and +/// executes once the delay elapses. +/// - The timelock holds `DEFAULT_ADMIN_ROLE` on itself (OZ +/// self-administration): role changes — e.g. provisioning the dedicated +/// canceller once `TIMELOCK_CANCELLER` is decided — must themselves go +/// through the delay. +/// - Nobody else holds anything. In particular the deploy key holds no role +/// (the deploy passes `admin = address(0)`, so the constructor grants the +/// deployer nothing — there is nothing to revoke), and the zero address +/// holds no role (OZ's "open role" semantics stay disabled). +/// +/// @dev The timelock is deployed via the Zoltu deterministic factory, so its +/// address is a pure function of its creation code — computable in-source by +/// `expectedTimelockAddress`. The per-chain ADDRESS pins below are the +/// audit-trail constants scripts and invariants target; each is hydrated by +/// a post-deploy pin PR and must equal the derived address (a structure test +/// pins that equality once hydrated). Constructor arguments embed the +/// chain's Safe, so the timelock address is a per-chain deploy artifact — +/// same policy, different address per chain, exactly like the Safe itself. +library LibTimelockInvariants { + /// @notice The timelock's minimum delay in seconds: every governance + /// operation (vault ownership actions, authoriser grant-map changes) + /// must wait at least this long between `schedule` and `execute`. + /// 48 hours: long enough for depositors and integrating protocols to + /// observe a pending admin action and exit, short enough for routine + /// operations to remain practical. Changing it post-deploy is itself a + /// timelocked operation (`updateDelay` is self-administered) and must + /// update this pin in the same operational window. + uint256 internal constant TIMELOCK_MIN_DELAY = 48 hours; + + /// @notice OZ `TimelockController` proposer role. Mirrored from the OZ + /// source as a constant so invariant call sites don't need a deployed + /// instance to name the role; `LibTimelockInvariantsTest` pins each + /// mirror against the live getter. + bytes32 internal constant TIMELOCK_PROPOSER_ROLE = keccak256("PROPOSER_ROLE"); + + /// @notice OZ `TimelockController` executor role. + bytes32 internal constant TIMELOCK_EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE"); + + /// @notice OZ `TimelockController` canceller role. + bytes32 internal constant TIMELOCK_CANCELLER_ROLE = keccak256("CANCELLER_ROLE"); + + /// @notice OZ `AccessControl` root admin role — held by the timelock on + /// itself (self-administration) and nobody else. + bytes32 internal constant TIMELOCK_DEFAULT_ADMIN_ROLE = bytes32(0); + + /// @notice The ST0x governance timelock on **Base**. + /// + /// **PLACEHOLDER** (`address(0)`) until the + /// `20260729-deploy-governance-timelock` broadcast executes on Base and + /// the post-execution pin PR hydrates this constant with the logged + /// address (which must equal `expectedTimelockAddress(Base Safe)`). + address internal constant STOX_GOVERNANCE_TIMELOCK = address(0); + + /// @notice The ST0x governance timelock on **Ethereum mainnet**. + /// + /// **PLACEHOLDER** (`address(0)`) until the + /// `20260729-deploy-governance-timelock` broadcast executes on Ethereum + /// and the post-execution pin PR hydrates this constant. A distinct + /// per-chain address: the constructor embeds the chain's Safe, so the + /// Zoltu-derived address differs per chain by construction. + address internal constant STOX_GOVERNANCE_TIMELOCK_ETHEREUM = address(0); + + /// @notice **PLACEHOLDER** for a dedicated canceller principal (a + /// separate key or Safe that can veto a scheduled operation during the + /// delay window without being able to propose or execute). Undecided: + /// until it is chosen the token-owner Safe holds `CANCELLER_ROLE` (the + /// OZ constructor grants it alongside `PROPOSER_ROLE`). Provisioning a + /// dedicated canceller later is itself a timelocked operation — schedule + /// `grantRole(CANCELLER_ROLE, canceller)` on the timelock — and hydrates + /// this constant in the same operational window. `assertTimelockState` + /// starts asserting the grant once this pin is non-zero. + address internal constant TIMELOCK_CANCELLER = address(0); + + /// @notice The ST0x governance timelock address for the active chain, + /// selected by chain id — `address(0)` until that chain's timelock is + /// deployed and the pin hydrated. Reverts for any chain without a pin + /// rather than falling back to another chain's timelock. + /// @param chainId The active chain id (`block.chainid`). + /// @return timelock The chain's governance timelock pin. + function timelockForChainId(uint256 chainId) internal pure returns (address timelock) { + if (chainId == LibSafeInvariants.BASE_CHAIN_ID) { + return STOX_GOVERNANCE_TIMELOCK; + } + if (chainId == LibSafeInvariants.ETHEREUM_CHAIN_ID) { + return STOX_GOVERNANCE_TIMELOCK_ETHEREUM; + } + revert UnsupportedChainForGovernanceTimelock(chainId); + } + + /// @notice The exact creation code the governance timelock deploy + /// broadcasts: the audited OZ `TimelockController` creation bytecode + /// (compiled from the version-locked soldeer dependency under this + /// repo's single compiler profile) with the pinned constructor + /// arguments appended — `TIMELOCK_MIN_DELAY`, the supplied Safe as sole + /// proposer (which also makes it canceller) and sole executor, and + /// `admin = address(0)` so the timelock is self-administered from birth + /// and the deploy key is never granted anything. + /// @param proposerExecutorSafe The chain's token-owner Safe. + /// @return The creation code including constructor arguments. + function timelockInitCode(address proposerExecutorSafe) internal pure returns (bytes memory) { + address[] memory proposers = new address[](1); + proposers[0] = proposerExecutorSafe; + address[] memory executors = new address[](1); + executors[0] = proposerExecutorSafe; + return abi.encodePacked( + type(TimelockController).creationCode, abi.encode(TIMELOCK_MIN_DELAY, proposers, executors, address(0)) + ); + } + + /// @notice The deterministic address `timelockInitCode` deploys to via + /// the Zoltu factory: `CREATE2` with the factory as deployer and a zero + /// salt (the factory hardcodes it), so the address is a pure function + /// of the creation code. The deploy script asserts the landed address + /// equals this; once the per-chain pin is hydrated a structure test + /// pins `pin == expectedTimelockAddress(chain's Safe)`. + /// @param proposerExecutorSafe The chain's token-owner Safe. + /// @return The deterministic timelock address for that Safe. + function expectedTimelockAddress(address proposerExecutorSafe) internal pure returns (address) { + bytes32 initCodeHash = keccak256(timelockInitCode(proposerExecutorSafe)); + // forge-lint: disable-next-line(unsafe-typecast) + return address( + uint160( + uint256(keccak256(abi.encodePacked(hex"ff", LibRainDeploy.ZOLTU_FACTORY, bytes32(0), initCodeHash))) + ) + ); + } + + /// @notice The runtime codehash every deployed governance timelock must + /// carry: the hash of the OZ `TimelockController` runtime bytecode this + /// repo compiles. Derived in-source (the contract has no immutables, so + /// `type(...).runtimeCode` is the exact deployed shape) rather than + /// pinned as a literal, so a dependency or compiler-settings change + /// surfaces as a codehash mismatch against the live deployment instead + /// of silently moving the expectation. + /// @return The expected `TimelockController` runtime codehash. + function timelockRuntimeCodehash() internal pure returns (bytes32) { + return keccak256(type(TimelockController).runtimeCode); + } + + /// @notice Assert the governance timelock at `timelock` is in its pinned + /// state: deployed with the expected `TimelockController` runtime + /// codehash, `getMinDelay() == TIMELOCK_MIN_DELAY`, the Safe holds + /// proposer + canceller + executor, the timelock self-administers, and + /// no unsanctioned holder exists on the checked surface — the Safe does + /// not hold root admin, and the zero address holds nothing (OZ's "open + /// role" semantics stay disabled). Reverts with a typed error on the + /// first failure; returns silently otherwise. + /// @dev The negative checks are targeted assertions over the named + /// principals, not an exhaustive holder scan (a plain `AccessControl` + /// cannot enumerate members) — the same bound `LibAuthoriserInvariants` + /// documents for the authoriser's grant map. Once `TIMELOCK_CANCELLER` + /// is hydrated the dedicated canceller's grant is asserted too. + /// @param timelock The governance timelock to validate. + /// @param proposerExecutorSafe The chain's token-owner Safe expected to + /// hold the proposer / canceller / executor roles. + function assertTimelockState(address timelock, address proposerExecutorSafe) internal view { + if (timelock.code.length == 0) revert TimelockNotDeployed(timelock); + bytes32 expectedCodehash = timelockRuntimeCodehash(); + bytes32 actualCodehash = timelock.codehash; + if (actualCodehash != expectedCodehash) { + revert TimelockCodehashMismatch(timelock, expectedCodehash, actualCodehash); + } + + uint256 actualMinDelay = TimelockController(payable(timelock)).getMinDelay(); + if (actualMinDelay != TIMELOCK_MIN_DELAY) { + revert TimelockMinDelayMismatch(timelock, TIMELOCK_MIN_DELAY, actualMinDelay); + } + + IAccessControl acl = IAccessControl(timelock); + + // The Safe drives every stage of the operation lifecycle: schedule, + // cancel (until a dedicated canceller is provisioned), execute. + _assertHasRole(acl, timelock, TIMELOCK_PROPOSER_ROLE, proposerExecutorSafe); + _assertHasRole(acl, timelock, TIMELOCK_CANCELLER_ROLE, proposerExecutorSafe); + _assertHasRole(acl, timelock, TIMELOCK_EXECUTOR_ROLE, proposerExecutorSafe); + + // Self-administration: role changes go through the delay. + _assertHasRole(acl, timelock, TIMELOCK_DEFAULT_ADMIN_ROLE, timelock); + + // The Safe must NOT hold root admin — that would let it re-grant + // roles instantly, bypassing the delay the timelock exists to + // impose. + if (acl.hasRole(TIMELOCK_DEFAULT_ADMIN_ROLE, proposerExecutorSafe)) { + revert TimelockUnexpectedRole(timelock, TIMELOCK_DEFAULT_ADMIN_ROLE, proposerExecutorSafe); + } + + // OZ treats a zero-address grantee as "role open to everyone". + // Every lifecycle role must stay closed: open-proposer or + // open-canceller is an obvious takeover, and open-executor would + // let anyone execute (acceptable in some designs, but not the + // pinned one — execution stays with the Safe). + _assertNotOpenRole(acl, timelock, TIMELOCK_PROPOSER_ROLE); + _assertNotOpenRole(acl, timelock, TIMELOCK_CANCELLER_ROLE); + _assertNotOpenRole(acl, timelock, TIMELOCK_EXECUTOR_ROLE); + _assertNotOpenRole(acl, timelock, TIMELOCK_DEFAULT_ADMIN_ROLE); + + // Once the dedicated canceller is decided and its pin hydrated, it + // must hold CANCELLER_ROLE (provisioned via a timelocked + // `grantRole`). + if (TIMELOCK_CANCELLER != address(0)) { + _assertHasRole(acl, timelock, TIMELOCK_CANCELLER_ROLE, TIMELOCK_CANCELLER); + } + } + + /// @notice Revert `TimelockMissingRole` unless `account` holds `role`. + /// @param acl The timelock's `IAccessControl` surface. + /// @param timelock The timelock address (surfaced in the revert). + /// @param role The role to check. + /// @param account The account that must hold the role. + function _assertHasRole(IAccessControl acl, address timelock, bytes32 role, address account) private view { + if (!acl.hasRole(role, account)) { + revert TimelockMissingRole(timelock, role, account); + } + } + + /// @notice Revert `TimelockUnexpectedRole` if the zero address holds + /// `role` — OZ's `onlyRoleOrOpenRole` treats that as the role being + /// open to every caller. + /// @param acl The timelock's `IAccessControl` surface. + /// @param timelock The timelock address (surfaced in the revert). + /// @param role The role that must not be open. + function _assertNotOpenRole(IAccessControl acl, address timelock, bytes32 role) private view { + if (acl.hasRole(role, address(0))) { + revert TimelockUnexpectedRole(timelock, role, address(0)); + } + } +} diff --git a/test/src/lib/LibAuthoriserInvariants.t.sol b/test/src/lib/LibAuthoriserInvariants.t.sol index e0288be8..cca25957 100644 --- a/test/src/lib/LibAuthoriserInvariants.t.sol +++ b/test/src/lib/LibAuthoriserInvariants.t.sol @@ -6,6 +6,8 @@ import {Test} from "forge-std-1.16.1/src/Test.sol"; import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; import { LibAuthoriserInvariants, + RoleGrant, + ExpectedGrantMissing, UnexpectedDefaultAdmin, AuthoriserImplCodehashMismatch } from "../../../src/lib/LibAuthoriserInvariants.sol"; @@ -79,4 +81,64 @@ contract LibAuthoriserInvariantsTest is Test { ); harness.callAssertExpectedGrants(clone); } + + /// @notice The admin-holder parameterisation: the seven `_ADMIN` entries + /// track `adminHolder`, the six operational entries stay split between + /// the service signer and the Safe, and the narrower overloads are exact + /// collapses of the widest one (so no consumer can drift from the single + /// map). + function testExpectedGrantsAdminHolderParameterisation() external pure { + address safe = address(0x5AFE); + address timelock = address(0x7135); + RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(safe, timelock); + assertEq(grants.length, 13); + for (uint256 i = 0; i < 7; i++) { + assertEq(grants[i].grantee, timelock, "admin entries must track adminHolder"); + } + for (uint256 i = 7; i < 10; i++) { + assertEq(grants[i].grantee, LibAuthoriserInvariants.GRANTEE_SERVICE_1C66); + } + for (uint256 i = 10; i < 13; i++) { + assertEq(grants[i].grantee, safe, "operational Safe entries must track the Safe"); + } + + // The two-arg overload is the adminHolder == Safe collapse. + RoleGrant[] memory collapsed = LibAuthoriserInvariants.expectedGrants(safe); + RoleGrant[] memory widened = LibAuthoriserInvariants.expectedGrants(safe, safe); + assertEq(collapsed.length, widened.length); + for (uint256 i = 0; i < collapsed.length; i++) { + assertEq(collapsed[i].role, widened[i].role); + assertEq(collapsed[i].grantee, widened[i].grantee); + } + } + + /// @notice `assertExpectedGrants(authoriser, safe, adminHolder)` passes + /// once the live clone's seven `_ADMIN` roles are mocked onto the admin + /// holder — the exact post-timelock-migration shape — and pinpoints the + /// first missing `_ADMIN` grant when the mock is absent. + function testAssertExpectedGrantsWithDistinctAdminHolder() external { + selectBaseFork(); + address clone = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + address safe = LibAuthoriserInvariants.GRANTEE_TOKEN_OWNER_SAFE; + address timelock = address(0x7135); + LibAuthoriserInvariantsHarness harness = new LibAuthoriserInvariantsHarness(); + + // Without the timelock holding anything, the first `_ADMIN` entry is + // reported missing for the timelock. + RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(safe, timelock); + vm.expectRevert(abi.encodeWithSelector(ExpectedGrantMissing.selector, clone, grants[0].role, timelock)); + harness.callAssertExpectedGrants(clone, safe, timelock); + + // Mock the seven `_ADMIN` grants onto the timelock — the operational + // entries are already live on the fork — and the full assertion + // passes. + for (uint256 i = 0; i < 7; i++) { + vm.mockCall( + clone, + abi.encodeWithSelector(IAccessControl.hasRole.selector, grants[i].role, timelock), + abi.encode(true) + ); + } + harness.callAssertExpectedGrants(clone, safe, timelock); + } } diff --git a/test/src/lib/LibAuthoriserInvariantsHarness.sol b/test/src/lib/LibAuthoriserInvariantsHarness.sol index 01e7bba0..73eae56b 100644 --- a/test/src/lib/LibAuthoriserInvariantsHarness.sol +++ b/test/src/lib/LibAuthoriserInvariantsHarness.sol @@ -16,4 +16,8 @@ contract LibAuthoriserInvariantsHarness { function callAssertExpectedGrants(address authoriser) external view { LibAuthoriserInvariants.assertExpectedGrants(authoriser); } + + function callAssertExpectedGrants(address authoriser, address tokenOwnerSafe, address adminHolder) external view { + LibAuthoriserInvariants.assertExpectedGrants(authoriser, tokenOwnerSafe, adminHolder); + } } diff --git a/test/src/lib/LibTimelockInvariants.t.sol b/test/src/lib/LibTimelockInvariants.t.sol new file mode 100644 index 00000000..de9cf537 --- /dev/null +++ b/test/src/lib/LibTimelockInvariants.t.sol @@ -0,0 +1,218 @@ +// 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 {TimelockController} from "@openzeppelin-contracts-5.6.1/governance/TimelockController.sol"; +import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; +import { + LibTimelockInvariants, + UnsupportedChainForGovernanceTimelock, + TimelockNotDeployed, + TimelockCodehashMismatch, + TimelockMinDelayMismatch, + TimelockMissingRole, + TimelockUnexpectedRole +} from "../../../src/lib/LibTimelockInvariants.sol"; +import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; +import {LibTimelockInvariantsHarness} from "./LibTimelockInvariantsHarness.sol"; + +/// @title LibTimelockInvariantsTest +/// @notice Local (non-fork) coverage for `LibTimelockInvariants`: the Zoltu +/// address derivation is validated against the REAL factory bytecode (etched +/// locally), the state assertion is exercised green against a +/// pinned-configuration deploy and red against every deviation class — +/// missing code, alien code, wrong delay, missing role, open role, and a +/// root-admin escalation path. +contract LibTimelockInvariantsTest is Test { + /// @notice Stand-in for a chain's token-owner Safe. The invariants only + /// read role membership for this address, so a plain EOA-style address + /// is sufficient locally. + address internal constant SAFE = address(0x5aFe00000000000000000000000000000000aAaa); + + LibTimelockInvariantsHarness internal harness; + + function setUp() external { + harness = new LibTimelockInvariantsHarness(); + } + + /// @notice Deploy a timelock through the real (etched) Zoltu factory + /// from the pinned init code, exactly as the deploy broadcast does. + function deployPinnedTimelock() internal returns (address) { + LibRainDeploy.etchZoltuFactory(vm); + return LibRainDeploy.deployZoltu(LibTimelockInvariants.timelockInitCode(SAFE)); + } + + /// @notice The in-source CREATE2 derivation must land exactly where the + /// real Zoltu factory deploys the same init code — this is the equality + /// the deploy script's address assertion and the future pin PR both + /// stand on. + function testExpectedAddressMatchesRealZoltuFactory() external { + address deployed = deployPinnedTimelock(); + assertEq(deployed, LibTimelockInvariants.expectedTimelockAddress(SAFE)); + } + + /// @notice Distinct Safes must derive distinct timelock addresses — the + /// constructor arguments are part of the init code, so per-chain Safes + /// produce per-chain timelocks by construction. + function testExpectedAddressVariesWithSafe() external pure { + assertNotEq( + LibTimelockInvariants.expectedTimelockAddress(SAFE), + LibTimelockInvariants.expectedTimelockAddress(address(0xBEEF)) + ); + } + + /// @notice The full state assertion passes against a fresh + /// pinned-configuration deploy. + function testAssertTimelockStatePasses() external { + address timelock = deployPinnedTimelock(); + LibTimelockInvariants.assertTimelockState(timelock, SAFE); + } + + /// @notice The role-hash mirrors match the live OZ getters, so invariant + /// call sites can name roles without a deployed instance. + function testRoleMirrorsMatchLiveGetters() external { + TimelockController timelock = TimelockController(payable(deployPinnedTimelock())); + assertEq(LibTimelockInvariants.TIMELOCK_PROPOSER_ROLE, timelock.PROPOSER_ROLE()); + assertEq(LibTimelockInvariants.TIMELOCK_EXECUTOR_ROLE, timelock.EXECUTOR_ROLE()); + assertEq(LibTimelockInvariants.TIMELOCK_CANCELLER_ROLE, timelock.CANCELLER_ROLE()); + assertEq(LibTimelockInvariants.TIMELOCK_DEFAULT_ADMIN_ROLE, timelock.DEFAULT_ADMIN_ROLE()); + } + + /// @notice The pinned delay is 48 hours and the pinned deploy carries it. + function testMinDelayPin() external { + TimelockController timelock = TimelockController(payable(deployPinnedTimelock())); + assertEq(LibTimelockInvariants.TIMELOCK_MIN_DELAY, 48 hours); + assertEq(timelock.getMinDelay(), LibTimelockInvariants.TIMELOCK_MIN_DELAY); + } + + /// @notice An address without code is rejected before any call into it. + function testAssertRejectsUndeployed() external { + address missing = LibTimelockInvariants.expectedTimelockAddress(SAFE); + vm.expectRevert(abi.encodeWithSelector(TimelockNotDeployed.selector, missing)); + harness.callAssertTimelockState(missing, SAFE); + } + + /// @notice Alien bytecode at the timelock address is rejected by the + /// codehash pin before any role read is trusted. + function testAssertRejectsWrongCodehash() external { + address timelock = deployPinnedTimelock(); + vm.etch(timelock, hex"600160005260206000f3"); + vm.expectRevert( + abi.encodeWithSelector( + TimelockCodehashMismatch.selector, + timelock, + LibTimelockInvariants.timelockRuntimeCodehash(), + timelock.codehash + ) + ); + harness.callAssertTimelockState(timelock, SAFE); + } + + /// @notice A timelock deployed with a different delay is rejected: the + /// runtime bytecode is identical (delay is storage, not code), so only + /// the `getMinDelay` pin catches it. + function testAssertRejectsWrongMinDelay() external { + address[] memory principals = new address[](1); + principals[0] = SAFE; + TimelockController wrongDelay = new TimelockController(1 hours, principals, principals, address(0)); + vm.expectRevert( + abi.encodeWithSelector( + TimelockMinDelayMismatch.selector, + address(wrongDelay), + LibTimelockInvariants.TIMELOCK_MIN_DELAY, + 1 hours + ) + ); + harness.callAssertTimelockState(address(wrongDelay), SAFE); + } + + /// @notice A timelock whose proposer is not the Safe is rejected with + /// the exact missing `(role, account)` pair. + function testAssertRejectsMissingProposerRole() external { + address[] memory otherProposer = new address[](1); + otherProposer[0] = address(0xBEEF); + address[] memory executors = new address[](1); + executors[0] = SAFE; + TimelockController wrongProposer = + new TimelockController(LibTimelockInvariants.TIMELOCK_MIN_DELAY, otherProposer, executors, address(0)); + vm.expectRevert( + abi.encodeWithSelector( + TimelockMissingRole.selector, address(wrongProposer), LibTimelockInvariants.TIMELOCK_PROPOSER_ROLE, SAFE + ) + ); + harness.callAssertTimelockState(address(wrongProposer), SAFE); + } + + /// @notice A zero-address executor grant — OZ's "execution open to + /// everyone" switch — is rejected. Simulated through the timelock's own + /// self-administration path, proving the pinned deploy COULD drift here + /// only via a (timelocked) governance action that this invariant would + /// then flag. + function testAssertRejectsOpenExecutorRole() external { + address timelock = deployPinnedTimelock(); + vm.prank(timelock); + IAccessControl(timelock).grantRole(LibTimelockInvariants.TIMELOCK_EXECUTOR_ROLE, address(0)); + vm.expectRevert( + abi.encodeWithSelector( + TimelockUnexpectedRole.selector, timelock, LibTimelockInvariants.TIMELOCK_EXECUTOR_ROLE, address(0) + ) + ); + harness.callAssertTimelockState(timelock, SAFE); + } + + /// @notice A Safe holding `DEFAULT_ADMIN_ROLE` is rejected — root admin + /// on the proposer would let it re-grant roles instantly, bypassing the + /// delay entirely. Simulated by deploying with the optional constructor + /// admin set to the Safe. + function testAssertRejectsSafeAsRootAdmin() external { + address[] memory principals = new address[](1); + principals[0] = SAFE; + TimelockController adminned = + new TimelockController(LibTimelockInvariants.TIMELOCK_MIN_DELAY, principals, principals, SAFE); + vm.expectRevert( + abi.encodeWithSelector( + TimelockUnexpectedRole.selector, + address(adminned), + LibTimelockInvariants.TIMELOCK_DEFAULT_ADMIN_ROLE, + SAFE + ) + ); + harness.callAssertTimelockState(address(adminned), SAFE); + } + + /// @notice Chain selection returns the per-chain pin for the supported + /// chains and reverts (never falls back) for anything else. + function testTimelockForChainId() external { + assertEq( + LibTimelockInvariants.timelockForChainId(LibSafeInvariants.BASE_CHAIN_ID), + LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK + ); + assertEq( + LibTimelockInvariants.timelockForChainId(LibSafeInvariants.ETHEREUM_CHAIN_ID), + LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK_ETHEREUM + ); + vm.expectRevert(abi.encodeWithSelector(UnsupportedChainForGovernanceTimelock.selector, uint256(31337))); + harness.callTimelockForChainId(31337); + } + + /// @notice The hydration contract for the per-chain pins: while a pin is + /// a placeholder it must be zero; once hydrated it must equal the + /// derived Zoltu address for that chain's Safe. Both directions are + /// asserted here so the post-deploy pin PR turns this from the + /// placeholder branch to the equality branch with no test change. + function testPinsMatchDerivedAddressesOnceHydrated() external pure { + address basePin = LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK; + if (basePin != address(0)) { + assertEq(basePin, LibTimelockInvariants.expectedTimelockAddress(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE)); + } + address ethereumPin = LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK_ETHEREUM; + if (ethereumPin != address(0)) { + assertEq( + ethereumPin, + LibTimelockInvariants.expectedTimelockAddress(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM) + ); + } + } +} diff --git a/test/src/lib/LibTimelockInvariantsHarness.sol b/test/src/lib/LibTimelockInvariantsHarness.sol new file mode 100644 index 00000000..d733142d --- /dev/null +++ b/test/src/lib/LibTimelockInvariantsHarness.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibTimelockInvariants} from "../../../src/lib/LibTimelockInvariants.sol"; + +/// @notice External-call harness around `LibTimelockInvariants`'s internal +/// asserts so `vm.expectRevert` can catch their typed errors — +/// library-internal reverts inline, and `expectRevert` only sees reverts from +/// a lower call depth than the cheatcode itself. +contract LibTimelockInvariantsHarness { + function callAssertTimelockState(address timelock, address proposerExecutorSafe) external view { + LibTimelockInvariants.assertTimelockState(timelock, proposerExecutorSafe); + } + + function callTimelockForChainId(uint256 chainId) external pure returns (address) { + return LibTimelockInvariants.timelockForChainId(chainId); + } +} From 71f10aca6990ab3c0a31a02809efc0e385b3067b Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 10 Aug 2026 08:47:58 +0000 Subject: [PATCH 10/17] feat(lib): HyperEVM governance-timelock pin and chain arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HyperEVM carries 29 live production tokens, so it is governed on the same terms as Base and Ethereum rather than as a follow-up. Adds the per-chain timelock pin placeholder and the timelockForChainId arm, plus the derived-address assertion for it and an explicit test that all three pinned chains resolve. Note the HyperEVM Safe shares Ethereum's address, and the timelock constructor embeds only that Safe, so the Zoltu-derived timelock address is identical on those two chains. That is correct — same policy, same init code, same CREATE2 address — and the pins stay separate constants because the deploy is per-chain. --- src/lib/LibTimelockInvariants.sol | 20 ++++++++++++++++++ test/src/lib/LibTimelockInvariants.t.sol | 27 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/lib/LibTimelockInvariants.sol b/src/lib/LibTimelockInvariants.sol index fc409c7c..a02cb14e 100644 --- a/src/lib/LibTimelockInvariants.sol +++ b/src/lib/LibTimelockInvariants.sol @@ -132,6 +132,23 @@ library LibTimelockInvariants { /// Zoltu-derived address differs per chain by construction. address internal constant STOX_GOVERNANCE_TIMELOCK_ETHEREUM = address(0); + /// @notice The ST0x governance timelock on **HyperEVM**. + /// + /// **PLACEHOLDER** (`address(0)`) until the + /// `20260729-deploy-governance-timelock` broadcast executes on HyperEVM + /// and the post-execution pin PR hydrates this constant. HyperEVM + /// carries 29 live production tokens, so it is governed on exactly the + /// same terms as Base and Ethereum — not a follow-up. + /// @dev HyperEVM's token-owner Safe shares Ethereum's ADDRESS + /// (`STOX_TOKEN_OWNER_SAFE_HYPEREVM == STOX_TOKEN_OWNER_SAFE_ETHEREUM`), + /// and the timelock's constructor embeds only that Safe — so the + /// Zoltu-derived address is IDENTICAL on the two chains. That is + /// correct and expected (same policy, same init code, same CREATE2 + /// address), but it means the two pins will read the same value once + /// hydrated; they are kept as separate constants because the DEPLOY is + /// per-chain and either could diverge if a chain's Safe ever moves. + address internal constant STOX_GOVERNANCE_TIMELOCK_HYPEREVM = address(0); + /// @notice **PLACEHOLDER** for a dedicated canceller principal (a /// separate key or Safe that can veto a scheduled operation during the /// delay window without being able to propose or execute). Undecided: @@ -156,6 +173,9 @@ library LibTimelockInvariants { if (chainId == LibSafeInvariants.ETHEREUM_CHAIN_ID) { return STOX_GOVERNANCE_TIMELOCK_ETHEREUM; } + if (chainId == LibSafeInvariants.HYPEREVM_CHAIN_ID) { + return STOX_GOVERNANCE_TIMELOCK_HYPEREVM; + } revert UnsupportedChainForGovernanceTimelock(chainId); } diff --git a/test/src/lib/LibTimelockInvariants.t.sol b/test/src/lib/LibTimelockInvariants.t.sol index de9cf537..a4396237 100644 --- a/test/src/lib/LibTimelockInvariants.t.sol +++ b/test/src/lib/LibTimelockInvariants.t.sol @@ -214,5 +214,32 @@ contract LibTimelockInvariantsTest is Test { LibTimelockInvariants.expectedTimelockAddress(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM) ); } + address hyperevmPin = LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK_HYPEREVM; + if (hyperevmPin != address(0)) { + assertEq( + hyperevmPin, + LibTimelockInvariants.expectedTimelockAddress(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_HYPEREVM) + ); + } + } + + /// @notice Every chain with a pinned token-owner Safe resolves through + /// `timelockForChainId` rather than reverting. The chain-coverage guard + /// in `GovernanceTimelockMigration.t.sol` enforces this against the + /// live Safe map; this pins the three chains explicitly so a dropped + /// arm fails here too, next to the constants it would have dropped. + function testEveryPinnedChainResolves() external pure { + assertEq( + LibTimelockInvariants.timelockForChainId(LibSafeInvariants.BASE_CHAIN_ID), + LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK + ); + assertEq( + LibTimelockInvariants.timelockForChainId(LibSafeInvariants.ETHEREUM_CHAIN_ID), + LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK_ETHEREUM + ); + assertEq( + LibTimelockInvariants.timelockForChainId(LibSafeInvariants.HYPEREVM_CHAIN_ID), + LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK_HYPEREVM + ); } } From 0090c9ae4c33103134bb394377fbca976763f6b9 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 10 Aug 2026 10:00:18 +0000 Subject: [PATCH 11/17] fix(test): pin the grant map at 16 entries after the restack merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin-holder parameterisation was written against a 13-entry map; the multichain stack underneath now carries the three additional service-signer rows from #280, so the merged map is 16. The structure test still asserted 13 and failed the restacked branch. Widens it to 16 and asserts the new rows explicitly: the additional signer's three action roles are OPERATIONAL, so they must track the signer regardless of who holds the _ADMIN slice — the property that keeps the timelock migration from ever moving them. --- test/src/lib/LibAuthoriserInvariants.t.sol | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/src/lib/LibAuthoriserInvariants.t.sol b/test/src/lib/LibAuthoriserInvariants.t.sol index cca25957..7a374725 100644 --- a/test/src/lib/LibAuthoriserInvariants.t.sol +++ b/test/src/lib/LibAuthoriserInvariants.t.sol @@ -91,7 +91,7 @@ contract LibAuthoriserInvariantsTest is Test { address safe = address(0x5AFE); address timelock = address(0x7135); RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(safe, timelock); - assertEq(grants.length, 13); + assertEq(grants.length, 16); for (uint256 i = 0; i < 7; i++) { assertEq(grants[i].grantee, timelock, "admin entries must track adminHolder"); } @@ -101,6 +101,16 @@ contract LibAuthoriserInvariantsTest is Test { for (uint256 i = 10; i < 13; i++) { assertEq(grants[i].grantee, safe, "operational Safe entries must track the Safe"); } + // The additional service signer's three action roles are operational, + // not admin: they must track the signer regardless of who holds the + // `_ADMIN` slice, so the timelock migration never moves them. + for (uint256 i = 13; i < 16; i++) { + assertEq( + grants[i].grantee, + LibAuthoriserInvariants.GRANTEE_SERVICE_3D0C, + "additional service signer entries must be independent of adminHolder" + ); + } // The two-arg overload is the adminHolder == Safe collapse. RoleGrant[] memory collapsed = LibAuthoriserInvariants.expectedGrants(safe); From 372764539b5157cb2e984b440fd49f93865546a9 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 10 Aug 2026 10:25:45 +0000 Subject: [PATCH 12/17] pin(timelock): hydrate the deployed Base governance timelock Deployed by 20260729-deploy-governance-timelock (Base, run 31378549555) at 0xA34b2968A8B480440B218B6768Ad700FC81379b3. Verified on-chain: 48h min delay, timelock self-administers, Safe holds proposer/canceller/executor, deploy key holds nothing. Equals expectedTimelockAddress(Base Safe), which the structure test asserts. --- src/lib/LibTimelockInvariants.sol | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/lib/LibTimelockInvariants.sol b/src/lib/LibTimelockInvariants.sol index a02cb14e..1872236f 100644 --- a/src/lib/LibTimelockInvariants.sol +++ b/src/lib/LibTimelockInvariants.sol @@ -115,13 +115,12 @@ library LibTimelockInvariants { /// itself (self-administration) and nobody else. bytes32 internal constant TIMELOCK_DEFAULT_ADMIN_ROLE = bytes32(0); - /// @notice The ST0x governance timelock on **Base**. - /// - /// **PLACEHOLDER** (`address(0)`) until the - /// `20260729-deploy-governance-timelock` broadcast executes on Base and - /// the post-execution pin PR hydrates this constant with the logged - /// address (which must equal `expectedTimelockAddress(Base Safe)`). - address internal constant STOX_GOVERNANCE_TIMELOCK = address(0); + /// @notice The ST0x governance timelock on **Base**. Deployed by the + /// `20260729-deploy-governance-timelock` broadcast; equals + /// `expectedTimelockAddress(STOX_TOKEN_OWNER_SAFE)`, which + /// `testPinsMatchDerivedAddressesOnceHydrated` asserts. + /// https://basescan.org/address/0xa34b2968a8b480440b218b6768ad700fc81379b3 + address internal constant STOX_GOVERNANCE_TIMELOCK = address(0xA34b2968A8B480440B218B6768Ad700FC81379b3); /// @notice The ST0x governance timelock on **Ethereum mainnet**. /// From 096339e833c62d6c5f553e899488c90434a44ba5 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 10 Aug 2026 11:17:55 +0000 Subject: [PATCH 13/17] revert(pin): unpin the Base timelock - optimizer change moved the address The 0xA34b... deployment was broadcast when this branch still compiled at optimizer_runs=5000. Commit 0a8c62c lowered it to 2000 (so the H01 fix fits EIP-170), which changes TimelockController's creation bytecode and therefore its CREATE2 address: current source derives 0xdb4b2187... instead. Pinning an address the source can no longer reproduce is worse than not pinning, so the pin goes back to address(0) until a deploy runs against settled compiler settings. The 0xA34b... instance holds no power (nothing was migrated to it) and is simply abandoned. TimelockPinMismatch, added alongside the multi-chain deploy, is what caught this. --- src/lib/LibTimelockInvariants.sol | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/lib/LibTimelockInvariants.sol b/src/lib/LibTimelockInvariants.sol index 1872236f..c04f9db6 100644 --- a/src/lib/LibTimelockInvariants.sol +++ b/src/lib/LibTimelockInvariants.sol @@ -115,12 +115,8 @@ library LibTimelockInvariants { /// itself (self-administration) and nobody else. bytes32 internal constant TIMELOCK_DEFAULT_ADMIN_ROLE = bytes32(0); - /// @notice The ST0x governance timelock on **Base**. Deployed by the - /// `20260729-deploy-governance-timelock` broadcast; equals - /// `expectedTimelockAddress(STOX_TOKEN_OWNER_SAFE)`, which - /// `testPinsMatchDerivedAddressesOnceHydrated` asserts. - /// https://basescan.org/address/0xa34b2968a8b480440b218b6768ad700fc81379b3 - address internal constant STOX_GOVERNANCE_TIMELOCK = address(0xA34b2968A8B480440B218B6768Ad700FC81379b3); + /// @notice The ST0x governance timelock on **Base**. + address internal constant STOX_GOVERNANCE_TIMELOCK = address(0); /// @notice The ST0x governance timelock on **Ethereum mainnet**. /// From 78aeb1d66026e1e2e4119e2d4a1a961932a468b1 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 11 Aug 2026 09:51:59 +0000 Subject: [PATCH 14/17] pin(timelock): hydrate the deployed governance timelocks on all three chains Deployed by 20260729-deploy-governance-timelock (run 31385281204), which covered Base, Ethereum and HyperEVM in a single dispatch: Base 0xdb4b2187A685310E6b64170c97B80E90DD4a9B71 Ethereum 0x290961EF70A86aB70B7201D46d29f2f357416b49 HyperEVM 0x290961EF70A86aB70B7201D46d29f2f357416b49 Each was verified on-chain (48h min delay, timelock self-administers, Safe holds proposer/canceller/executor, deploy key holds nothing) and equals expectedTimelockAddress for its chain's Safe, which the structure test asserts. Ethereum and HyperEVM share an address because they share a Safe and the constructor embeds only that Safe. --- src/lib/LibTimelockInvariants.sol | 40 ++++++++++++++----------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/src/lib/LibTimelockInvariants.sol b/src/lib/LibTimelockInvariants.sol index c04f9db6..917fbc45 100644 --- a/src/lib/LibTimelockInvariants.sol +++ b/src/lib/LibTimelockInvariants.sol @@ -115,34 +115,30 @@ library LibTimelockInvariants { /// itself (self-administration) and nobody else. bytes32 internal constant TIMELOCK_DEFAULT_ADMIN_ROLE = bytes32(0); - /// @notice The ST0x governance timelock on **Base**. - address internal constant STOX_GOVERNANCE_TIMELOCK = address(0); + /// @notice The ST0x governance timelock on **Base**. Equals + /// `expectedTimelockAddress(STOX_TOKEN_OWNER_SAFE)`, which + /// `testPinsMatchDerivedAddressesOnceHydrated` asserts. + /// https://basescan.org/address/0xdb4b2187a685310e6b64170c97b80e90dd4a9b71 + address internal constant STOX_GOVERNANCE_TIMELOCK = address(0xdb4b2187A685310E6b64170c97B80E90DD4a9B71); - /// @notice The ST0x governance timelock on **Ethereum mainnet**. - /// - /// **PLACEHOLDER** (`address(0)`) until the - /// `20260729-deploy-governance-timelock` broadcast executes on Ethereum - /// and the post-execution pin PR hydrates this constant. A distinct - /// per-chain address: the constructor embeds the chain's Safe, so the - /// Zoltu-derived address differs per chain by construction. - address internal constant STOX_GOVERNANCE_TIMELOCK_ETHEREUM = address(0); + /// @notice The ST0x governance timelock on **Ethereum mainnet**. Equals + /// `expectedTimelockAddress(STOX_TOKEN_OWNER_SAFE_ETHEREUM)`. + /// https://etherscan.io/address/0x290961ef70a86ab70b7201d46d29f2f357416b49 + address internal constant STOX_GOVERNANCE_TIMELOCK_ETHEREUM = address(0x290961EF70A86aB70B7201D46d29f2f357416b49); - /// @notice The ST0x governance timelock on **HyperEVM**. - /// - /// **PLACEHOLDER** (`address(0)`) until the - /// `20260729-deploy-governance-timelock` broadcast executes on HyperEVM - /// and the post-execution pin PR hydrates this constant. HyperEVM + /// @notice The ST0x governance timelock on **HyperEVM**. HyperEVM /// carries 29 live production tokens, so it is governed on exactly the - /// same terms as Base and Ethereum — not a follow-up. + /// same terms as Base and Ethereum. /// @dev HyperEVM's token-owner Safe shares Ethereum's ADDRESS /// (`STOX_TOKEN_OWNER_SAFE_HYPEREVM == STOX_TOKEN_OWNER_SAFE_ETHEREUM`), /// and the timelock's constructor embeds only that Safe — so the - /// Zoltu-derived address is IDENTICAL on the two chains. That is - /// correct and expected (same policy, same init code, same CREATE2 - /// address), but it means the two pins will read the same value once - /// hydrated; they are kept as separate constants because the DEPLOY is - /// per-chain and either could diverge if a chain's Safe ever moves. - address internal constant STOX_GOVERNANCE_TIMELOCK_HYPEREVM = address(0); + /// Zoltu-derived address is IDENTICAL on the two chains, as this pin and + /// the Ethereum one show. That is correct and expected (same policy, same + /// init code, same CREATE2 address); they stay separate constants because + /// the DEPLOY is per-chain and either could diverge if a chain's Safe ever + /// moves. + /// https://hyperevmscan.io/address/0x290961ef70a86ab70b7201d46d29f2f357416b49 + address internal constant STOX_GOVERNANCE_TIMELOCK_HYPEREVM = address(0x290961EF70A86aB70B7201D46d29f2f357416b49); /// @notice **PLACEHOLDER** for a dedicated canceller principal (a /// separate key or Safe that can veto a scheduled operation during the From 260f852007b5b54a1c26f3bcb48395e7650f5c86 Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 11 Aug 2026 11:35:41 +0000 Subject: [PATCH 15/17] feat(lib): freeze the timelock bytecode as pinned constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime-codehash expectation was derived in-source (keccak256(type(TimelockController).runtimeCode)), which describes whatever the CURRENT compiler settings produce, not what production carries — as soon as the settings change, the source drifts from prod. The optimizer_runs 5000->2000 change demonstrated the failure mode: the deployed Base instance became underivable from source and had to be abandoned. Now the timelock is described by constants, per the repo's pin convention: - TIMELOCK_CREATION_CODE: the audited OZ TimelockController 5.6.1 creation bytecode, compiled once under the settled profile and embedded as a literal. The deploy broadcasts THESE bytes, so the CREATE2 address derivation and the deployed bytecode are immune to future compiler-settings changes. - TIMELOCK_RUNTIME_CODEHASH: the keccak of the runtime those bytes deploy (no immutables, so constructor-independent), asserted by assertTimelockState in place of the removed derivation function. testTimelockPinsMatchCompiledDependency ties both pins to the compiled dependency while the profile remains at the pinned generation; on divergence the pins stand — they describe prod, not source. Co-Authored-By: Claude Fable 5 --- src/lib/LibTimelockInvariants.sol | 56 ++++++++++++++---------- test/src/lib/LibTimelockInvariants.t.sol | 16 ++++++- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/src/lib/LibTimelockInvariants.sol b/src/lib/LibTimelockInvariants.sol index c04f9db6..45bb58ba 100644 --- a/src/lib/LibTimelockInvariants.sol +++ b/src/lib/LibTimelockInvariants.sol @@ -22,8 +22,8 @@ error UnsupportedChainForGovernanceTimelock(uint256 chainId); error TimelockNotDeployed(address timelock); /// @notice The runtime codehash at the timelock address does not match the -/// codehash of the audited OZ `TimelockController` this repo compiles -/// against. The contract at the pin is not the expected timelock. +/// pinned `TIMELOCK_RUNTIME_CODEHASH`. The contract at the address is not +/// the frozen audited timelock generation this repo deploys and asserts. /// @param timelock The timelock address inspected. /// @param expected The expected `TimelockController` runtime codehash. /// @param actual The codehash observed on-chain. @@ -155,6 +155,30 @@ library LibTimelockInvariants { /// starts asserting the grant once this pin is non-zero. address internal constant TIMELOCK_CANCELLER = address(0); + /// @notice The FROZEN creation bytecode of the governance timelock: the + /// audited OZ `TimelockController` from the version-locked 5.6.1 + /// soldeer dependency, compiled once under this repo's settled compiler + /// profile and pinned as a literal. The deploy broadcasts THESE bytes + /// (with constructor arguments appended), never the current + /// compilation, so a compiler-settings or dependency change cannot move + /// the deployed bytecode, the derived CREATE2 address, or the runtime + /// expectation away from what production carries. + /// `testTimelockPinsMatchCompiledDependency` ties the pin to the + /// compiled dependency while the profile remains at the pinned + /// generation; when they diverge, the pin stands — it describes prod, + /// not source. + bytes internal constant TIMELOCK_CREATION_CODE = + hex"608060405234801561000f575f80fd5b50604051611fd5380380611fd583398101604081905261002e916102f4565b6100385f3061017b565b506001600160a01b03811615610054576100525f8261017b565b505b5f5b83518110156100e8576100a87fb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc18583815181106100955761009561036d565b602002602001015161017b60201b60201c565b506100df7ffd643c72710c63c0180259aba6b2d05451e3591a24e58b62239378085726f7838583815181106100955761009561036d565b50600101610056565b505f5b82518110156101335761012a7fd8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e638483815181106100955761009561036d565b506001016100eb565b506002849055604080515f8152602081018690527f11c24f4ead16507c69ac467fbd5e4eed5fb5c699626d2cc6d66421df253886d5910160405180910390a150505050610381565b5f828152602081815260408083206001600160a01b038516845290915281205460ff1661021b575f838152602081815260408083206001600160a01b03861684529091529020805460ff191660011790556101d33390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a450600161021e565b505f5b92915050565b634e487b7160e01b5f52604160045260245ffd5b80516001600160a01b038116811461024e575f80fd5b919050565b5f82601f830112610262575f80fd5b815160206001600160401b038083111561027e5761027e610224565b8260051b604051601f19603f830116810181811084821117156102a3576102a3610224565b60405293845260208187018101949081019250878511156102c2575f80fd5b6020870191505b848210156102e9576102da82610238565b835291830191908301906102c9565b979650505050505050565b5f805f8060808587031215610307575f80fd5b845160208601519094506001600160401b0380821115610325575f80fd5b61033188838901610253565b94506040870151915080821115610346575f80fd5b5061035387828801610253565b92505061036260608601610238565b905092959194509250565b634e487b7160e01b5f52603260045260245ffd5b611c478061038e5f395ff3fe6080604052600436106101b2575f3560e01c80638065657f116100e7578063bc197c8111610087578063d547741f11610062578063d547741f146105b3578063e38335e5146105d2578063f23a6e61146105e5578063f27a0c9214610629575f80fd5b8063bc197c8114610525578063c4d252f514610569578063d45c443514610588575f80fd5b806391d14854116100c257806391d148541461047e578063a217fddf146104c0578063b08e51c0146104d3578063b1c5f42714610506575f80fd5b80638065657f1461040d5780638f2a0bb01461042c5780638f61f4f51461044b575f80fd5b80632ab0f5291161015257806336568abe1161012d57806336568abe14610384578063584b153e146103a357806364d62353146103c25780637958004c146103e1575f80fd5b80632ab0f529146103275780632f2ff15d1461034657806331d5075014610365575f80fd5b8063134008d31161018d578063134008d31461025357806313bc9f2014610266578063150b7a0214610285578063248a9ca3146102f9575f80fd5b806301d5062a146101bd57806301ffc9a7146101de57806307bd026514610212575f80fd5b366101b957005b5f80fd5b3480156101c8575f80fd5b506101dc6101d7366004611418565b61063d565b005b3480156101e9575f80fd5b506101fd6101f8366004611487565b610711565b60405190151581526020015b60405180910390f35b34801561021d575f80fd5b506102457fd8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e6381565b604051908152602001610209565b6101dc6102613660046114c6565b610721565b348015610271575f80fd5b506101fd61028036600461152d565b610816565b348015610290575f80fd5b506102c861029f3660046115f5565b7f150b7a0200000000000000000000000000000000000000000000000000000000949350505050565b6040517fffffffff000000000000000000000000000000000000000000000000000000009091168152602001610209565b348015610304575f80fd5b5061024561031336600461152d565b5f9081526020819052604090206001015490565b348015610332575f80fd5b506101fd61034136600461152d565b61083b565b348015610351575f80fd5b506101dc610360366004611659565b610843565b348015610370575f80fd5b506101fd61037f36600461152d565b61086d565b34801561038f575f80fd5b506101dc61039e366004611659565b610891565b3480156103ae575f80fd5b506101fd6103bd36600461152d565b6108e2565b3480156103cd575f80fd5b506101dc6103dc36600461152d565b610927565b3480156103ec575f80fd5b506104006103fb36600461152d565b6109b3565b6040516102099190611697565b348015610418575f80fd5b506102456104273660046114c6565b6109fb565b348015610437575f80fd5b506101dc6104463660046116fe565b610a39565b348015610456575f80fd5b506102457fb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc181565b348015610489575f80fd5b506101fd610498366004611659565b5f918252602082815260408084206001600160a01b0393909316845291905290205460ff1690565b3480156104cb575f80fd5b506102455f81565b3480156104de575f80fd5b506102457ffd643c72710c63c0180259aba6b2d05451e3591a24e58b62239378085726f78381565b348015610511575f80fd5b506102456105203660046117a7565b610bdb565b348015610530575f80fd5b506102c861053f3660046118c5565b7fbc197c810000000000000000000000000000000000000000000000000000000095945050505050565b348015610574575f80fd5b506101dc61058336600461152d565b610c1f565b348015610593575f80fd5b506102456105a236600461152d565b5f9081526001602052604090205490565b3480156105be575f80fd5b506101dc6105cd366004611659565b610ce2565b6101dc6105e03660046117a7565b610d06565b3480156105f0575f80fd5b506102c86105ff366004611968565b7ff23a6e610000000000000000000000000000000000000000000000000000000095945050505050565b348015610634575f80fd5b50600254610245565b7fb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc161066781610ee0565b5f6106768989898989896109fb565b90506106828184610eed565b5f817f4cf4410cc57040e44862ef0f45f3dd5a5e02db8eb8add648d4b0e236f1d07dca8b8b8b8b8b8a6040516106bd969594939291906119f1565b60405180910390a3831561070657807f20fda5fd27a1ea7bf5b9567f143ac5470bb059374a27e8f67cb44f946f6d0387856040516106fd91815260200190565b60405180910390a25b505050505050505050565b5f61071b82610fb0565b92915050565b5f80527fdae2aa361dfd1ca020a396615627d436107c35eff9fe7738a3512819782d70696020527f5ba6852781629bcdcd4bdaa6de76d786f1c64b16acdac474e55bebc0ea157951547fd8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e639060ff1661079d5761079d8133611005565b5f6107ac8888888888886109fb565b90506107b88185611074565b6107c4888888886110db565b5f817fc2617efa69bab66782fa219543714338489c4e9e178271560a91b82c3f612b588a8a8a8a6040516107fb9493929190611a2e565b60405180910390a361080c8161114f565b5050505050505050565b5f60025b610823836109b3565b600381111561083457610834611683565b1492915050565b5f600361081a565b5f8281526020819052604090206001015461085d81610ee0565b610867838361117a565b50505050565b5f80610878836109b3565b600381111561088957610889611683565b141592915050565b6001600160a01b03811633146108d3576040517f6697b23200000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6108dd8282611221565b505050565b5f806108ed836109b3565b9050600181600381111561090357610903611683565b14806109205750600281600381111561091e5761091e611683565b145b9392505050565b33308114610971576040517fe2850c590000000000000000000000000000000000000000000000000000000081526001600160a01b03821660048201526024015b60405180910390fd5b60025460408051918252602082018490527f11c24f4ead16507c69ac467fbd5e4eed5fb5c699626d2cc6d66421df253886d5910160405180910390a150600255565b5f81815260016020526040812054805f036109d057505f92915050565b600181036109e15750600392915050565b428111156109f25750600192915050565b50600292915050565b5f868686868686604051602001610a17969594939291906119f1565b6040516020818303038152906040528051906020012090509695505050505050565b7fb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc1610a6381610ee0565b8887141580610a725750888514155b15610aba576040517fffb03211000000000000000000000000000000000000000000000000000000008152600481018a90526024810186905260448101889052606401610968565b5f610acb8b8b8b8b8b8b8b8b610bdb565b9050610ad78184610eed565b5f5b8a811015610b8c5780827f4cf4410cc57040e44862ef0f45f3dd5a5e02db8eb8add648d4b0e236f1d07dca8e8e85818110610b1657610b16611a60565b9050602002016020810190610b2b9190611a74565b8d8d86818110610b3d57610b3d611a60565b905060200201358c8c87818110610b5657610b56611a60565b9050602002810190610b689190611a8d565b8c8b604051610b7c969594939291906119f1565b60405180910390a3600101610ad9565b508315610bce57807f20fda5fd27a1ea7bf5b9567f143ac5470bb059374a27e8f67cb44f946f6d038785604051610bc591815260200190565b60405180910390a25b5050505050505050505050565b5f8888888888888888604051602001610bfb989796959493929190611b61565b60405160208183030381529060405280519060200120905098975050505050505050565b7ffd643c72710c63c0180259aba6b2d05451e3591a24e58b62239378085726f783610c4981610ee0565b610c52826108e2565b610ca75781610c6160026112a2565b610c6b60016112a2565b6040517f5ead8eb50000000000000000000000000000000000000000000000000000000081526004810193909352176024820152604401610968565b5f828152600160205260408082208290555183917fbaa1eb22f2a492ba1a5fea61b8df4d27c6c8b5f3971e63bb58fa14ff72eedb7091a25050565b5f82815260208190526040902060010154610cfc81610ee0565b6108678383611221565b5f80527fdae2aa361dfd1ca020a396615627d436107c35eff9fe7738a3512819782d70696020527f5ba6852781629bcdcd4bdaa6de76d786f1c64b16acdac474e55bebc0ea157951547fd8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e639060ff16610d8257610d828133611005565b8786141580610d915750878414155b15610dd9576040517fffb03211000000000000000000000000000000000000000000000000000000008152600481018990526024810185905260448101879052606401610968565b5f610dea8a8a8a8a8a8a8a8a610bdb565b9050610df68185611074565b5f5b89811015610eca575f8b8b83818110610e1357610e13611a60565b9050602002016020810190610e289190611a74565b90505f8a8a84818110610e3d57610e3d611a60565b905060200201359050365f8a8a86818110610e5a57610e5a611a60565b9050602002810190610e6c9190611a8d565b91509150610e7c848484846110db565b84867fc2617efa69bab66782fa219543714338489c4e9e178271560a91b82c3f612b5886868686604051610eb39493929190611a2e565b60405180910390a350505050806001019050610df8565b50610ed48161114f565b50505050505050505050565b610eea8133611005565b50565b610ef68261086d565b15610f405781610f055f6112a2565b6040517f5ead8eb500000000000000000000000000000000000000000000000000000000815260048101929092526024820152604401610968565b5f610f4a60025490565b905080821015610f90576040517f543366090000000000000000000000000000000000000000000000000000000081526004810183905260248101829052604401610968565b610f9a8242611c19565b5f93845260016020526040909320929092555050565b5f7fffffffff0000000000000000000000000000000000000000000000000000000082167f4e2312e000000000000000000000000000000000000000000000000000000000148061071b575061071b826112c4565b5f828152602081815260408083206001600160a01b038516845290915290205460ff16611070576040517fe2517d3f0000000000000000000000000000000000000000000000000000000081526001600160a01b038216600482015260248101839052604401610968565b5050565b61107d82610816565b61108c5781610f0560026112a2565b80158015906110a1575061109f8161083b565b155b15611070576040517f90a9a61800000000000000000000000000000000000000000000000000000000815260048101829052602401610968565b5f80856001600160a01b03168585856040516110f8929190611c38565b5f6040518083038185875af1925050503d805f8114611132576040519150601f19603f3d011682016040523d82523d5f602084013e611137565b606091505b5091509150611146828261135a565b50505050505050565b61115881610816565b6111675780610f0560026112a2565b5f90815260016020819052604090912055565b5f828152602081815260408083206001600160a01b038516845290915281205460ff1661121a575f838152602081815260408083206001600160a01b03861684529091529020805460ff191660011790556111d23390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a450600161071b565b505f61071b565b5f828152602081815260408083206001600160a01b038516845290915281205460ff161561121a575f838152602081815260408083206001600160a01b0386168085529252808320805460ff1916905551339286917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a450600161071b565b5f8160038111156112b5576112b5611683565b600160ff919091161b92915050565b5f7fffffffff0000000000000000000000000000000000000000000000000000000082167f7965db0b00000000000000000000000000000000000000000000000000000000148061071b57507f01ffc9a7000000000000000000000000000000000000000000000000000000007fffffffff0000000000000000000000000000000000000000000000000000000083161461071b565b6060821561136957508061071b565b81511561137e57611379826113b0565b61071b565b6040517fd6bda27500000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b805160208201fd5b80356001600160a01b03811681146113ce575f80fd5b919050565b5f8083601f8401126113e3575f80fd5b50813567ffffffffffffffff8111156113fa575f80fd5b602083019150836020828501011115611411575f80fd5b9250929050565b5f805f805f805f60c0888a03121561142e575f80fd5b611437886113b8565b965060208801359550604088013567ffffffffffffffff811115611459575f80fd5b6114658a828b016113d3565b989b979a50986060810135976080820135975060a09091013595509350505050565b5f60208284031215611497575f80fd5b81357fffffffff0000000000000000000000000000000000000000000000000000000081168114610920575f80fd5b5f805f805f8060a087890312156114db575f80fd5b6114e4876113b8565b955060208701359450604087013567ffffffffffffffff811115611506575f80fd5b61151289828a016113d3565b979a9699509760608101359660809091013595509350505050565b5f6020828403121561153d575f80fd5b5035919050565b634e487b7160e01b5f52604160045260245ffd5b604051601f8201601f1916810167ffffffffffffffff8111828210171561158157611581611544565b604052919050565b5f82601f830112611598575f80fd5b813567ffffffffffffffff8111156115b2576115b2611544565b6115c56020601f19601f84011601611558565b8181528460208386010111156115d9575f80fd5b816020850160208301375f918101602001919091529392505050565b5f805f8060808587031215611608575f80fd5b611611856113b8565b935061161f602086016113b8565b925060408501359150606085013567ffffffffffffffff811115611641575f80fd5b61164d87828801611589565b91505092959194509250565b5f806040838503121561166a575f80fd5b8235915061167a602084016113b8565b90509250929050565b634e487b7160e01b5f52602160045260245ffd5b60208101600483106116b757634e487b7160e01b5f52602160045260245ffd5b91905290565b5f8083601f8401126116cd575f80fd5b50813567ffffffffffffffff8111156116e4575f80fd5b6020830191508360208260051b8501011115611411575f80fd5b5f805f805f805f805f60c08a8c031215611716575f80fd5b893567ffffffffffffffff8082111561172d575f80fd5b6117398d838e016116bd565b909b50995060208c0135915080821115611751575f80fd5b61175d8d838e016116bd565b909950975060408c0135915080821115611775575f80fd5b506117828c828d016116bd565b9a9d999c50979a969997986060880135976080810135975060a0013595509350505050565b5f805f805f805f8060a0898b0312156117be575f80fd5b883567ffffffffffffffff808211156117d5575f80fd5b6117e18c838d016116bd565b909a50985060208b01359150808211156117f9575f80fd5b6118058c838d016116bd565b909850965060408b013591508082111561181d575f80fd5b5061182a8b828c016116bd565b999c989b509699959896976060870135966080013595509350505050565b5f82601f830112611857575f80fd5b8135602067ffffffffffffffff82111561187357611873611544565b8160051b611882828201611558565b928352848101820192828101908785111561189b575f80fd5b83870192505b848310156118ba578235825291830191908301906118a1565b979650505050505050565b5f805f805f60a086880312156118d9575f80fd5b6118e2866113b8565b94506118f0602087016113b8565b9350604086013567ffffffffffffffff8082111561190c575f80fd5b61191889838a01611848565b9450606088013591508082111561192d575f80fd5b61193989838a01611848565b9350608088013591508082111561194e575f80fd5b5061195b88828901611589565b9150509295509295909350565b5f805f805f60a0868803121561197c575f80fd5b611985866113b8565b9450611993602087016113b8565b93506040860135925060608601359150608086013567ffffffffffffffff8111156119bc575f80fd5b61195b88828901611589565b81835281816020850137505f602082840101525f6020601f19601f840116840101905092915050565b6001600160a01b038716815285602082015260a060408201525f611a1960a0830186886119c8565b60608301949094525060800152949350505050565b6001600160a01b0385168152836020820152606060408201525f611a566060830184866119c8565b9695505050505050565b634e487b7160e01b5f52603260045260245ffd5b5f60208284031215611a84575f80fd5b610920826113b8565b5f808335601e19843603018112611aa2575f80fd5b83018035915067ffffffffffffffff821115611abc575f80fd5b602001915036819003821315611411575f80fd5b5f838385526020808601955060208560051b830101845f5b87811015611b5457601f198584030189528135601e19883603018112611b0c575f80fd5b8701848101903567ffffffffffffffff811115611b27575f80fd5b803603821315611b35575f80fd5b611b408582846119c8565b9a86019a9450505090830190600101611ae8565b5090979650505050505050565b60a080825281018890525f8960c08301825b8b811015611ba1576001600160a01b03611b8c846113b8565b16825260209283019290910190600101611b73565b5083810360208501528881527f07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff891115611bd9575f80fd5b8860051b9150818a60208301370182810360209081016040850152611c019082018789611ad0565b60608401959095525050608001529695505050505050565b8082018082111561071b57634e487b7160e01b5f52601160045260245ffd5b818382375f910190815291905056"; + + /// @notice The runtime codehash every deployed governance timelock must + /// carry: keccak256 of the runtime bytecode `TIMELOCK_CREATION_CODE` + /// deploys (`TimelockController` has no immutables, so the runtime + /// shape is constructor-independent). Pinned as a literal for the same + /// reason the creation code is: a compiler-settings change must not + /// drift the source's expectation away from the deployed bytecode. + bytes32 internal constant TIMELOCK_RUNTIME_CODEHASH = + 0xb6234401d5b271d66957815831adcde1e66f80a4c7a9d01881c7eecb70e96993; + /// @notice The ST0x governance timelock address for the active chain, /// selected by chain id — `address(0)` until that chain's timelock is /// deployed and the pin hydrated. Reverts for any chain without a pin @@ -175,10 +199,8 @@ library LibTimelockInvariants { } /// @notice The exact creation code the governance timelock deploy - /// broadcasts: the audited OZ `TimelockController` creation bytecode - /// (compiled from the version-locked soldeer dependency under this - /// repo's single compiler profile) with the pinned constructor - /// arguments appended — `TIMELOCK_MIN_DELAY`, the supplied Safe as sole + /// broadcasts: the FROZEN `TIMELOCK_CREATION_CODE` with the pinned + /// constructor arguments appended — `TIMELOCK_MIN_DELAY`, the supplied Safe as sole /// proposer (which also makes it canceller) and sole executor, and /// `admin = address(0)` so the timelock is self-administered from birth /// and the deploy key is never granted anything. @@ -189,9 +211,8 @@ library LibTimelockInvariants { proposers[0] = proposerExecutorSafe; address[] memory executors = new address[](1); executors[0] = proposerExecutorSafe; - return abi.encodePacked( - type(TimelockController).creationCode, abi.encode(TIMELOCK_MIN_DELAY, proposers, executors, address(0)) - ); + return + abi.encodePacked(TIMELOCK_CREATION_CODE, abi.encode(TIMELOCK_MIN_DELAY, proposers, executors, address(0))); } /// @notice The deterministic address `timelockInitCode` deploys to via @@ -212,18 +233,6 @@ library LibTimelockInvariants { ); } - /// @notice The runtime codehash every deployed governance timelock must - /// carry: the hash of the OZ `TimelockController` runtime bytecode this - /// repo compiles. Derived in-source (the contract has no immutables, so - /// `type(...).runtimeCode` is the exact deployed shape) rather than - /// pinned as a literal, so a dependency or compiler-settings change - /// surfaces as a codehash mismatch against the live deployment instead - /// of silently moving the expectation. - /// @return The expected `TimelockController` runtime codehash. - function timelockRuntimeCodehash() internal pure returns (bytes32) { - return keccak256(type(TimelockController).runtimeCode); - } - /// @notice Assert the governance timelock at `timelock` is in its pinned /// state: deployed with the expected `TimelockController` runtime /// codehash, `getMinDelay() == TIMELOCK_MIN_DELAY`, the Safe holds @@ -242,10 +251,9 @@ library LibTimelockInvariants { /// hold the proposer / canceller / executor roles. function assertTimelockState(address timelock, address proposerExecutorSafe) internal view { if (timelock.code.length == 0) revert TimelockNotDeployed(timelock); - bytes32 expectedCodehash = timelockRuntimeCodehash(); bytes32 actualCodehash = timelock.codehash; - if (actualCodehash != expectedCodehash) { - revert TimelockCodehashMismatch(timelock, expectedCodehash, actualCodehash); + if (actualCodehash != TIMELOCK_RUNTIME_CODEHASH) { + revert TimelockCodehashMismatch(timelock, TIMELOCK_RUNTIME_CODEHASH, actualCodehash); } uint256 actualMinDelay = TimelockController(payable(timelock)).getMinDelay(); diff --git a/test/src/lib/LibTimelockInvariants.t.sol b/test/src/lib/LibTimelockInvariants.t.sol index a4396237..797fba24 100644 --- a/test/src/lib/LibTimelockInvariants.t.sol +++ b/test/src/lib/LibTimelockInvariants.t.sol @@ -94,6 +94,20 @@ contract LibTimelockInvariantsTest is Test { harness.callAssertTimelockState(missing, SAFE); } + /// @notice The frozen creation-code and runtime-codehash pins match the + /// version-locked OZ dependency as the current profile compiles it. Red + /// here means the compiler settings or the dependency moved past the + /// pinned generation: regenerate the pins only while no chain has + /// deployed or migrated onto them; once production carries the pinned + /// generation the pins stand (they describe prod, not source) and the + /// divergence is a deliberate new-generation decision. + function testTimelockPinsMatchCompiledDependency() external pure { + assertEq( + keccak256(LibTimelockInvariants.TIMELOCK_CREATION_CODE), keccak256(type(TimelockController).creationCode) + ); + assertEq(LibTimelockInvariants.TIMELOCK_RUNTIME_CODEHASH, keccak256(type(TimelockController).runtimeCode)); + } + /// @notice Alien bytecode at the timelock address is rejected by the /// codehash pin before any role read is trusted. function testAssertRejectsWrongCodehash() external { @@ -103,7 +117,7 @@ contract LibTimelockInvariantsTest is Test { abi.encodeWithSelector( TimelockCodehashMismatch.selector, timelock, - LibTimelockInvariants.timelockRuntimeCodehash(), + LibTimelockInvariants.TIMELOCK_RUNTIME_CODEHASH, timelock.codehash ) ); From d3277503e1a7ba9123890f0040389ddb975e00ab Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 11 Aug 2026 11:45:32 +0000 Subject: [PATCH 16/17] test(lib): walk the open-role rejection across every lifecycle role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zero-address (open-role) rejection was tested only for EXECUTOR_ROLE; mutants deleting the proposer, canceller, or DEFAULT_ADMIN open-role checks survived the suite. The strengthened test grants address(0) each of the four roles in turn through the self-administration path, asserts the typed revert per role, revokes, and proves the closed state passes again after each revoke — so each rejection is attributable to the zero-grant alone. Co-Authored-By: Claude Fable 5 --- test/src/lib/LibTimelockInvariants.t.sol | 38 ++++++++++++++++-------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/test/src/lib/LibTimelockInvariants.t.sol b/test/src/lib/LibTimelockInvariants.t.sol index 797fba24..711b6b20 100644 --- a/test/src/lib/LibTimelockInvariants.t.sol +++ b/test/src/lib/LibTimelockInvariants.t.sol @@ -159,20 +159,32 @@ contract LibTimelockInvariantsTest is Test { harness.callAssertTimelockState(address(wrongProposer), SAFE); } - /// @notice A zero-address executor grant — OZ's "execution open to - /// everyone" switch — is rejected. Simulated through the timelock's own - /// self-administration path, proving the pinned deploy COULD drift here - /// only via a (timelocked) governance action that this invariant would - /// then flag. - function testAssertRejectsOpenExecutorRole() external { + /// @notice A zero-address grant on ANY lifecycle role — OZ's + /// `onlyRoleOrOpenRole` treats `hasRole(role, address(0))` as "open to + /// everyone" — is rejected, walked per role: proposer, canceller, + /// executor, and root admin each trip their own typed revert, and the + /// closed state passes again after each revoke. Simulated through the + /// timelock's own self-administration path, proving the pinned deploy + /// COULD drift here only via a (timelocked) governance action that this + /// invariant would then flag. + function testAssertRejectsEveryOpenRole() external { address timelock = deployPinnedTimelock(); - vm.prank(timelock); - IAccessControl(timelock).grantRole(LibTimelockInvariants.TIMELOCK_EXECUTOR_ROLE, address(0)); - vm.expectRevert( - abi.encodeWithSelector( - TimelockUnexpectedRole.selector, timelock, LibTimelockInvariants.TIMELOCK_EXECUTOR_ROLE, address(0) - ) - ); + bytes32[4] memory roles = [ + LibTimelockInvariants.TIMELOCK_PROPOSER_ROLE, + LibTimelockInvariants.TIMELOCK_CANCELLER_ROLE, + LibTimelockInvariants.TIMELOCK_EXECUTOR_ROLE, + LibTimelockInvariants.TIMELOCK_DEFAULT_ADMIN_ROLE + ]; + for (uint256 i = 0; i < roles.length; i++) { + vm.prank(timelock); + IAccessControl(timelock).grantRole(roles[i], address(0)); + vm.expectRevert(abi.encodeWithSelector(TimelockUnexpectedRole.selector, timelock, roles[i], address(0))); + harness.callAssertTimelockState(timelock, SAFE); + vm.prank(timelock); + IAccessControl(timelock).revokeRole(roles[i], address(0)); + } + // Every zero-grant revoked: the closed state passes again, proving + // each rejection above was the zero-grant and nothing else. harness.callAssertTimelockState(timelock, SAFE); } From 6da08a2011c818b95cfbefcb2eb4467db91b8cb5 Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 11 Aug 2026 11:47:36 +0000 Subject: [PATCH 17/17] test(lib): walk the missing-role rejection across every required role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the open-role walk on the positive checks: the missing-role rejection was tested only for the Safe's proposer role, so mutants deleting the canceller, executor, or self-administration _assertHasRole lines survived. The strengthened test revokes each required grant in turn — proposer, canceller, executor off the Safe, re-granting and re-asserting the restored state after each — and finishes with the timelock's own root admin, which goes last and unrestored: once self-administration is revoked no principal can re-grant it, which is exactly why the invariant pins it. Co-Authored-By: Claude Fable 5 --- test/src/lib/LibTimelockInvariants.t.sol | 39 +++++++++++++++++------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/test/src/lib/LibTimelockInvariants.t.sol b/test/src/lib/LibTimelockInvariants.t.sol index 711b6b20..2fc20562 100644 --- a/test/src/lib/LibTimelockInvariants.t.sol +++ b/test/src/lib/LibTimelockInvariants.t.sol @@ -142,21 +142,38 @@ contract LibTimelockInvariantsTest is Test { harness.callAssertTimelockState(address(wrongDelay), SAFE); } - /// @notice A timelock whose proposer is not the Safe is rejected with - /// the exact missing `(role, account)` pair. - function testAssertRejectsMissingProposerRole() external { - address[] memory otherProposer = new address[](1); - otherProposer[0] = address(0xBEEF); - address[] memory executors = new address[](1); - executors[0] = SAFE; - TimelockController wrongProposer = - new TimelockController(LibTimelockInvariants.TIMELOCK_MIN_DELAY, otherProposer, executors, address(0)); + /// @notice A missing grant on ANY required role — the Safe's proposer, + /// canceller, and executor, and the timelock's own root admin — trips + /// the exact missing `(role, account)` pair, walked per role: revoke, + /// assert the typed revert, re-grant, and prove the restored state + /// passes. Self-administration goes last and is not restored: once the + /// timelock renounces its own root admin no principal can re-grant it, + /// which is exactly why the invariant pins it. + function testAssertRejectsEveryMissingRole() external { + address timelock = deployPinnedTimelock(); + bytes32[3] memory safeRoles = [ + LibTimelockInvariants.TIMELOCK_PROPOSER_ROLE, + LibTimelockInvariants.TIMELOCK_CANCELLER_ROLE, + LibTimelockInvariants.TIMELOCK_EXECUTOR_ROLE + ]; + for (uint256 i = 0; i < safeRoles.length; i++) { + vm.prank(timelock); + IAccessControl(timelock).revokeRole(safeRoles[i], SAFE); + vm.expectRevert(abi.encodeWithSelector(TimelockMissingRole.selector, timelock, safeRoles[i], SAFE)); + harness.callAssertTimelockState(timelock, SAFE); + vm.prank(timelock); + IAccessControl(timelock).grantRole(safeRoles[i], SAFE); + harness.callAssertTimelockState(timelock, SAFE); + } + + vm.prank(timelock); + IAccessControl(timelock).revokeRole(LibTimelockInvariants.TIMELOCK_DEFAULT_ADMIN_ROLE, timelock); vm.expectRevert( abi.encodeWithSelector( - TimelockMissingRole.selector, address(wrongProposer), LibTimelockInvariants.TIMELOCK_PROPOSER_ROLE, SAFE + TimelockMissingRole.selector, timelock, LibTimelockInvariants.TIMELOCK_DEFAULT_ADMIN_ROLE, timelock ) ); - harness.callAssertTimelockState(address(wrongProposer), SAFE); + harness.callAssertTimelockState(timelock, SAFE); } /// @notice A zero-address grant on ANY lifecycle role — OZ's