From c6626e13570c27f043c1aec52f80bc5048f5b1d9 Mon Sep 17 00:00:00 2001 From: highonhopium Date: Mon, 3 Aug 2026 14:57:52 +0100 Subject: [PATCH 01/12] feat(timelock): migrate the upgrade beacons under the timelock too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vault ownership and the authoriser _ADMIN roles are not the whole admin surface. Every production token proxies through the chain's three in-use upgrade beacons, and a beacon owner can upgradeTo a new implementation for all of them in one transaction — a hostile implementation can re-take vault ownership and rewrite the authoriser wiring outright. Timelocking setAuthorizer and vault ownership while leaving the beacons Safe-owned leaves the delay bypassable by design, so the beacons move in the same atomic bundle. - The migration selects still-Safe-owned beacons from live state, pinning each beacon's codehash to the OZ UpgradeableBeacon bytecode BEFORE trusting its owner() read, and aborts on any owner outside {Safe, timelock}. - Bundle order becomes grants -> vault transfers -> beacon transfers -> renounces, so the Safe gives nothing up until everything has landed. - The post-state proves each beacon still serves the implementation it served before the bundle: this moves upgrade AUTHORITY, never performs an upgrade. - LibBeaconInvariants gains an owner-parametric assertion and a migration-window variant, so the 2026-10-01 forcing function red-lines on un-migrated beacons, not just un-migrated vaults. run() and the happy-path test both crossed the legacy-codegen stack limit once the beacon locals were added. via_ir stays off per CLAUDE.md, so the selection collapses into a MigrationTargets struct and the post-state / artifact blocks move into helpers. No behavioural change. --- docs/TIMELOCK.md | 23 +- ...60729-migrate-governance-to-timelock.s.sol | 318 ++++++++++++++---- src/lib/LibBeaconInvariants.sol | 52 ++- ...60729-migrate-governance-to-timelock.t.sol | 153 +++++++-- .../deploy/GovernanceTimelockMigration.t.sol | 40 +++ test/src/lib/LibBeaconInvariantsHarness.sol | 11 + 6 files changed, 495 insertions(+), 102 deletions(-) diff --git a/docs/TIMELOCK.md b/docs/TIMELOCK.md index 77d8edc3..71104c27 100644 --- a/docs/TIMELOCK.md +++ b/docs/TIMELOCK.md @@ -9,13 +9,23 @@ After the migration executes, the timelock is: - the `owner()` of **every production receipt vault** — so `transferOwnership`, `setAuthorizer`, and every `onlyOwner` surface (including owner freezes) is - delay-gated; and + delay-gated; +- the `owner()` of the chain's **three in-use upgrade beacons** (receipt, + receipt vault, wrapped token vault) — so `upgradeTo` is delay-gated; and - the **sole holder of the authoriser's seven `_ADMIN` roles** (`DEPOSIT_ADMIN`, `WITHDRAW_ADMIN`, `CERTIFY_ADMIN`, `CONFISCATE_SHARES_ADMIN`, `CONFISCATE_RECEIPT_ADMIN`, `SCHEDULE_CORPORATE_ACTION_ADMIN`, `CANCEL_CORPORATE_ACTION_ADMIN`) — so adding or removing grants on the authoriser is delay-gated. +**Why the beacons are in scope.** Every production token proxies through those +three beacons, and a beacon owner can `upgradeTo` a new implementation for all +of them in a single transaction — a hostile implementation could re-take vault +ownership and rewrite the authoriser wiring outright. Timelocking +`setAuthorizer` and vault ownership while leaving the beacons on the Safe would +make the delay bypassable by design, so both surfaces move in the same atomic +bundle and are forced by the same deadline. + The Safe **keeps its three direct action roles** (`DEPOSIT`, `WITHDRAW`, `CERTIFY`) and the service signer keeps its operational grants: day-to-day operations are NOT timelocked. Only admin power is. @@ -98,8 +108,8 @@ timelocks live. signer cross-check. 4. **Sign + execute** — import the CI-authored artifact into the Safe UI (never a locally generated JSON), verify the hash, execute. The bundle is atomic: 7 - `_ADMIN` grants to the timelock → N vault `transferOwnership` → 7 Safe - renounces. + `_ADMIN` grants to the timelock → N vault `transferOwnership` → 3 beacon + `transferOwnership` → 7 Safe renounces. 5. **Post-execution flip PR** — mark the migration script `**EXECUTED YYYY-MM-DD.**`, repoint the strict uniform-ownership invariants (`LibInvariants.assertAll`, `LibTokenInvariants` consumers, @@ -149,13 +159,10 @@ way to resolve it. the single master grant map, parameterised on the admin holder; post-migration consumers pass the timelock. - `LibTokenInvariants.assertUniformOwnershipMigration` / + `LibBeaconInvariants.assertProdBeaconsOwnershipMigration` / `GovernanceTimelockMigration.t.sol` — the migration window + deadline (see - above). + above), over vault ownership, beacon ownership and `_ADMIN` holding. ## Explicitly out of scope (follow-ups) -- **Beacon ownership.** The upgrade beacons remain Safe-owned. Moving them under - the timelock is the same one-call-per-beacon `transferOwnership` pattern and - can reuse this machinery wholesale, but it gates contract UPGRADES (not token - admin) and deserves its own decision + rollout. - **Dedicated canceller** — see the role model above. diff --git a/script/20260729-migrate-governance-to-timelock.s.sol b/script/20260729-migrate-governance-to-timelock.s.sol index 1520a264..0c9ee93c 100644 --- a/script/20260729-migrate-governance-to-timelock.s.sol +++ b/script/20260729-migrate-governance-to-timelock.s.sol @@ -8,8 +8,11 @@ import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessContro import {Ownable} from "@openzeppelin-contracts-5.6.1/access/Ownable.sol"; import {TimelockController} from "@openzeppelin-contracts-5.6.1/governance/TimelockController.sol"; +import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; + import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol"; import {LibAuthoriserInvariants, RoleGrant} from "../src/lib/LibAuthoriserInvariants.sol"; +import {LibBeaconInvariants} from "../src/lib/LibBeaconInvariants.sol"; import {LibProdDeployV4} from "../src/generated/LibProdDeployV4.sol"; import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; @@ -63,12 +66,54 @@ error UnexpectedVaultOwner(address vault, address actual); /// @param role The `_ADMIN` role in the unexpected state. error UnexpectedAdminRoleState(bytes32 role); -/// @notice Every production vault is already timelock-owned and every -/// `_ADMIN` role already sits (exclusively) on the timelock — the migration -/// has fully landed. Dispatching again authors an empty bundle, which is -/// never meaningful. +/// @notice A production upgrade beacon reports an owner that is neither the +/// Safe (the only acceptable pre-migration state) nor the timelock (already +/// migrated). Same rule as `UnexpectedVaultOwner`, on the surface that can +/// repoint every proxy on the chain. +/// @param beacon The beacon inspected. +/// @param actual The unexpected address returned by `owner()`. +error UnexpectedBeaconOwner(address beacon, address actual); + +/// @notice A production beacon's runtime codehash is not the pinned OZ +/// `UpgradeableBeacon` bytecode, so its `owner()` / `upgradeTo` access +/// control is not the audited implementation and ownership must not be +/// transferred to it or away from it on the strength of that read. +/// @param beacon The beacon inspected. +/// @param expected The pinned `UpgradeableBeacon` runtime codehash. +/// @param actual The codehash observed on-chain. +error MigrationBeaconCodehashMismatch(address beacon, bytes32 expected, bytes32 actual); + +/// @notice A production beacon's `implementation()` changed across the +/// migration bundle. The migration moves the beacon's OWNER and must never +/// alter what the beacon serves — every production proxy would follow. +/// @param beacon The beacon whose implementation moved. +/// @param expected The implementation captured before the bundle. +/// @param actual The implementation observed after simulating the bundle. +error BeaconImplementationMoved(address beacon, address expected, address actual); + +/// @notice Every production vault and beacon is already timelock-owned and +/// every `_ADMIN` role already sits (exclusively) on the timelock — the +/// migration has fully landed. Dispatching again authors an empty bundle, +/// which is never meaningful. error NothingToMigrate(); +/// @notice The migration work selected from live chain state, grouped so +/// `run()` carries one local for the whole selection rather than four. +/// @dev Grouping is load-bearing, not cosmetic: `via_ir` is off for this +/// repo (see CLAUDE.md) and `run()` runs up against the legacy codegen's +/// stack limit, so each collapsed local buys headroom the authoring flow +/// needs. +/// @param vaults Production receipt vaults still owned by the Safe. +/// @param beacons In-use upgrade beacons still owned by the Safe. +/// @param grantRoles `_ADMIN` roles to grant to the timelock. +/// @param renounceRoles `_ADMIN` roles the Safe must renounce. +struct MigrationTargets { + address[] vaults; + address[] beacons; + bytes32[] grantRoles; + bytes32[] renounceRoles; +} + /// @notice The end-to-end governance-loop proof did not leave the scheduled /// operation in the `Done` state — the schedule → delay → execute path a /// future admin action must take does not work against the post-migration @@ -86,12 +131,24 @@ error GovernanceLoopNotProven(bytes32 id); /// 2. Transfers ownership of every production receipt vault from the /// Safe to the timelock (`transferOwnership` — single-step /// `OwnableUpgradeable`). -/// 3. Renounces the Safe's own copy of each `_ADMIN` role +/// 3. Transfers ownership of the chain's three in-use upgrade beacons +/// (receipt, receipt vault, wrapped token vault) from the Safe to the +/// timelock (`transferOwnership` — single-step `Ownable`). +/// 4. Renounces the Safe's own copy of each `_ADMIN` role /// (`renounceRole` — last, so the Safe stays fully empowered until /// every grant and transfer has landed inside the same atomic batch). /// +/// The beacon leg is what makes the delay real rather than cosmetic. A +/// beacon owner can `upgradeTo` a new implementation for EVERY production +/// proxy on the chain in one transaction, and a hostile implementation can +/// re-take vault ownership and rewrite the authoriser wiring outright — so +/// a timelock over `setAuthorizer` and vault ownership with the beacons +/// left on the Safe would be bypassable by design. Both surfaces move in +/// the same atomic bundle. +/// /// After execution the timelock is the only path to `setAuthorizer`, -/// `transferOwnership`, owner freezes, and authoriser grant-map changes: +/// `transferOwnership`, owner freezes, beacon upgrades, and authoriser +/// grant-map changes: /// the Safe schedules an operation on the timelock, waits out /// `TIMELOCK_MIN_DELAY` (48h), then executes it. The Safe KEEPS its three /// direct action roles (`DEPOSIT` / `WITHDRAW` / `CERTIFY`) — day-to-day @@ -120,17 +177,18 @@ error GovernanceLoopNotProven(bytes32 id); /// self-administration, no open roles); the chain's V4 authoriser clone /// carries the pinned codehash and its six operational grants (service /// signer + Safe action roles) are intact. -/// 2. **Select** — still-Safe-owned vaults and still-Safe-held `_ADMIN` -/// roles, from live chain state. -/// 3. **Build** — grants, then transfers, then renounces, in one bundle. -/// Compute the canonical MultiSend `SafeTxHash` against the live nonce -/// for signer cross-check. +/// 2. **Select** — still-Safe-owned vaults, still-Safe-owned beacons and +/// still-Safe-held `_ADMIN` roles, from live chain state. +/// 3. **Build** — grants, then vault transfers, then beacon transfers, +/// then renounces, in one bundle. Compute the canonical MultiSend +/// `SafeTxHash` against the live nonce for signer cross-check. /// 4. **Simulate** — prank-route each bundle item as the Safe. /// 5. **Post-state** — every production vault reports the timelock as -/// `owner()` and the chain's authoriser as `authorizer()`; the full -/// grant map holds with the timelock as admin holder; the Safe holds no -/// `_ADMIN` role; Safe identity + threshold unchanged; timelock state -/// unchanged. +/// `owner()` and the chain's authoriser as `authorizer()`; every in-use +/// beacon reports the timelock as `owner()` and still points at the +/// implementation it pointed at before the bundle; the full grant map +/// holds with the timelock as admin holder; the Safe holds no `_ADMIN` +/// role; Safe identity + threshold unchanged; timelock state unchanged. /// 6. **Artifact** — emit the Tx Builder JSON to /// `out/20260729-governance-timelock-migration-.json`. /// 7. **Governance-loop proof** — schedule an idempotent admin operation @@ -230,17 +288,30 @@ contract MigrateGovernanceToTimelock is Script { // --- Select from live state --------------------------------------- - address[] memory vaultTargets = _selectVaultTargets(tokens, address(safe), timelock); - (bytes32[] memory grantRoles, bytes32[] memory renounceRoles) = - _selectRoleTargets(authoriser, address(safe), timelock); + MigrationTargets memory targets; + targets.vaults = _selectVaultTargets(tokens, address(safe), timelock); + targets.beacons = _selectBeaconTargets(address(safe), timelock); + (targets.grantRoles, targets.renounceRoles) = _selectRoleTargets(authoriser, address(safe), timelock); - if (vaultTargets.length == 0 && grantRoles.length == 0 && renounceRoles.length == 0) { + if ( + targets.vaults.length == 0 && targets.beacons.length == 0 && targets.grantRoles.length == 0 + && targets.renounceRoles.length == 0 + ) { revert NothingToMigrate(); } + console2.log("Vault transfers:", targets.vaults.length); + console2.log("Beacon transfers:", targets.beacons.length); + console2.log("Admin role grants:", targets.grantRoles.length); + console2.log("Admin role renounces:", targets.renounceRoles.length); + + // What the beacons serve, captured before the bundle so the + // post-state can prove the migration moved ownership only. + address[3] memory beaconImplsBefore = _beaconImplementations(); + // --- Build the bundle --------------------------------------------- - SafeTx[] memory txs = _buildBundle(authoriser, timelock, address(safe), vaultTargets, grantRoles, renounceRoles); + SafeTx[] memory txs = _buildBundle(authoriser, timelock, address(safe), targets); // Capture the nonce before any simulation; the artifact executes as // a single MultiSend at this nonce, so this is the hash signers @@ -256,33 +327,35 @@ contract MigrateGovernanceToTimelock is Script { // --- Post-state --------------------------------------------------- - // Every production vault is timelock-owned and still gated by the - // chain's authoriser. - LibTokenInvariants.assertUniformOwnership(tokens, timelock); - LibTokenInvariants.assertUniformAuthoriser(tokens, authoriser); + _assertPostState(safe, timelock, authoriser, tokens, beaconImplsBefore); - // The full grant map holds with the timelock as admin holder, and - // the Safe's admin copies are gone — admin power moves, operational - // power stays. - LibAuthoriserInvariants.assertExpectedGrants(authoriser, address(safe), timelock); - bytes32[] memory roles = adminRoles(); - for (uint256 i = 0; i < roles.length; i++) { - if (IAccessControl(authoriser).hasRole(roles[i], address(safe))) { - revert UnexpectedAdminRoleState(roles[i]); - } - } + // --- Artifact ----------------------------------------------------- - // Safe identity + threshold unchanged; timelock configuration - // unchanged. - LibSafeInvariants.assertImmutableInvariants(safe); - LibSafeInvariants.assertThreshold(safe, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD); - LibTimelockInvariants.assertTimelockState(timelock, address(safe)); + _emitArtifact(address(safe), txs, bundleSafeTxHash, nonce, timelock); - // --- Artifact ----------------------------------------------------- + // --- Governance-loop proof ---------------------------------------- + + _proveGovernanceLoop(safe, timelock, authoriser); + } + /// @notice Write the Safe Tx Builder JSON for the bundle and log it + /// alongside the values a signer cross-checks in the Safe UI: the + /// canonical MultiSend `SafeTxHash`, the nonce it was computed against, + /// and the timelock the bundle hands governance to. The per-leg counts + /// are logged at selection time, before the bundle is built. + /// @dev Split out of `run()` for the same stack-limit reason as + /// `_assertPostState` — `via_ir` is off for this repo. + /// @param safe The chain's token-owner Safe. + /// @param txs The bundle transactions in execution order. + /// @param bundleSafeTxHash The canonical MultiSend `SafeTxHash`. + /// @param nonce The Safe nonce the hash was computed against. + /// @param timelock The chain's governance timelock. + function _emitArtifact(address safe, SafeTx[] memory txs, bytes32 bundleSafeTxHash, uint256 nonce, address timelock) + internal + { string memory artifactPath = string.concat("out/20260729-governance-timelock-migration-", vm.toString(block.chainid), ".json"); - string memory json = LibSafeOps.emitTxBuilderJson(address(safe), block.chainid, BUNDLE_NAME, txs); + string memory json = LibSafeOps.emitTxBuilderJson(safe, block.chainid, BUNDLE_NAME, txs); vm.writeFile(artifactPath, json); console2.log("==== TX BUILDER JSON BEGIN ===="); @@ -292,13 +365,57 @@ contract MigrateGovernanceToTimelock is Script { console2.log("Bundle MultiSend SafeTxHash:", vm.toString(bundleSafeTxHash)); console2.log("Nonce:", nonce); console2.log("Timelock:", vm.toString(timelock)); - console2.log("Vault transfers:", vaultTargets.length); - console2.log("Admin role grants:", grantRoles.length); - console2.log("Admin role renounces:", renounceRoles.length); + } - // --- Governance-loop proof ---------------------------------------- + /// @notice Assert the state the simulated bundle must have produced: + /// every production vault timelock-owned and still gated by the chain's + /// authoriser; every in-use beacon timelock-owned and still serving the + /// implementation it served before the bundle (the migration moves the + /// upgrade AUTHORITY, never the upgrade); the full grant map holding + /// with the timelock as admin holder and no `_ADMIN` copy left on the + /// Safe; Safe identity, threshold and timelock configuration unchanged. + /// @dev Split out of `run()` rather than inlined: `run()` already + /// carries the selection, bundle, nonce and artifact locals, and + /// `via_ir` is off for this repo (see CLAUDE.md — IR was measured and + /// made the vault bigger), so the post-state block's locals push the + /// function over the stack limit when inlined. + /// @param safe The chain's token-owner Safe. + /// @param timelock The chain's governance timelock. + /// @param authoriser The chain's authoriser clone. + /// @param tokens The chain's token table. + /// @param beaconImplsBefore The beacon implementations captured before + /// the bundle, index-aligned with `prodBeaconsForChainId`. + function _assertPostState( + IGnosisSafe safe, + address timelock, + address authoriser, + TokenInstance[] memory tokens, + address[3] memory beaconImplsBefore + ) internal view { + LibTokenInvariants.assertUniformOwnership(tokens, timelock); + LibTokenInvariants.assertUniformAuthoriser(tokens, authoriser); - _proveGovernanceLoop(safe, timelock, authoriser); + LibBeaconInvariants.assertProdBeaconsOwnedBy(block.chainid, timelock); + address[3] memory beaconsAfter = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + address[3] memory beaconImplsAfter = _beaconImplementations(); + for (uint256 i = 0; i < beaconsAfter.length; i++) { + if (beaconImplsAfter[i] != beaconImplsBefore[i]) { + revert BeaconImplementationMoved(beaconsAfter[i], beaconImplsBefore[i], beaconImplsAfter[i]); + } + } + + // Admin power moves, operational power stays. + LibAuthoriserInvariants.assertExpectedGrants(authoriser, address(safe), timelock); + bytes32[] memory roles = adminRoles(); + for (uint256 i = 0; i < roles.length; i++) { + if (IAccessControl(authoriser).hasRole(roles[i], address(safe))) { + revert UnexpectedAdminRoleState(roles[i]); + } + } + + LibSafeInvariants.assertImmutableInvariants(safe); + LibSafeInvariants.assertThreshold(safe, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD); + LibTimelockInvariants.assertTimelockState(timelock, address(safe)); } /// @notice Pre-flight the chain's authoriser: deployed with the pinned @@ -359,6 +476,63 @@ contract MigrateGovernanceToTimelock is Script { } } + /// @notice Select the beacon migration targets from live chain state: + /// every in-use production beacon still owned by the Safe. A beacon + /// already owned by the timelock is skipped; any other owner aborts the + /// authoring. + /// + /// Each candidate's runtime codehash is pinned to the OZ + /// `UpgradeableBeacon` bytecode FIRST, before its `owner()` is trusted: + /// the whole point of the codehash pin is that a matching beacon's + /// ownership and `upgradeTo` semantics are guaranteed by OZ's audit, so + /// transferring ownership on the strength of a `owner()` read from + /// unpinned bytecode would be reading a selector a look-alike could + /// shadow. + /// @param safe The chain's token-owner Safe. + /// @param timelock The chain's governance timelock. + /// @return targets The still-Safe-owned in-use beacons, in fixed + /// (receipt, receipt vault, wrapped token vault) order. + function _selectBeaconTargets(address safe, address timelock) internal view returns (address[] memory targets) { + address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + address[] memory candidates = new address[](beacons.length); + uint256 count = 0; + for (uint256 i = 0; i < beacons.length; i++) { + bytes32 codehash = beacons[i].codehash; + if (codehash != LibBeaconInvariants.UPGRADEABLE_BEACON_CODEHASH) { + revert MigrationBeaconCodehashMismatch( + beacons[i], LibBeaconInvariants.UPGRADEABLE_BEACON_CODEHASH, codehash + ); + } + address actual = Ownable(beacons[i]).owner(); + if (actual == timelock) { + continue; + } + if (actual != safe) { + revert UnexpectedBeaconOwner(beacons[i], actual); + } + candidates[count] = beacons[i]; + count++; + } + targets = new address[](count); + for (uint256 i = 0; i < count; i++) { + targets[i] = candidates[i]; + } + } + + /// @notice The implementation each in-use beacon currently serves, in + /// `prodBeaconsForChainId` order. Captured before the bundle and + /// re-read after so the migration can prove it moved ownership without + /// touching what the beacons point at — a beacon implementation change + /// would propagate to every production proxy on the chain. + /// @return impls The three current implementations, index-aligned with + /// `prodBeaconsForChainId`. + function _beaconImplementations() internal view returns (address[3] memory impls) { + address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + for (uint256 i = 0; i < beacons.length; i++) { + impls[i] = IBeacon(beacons[i]).implementation(); + } + } + /// @notice Select the role migration work from live chain state. Per /// `_ADMIN` role: Safe-only → grant + renounce; both (partial prior /// run) → renounce only; timelock-only → done, skip; neither → abort. @@ -404,46 +578,56 @@ contract MigrateGovernanceToTimelock is Script { } /// @notice Build the bundle in the safety-critical order: every grant - /// first, then every vault transfer, then every renounce — the Safe - /// gives nothing up until everything it is handing over has landed, and - /// the whole sequence executes atomically in one MultiSend. + /// first, then every vault transfer, then every beacon transfer, then + /// every renounce — the Safe gives nothing up until everything it is + /// handing over has landed, and the whole sequence executes atomically + /// in one MultiSend. /// @param authoriser The chain's authoriser clone. /// @param timelock The chain's governance timelock. /// @param safe The chain's token-owner Safe. - /// @param vaultTargets The still-Safe-owned receipt vaults. - /// @param grantRoles Roles to grant to the timelock. - /// @param renounceRoles Roles the Safe renounces. + /// @param targets The migration work selected from live chain state. /// @return txs The bundle transactions in execution order. - function _buildBundle( - address authoriser, - address timelock, - address safe, - address[] memory vaultTargets, - bytes32[] memory grantRoles, - bytes32[] memory renounceRoles - ) internal pure returns (SafeTx[] memory txs) { - txs = new SafeTx[](grantRoles.length + vaultTargets.length + renounceRoles.length); + function _buildBundle(address authoriser, address timelock, address safe, MigrationTargets memory targets) + internal + pure + returns (SafeTx[] memory txs) + { + txs = new SafeTx[]( + targets.grantRoles.length + targets.vaults.length + targets.beacons.length + targets.renounceRoles.length + ); uint256 t = 0; - for (uint256 i = 0; i < grantRoles.length; i++) { + for (uint256 i = 0; i < targets.grantRoles.length; i++) { txs[t] = SafeTx({ to: authoriser, value: 0, - data: abi.encodeCall(IAccessControl.grantRole, (grantRoles[i], timelock)), + data: abi.encodeCall(IAccessControl.grantRole, (targets.grantRoles[i], timelock)), operation: 0 }); t++; } - for (uint256 i = 0; i < vaultTargets.length; i++) { + for (uint256 i = 0; i < targets.vaults.length; i++) { txs[t] = SafeTx({ - to: vaultTargets[i], value: 0, data: abi.encodeCall(Ownable.transferOwnership, (timelock)), operation: 0 + to: targets.vaults[i], + value: 0, + data: abi.encodeCall(Ownable.transferOwnership, (timelock)), + operation: 0 + }); + t++; + } + for (uint256 i = 0; i < targets.beacons.length; i++) { + txs[t] = SafeTx({ + to: targets.beacons[i], + value: 0, + data: abi.encodeCall(Ownable.transferOwnership, (timelock)), + operation: 0 }); t++; } - for (uint256 i = 0; i < renounceRoles.length; i++) { + for (uint256 i = 0; i < targets.renounceRoles.length; i++) { txs[t] = SafeTx({ to: authoriser, value: 0, - data: abi.encodeCall(IAccessControl.renounceRole, (renounceRoles[i], safe)), + data: abi.encodeCall(IAccessControl.renounceRole, (targets.renounceRoles[i], safe)), operation: 0 }); t++; diff --git a/src/lib/LibBeaconInvariants.sol b/src/lib/LibBeaconInvariants.sol index 86cc8482..9921d641 100644 --- a/src/lib/LibBeaconInvariants.sol +++ b/src/lib/LibBeaconInvariants.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.25; import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; +import {LibMigrationInvariant} from "./LibMigrationInvariant.sol"; import {LibProdBeaconsBase} from "./LibProdBeaconsBase.sol"; import {LibProdBeacons0_1_1} from "./LibProdBeacons0_1_1.sol"; import {LibSafeInvariants} from "./LibSafeInvariants.sol"; @@ -213,8 +214,22 @@ library LibBeaconInvariants { /// pin's concern. /// @param chainId The active chain id (`block.chainid`). function assertProdBeaconsOwnedByChainSafe(uint256 chainId) internal view { + assertProdBeaconsOwnedBy(chainId, LibSafeInvariants.safeForChainId(chainId)); + } + + /// @notice Owner-parametric `assertProdBeaconsOwnedByChainSafe`: assert + /// the active chain's three IN-USE production beacons are deployed and + /// owned by `expectedOwner`. Parameterised because the beacon owner is a + /// principal an operational script deliberately mutates — the + /// governance-timelock migration moves it from the chain's Safe to the + /// chain's timelock — so the same iteration serves the pre-state + /// (Safe-owned), the post-state (timelock-owned), and the pre-flight of + /// the migration that moves it. + /// @param chainId The active chain id (`block.chainid`). + /// @param expectedOwner The address every in-use beacon must report as + /// `owner()`. + function assertProdBeaconsOwnedBy(uint256 chainId, address expectedOwner) internal view { address[3] memory beacons = prodBeaconsForChainId(chainId); - address expectedOwner = LibSafeInvariants.safeForChainId(chainId); for (uint256 i = 0; i < beacons.length; i++) { if (beacons[i].code.length == 0) { revert BeaconNotDeployed(beacons[i]); @@ -225,4 +240,39 @@ library LibBeaconInvariants { } } } + + /// @notice Migration-window variant of `assertProdBeaconsOwnedBy`: every + /// in-use production beacon on the chain must report `pre` OR `post` as + /// `owner()` before `deadline`, and exactly `post` at/after it. + /// + /// The beacon leg is the load-bearing half of the governance-timelock + /// migration. Whoever owns an in-use beacon can `upgradeTo` a new + /// implementation for EVERY production proxy on the chain in a single + /// transaction — which would let them re-take vault ownership and + /// rewrite the authoriser wiring outright. Leaving the beacons on the + /// Safe while vault ownership sits behind the timelock would make the + /// delay bypassable by design, so this surface migrates in the same + /// bundle and is forced by the same deadline. + /// @dev Mirrors `LibTokenInvariants.assertUniformOwnershipMigration` — + /// same two-valued window, same drift semantics (any third owner trips + /// `MigrationStateDrift` immediately, deadline notwithstanding). Where + /// the beacons POINT is deliberately not asserted here; the migration + /// script pins implementation immutability across its own bundle, and + /// cross-chain implementation parity is the parity pin's concern. + /// @param chainId The active chain id (`block.chainid`). + /// @param pre The accepted beacon owner before the migration runs. + /// @param post The accepted beacon owner after the migration runs. + /// @param deadline Unix timestamp past which only `post` is accepted. + function assertProdBeaconsOwnershipMigration(uint256 chainId, address pre, address post, uint256 deadline) + internal + view + { + address[3] memory beacons = prodBeaconsForChainId(chainId); + for (uint256 i = 0; i < beacons.length; i++) { + if (beacons[i].code.length == 0) { + revert BeaconNotDeployed(beacons[i]); + } + LibMigrationInvariant.assertMigration("beacon.owner()", IOwnable(beacons[i]).owner(), pre, post, deadline); + } + } } diff --git a/test/script/20260729-migrate-governance-to-timelock.t.sol b/test/script/20260729-migrate-governance-to-timelock.t.sol index 65dbba86..e053868a 100644 --- a/test/script/20260729-migrate-governance-to-timelock.t.sol +++ b/test/script/20260729-migrate-governance-to-timelock.t.sol @@ -5,6 +5,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; import {Ownable} from "@openzeppelin-contracts-5.6.1/access/Ownable.sol"; +import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; import {DeployGovernanceTimelockHarness} from "./DeployGovernanceTimelockHarness.sol"; @@ -12,10 +13,12 @@ import { MigrateGovernanceToTimelock, TimelockNotPinned, UnexpectedVaultOwner, + UnexpectedBeaconOwner, NothingToMigrate } from "../../script/20260729-migrate-governance-to-timelock.s.sol"; import {MigrateGovernanceToTimelockHarness} from "./MigrateGovernanceToTimelockHarness.sol"; import {LibAuthoriserInvariants, RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; +import {LibBeaconInvariants} from "../../src/lib/LibBeaconInvariants.sol"; import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol"; @@ -63,34 +66,91 @@ contract MigrateGovernanceToTimelockTest is Test { } /// @notice The full authoring against live Base state: post-state holds - /// (every vault timelock-owned, grant map on the timelock, Safe stripped - /// of `_ADMIN` roles) and the emitted artifact matches an independently - /// derived bundle — 7 grants, then every Safe-owned vault transfer, then - /// 7 renounces, in order. + /// (every vault AND every in-use beacon timelock-owned, grant map on the + /// timelock, Safe stripped of `_ADMIN` roles) and the emitted artifact + /// matches an independently derived bundle — 7 grants, then every + /// Safe-owned vault transfer, then every Safe-owned beacon transfer, + /// then 7 renounces, in order. function testRunAuthorsFullMigration() external { selectBaseFork(); address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; - address authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; address timelock = deployTimelock(); // Derive the expected target set from the PRE-run fork state — the // same derivation a reviewing signer performs. + uint256 expectedTransfers = _safeOwnedVaultCount(safe); + assertGt(expectedTransfers, 0, "fork state should have Safe-owned vaults pre-migration"); + uint256 expectedBeaconTransfers = _safeOwnedBeaconCount(safe); + assertGt(expectedBeaconTransfers, 0, "fork state should have Safe-owned beacons pre-migration"); + address[3] memory implsBefore = _beaconImplementations(); + + new MigrateGovernanceToTimelockHarness(timelock).run(); + + _assertPostState(safe, timelock, implsBefore); + _assertArtifactMatchesDerivedBundle(safe, timelock, expectedTransfers, expectedBeaconTransfers); + } + + /// @notice Count the production receipt vaults still owned by `safe` on + /// the active fork. + /// @param safe The chain's token-owner Safe. + /// @return count The Safe-owned vault count. + function _safeOwnedVaultCount(address safe) internal view returns (uint256 count) { address[] memory vaults = LibTokenInvariants.productionReceiptVaults(); - uint256 expectedTransfers = 0; for (uint256 i = 0; i < vaults.length; i++) { if (Ownable(vaults[i]).owner() == safe) { - expectedTransfers++; + count++; } } - assertGt(expectedTransfers, 0, "fork state should have Safe-owned vaults pre-migration"); + } - MigrateGovernanceToTimelockHarness script = new MigrateGovernanceToTimelockHarness(timelock); - script.run(); + /// @notice Count the in-use production beacons still owned by `safe` on + /// the active fork. + /// @param safe The chain's token-owner Safe. + /// @return count The Safe-owned beacon count. + function _safeOwnedBeaconCount(address safe) internal view returns (uint256 count) { + address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + for (uint256 i = 0; i < beacons.length; i++) { + if (Ownable(beacons[i]).owner() == safe) { + count++; + } + } + } + + /// @notice The implementation each in-use beacon currently serves, in + /// `prodBeaconsForChainId` order. + /// @return impls The three current implementations. + function _beaconImplementations() internal view returns (address[3] memory impls) { + address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + for (uint256 i = 0; i < beacons.length; i++) { + impls[i] = IBeacon(beacons[i]).implementation(); + } + } + + /// @notice Re-assert the migration post-state independently of the + /// script's own internal checks, so this test fails even if those rot: + /// every vault and beacon timelock-owned, beacon implementations + /// untouched, grant map on the timelock, no `_ADMIN` copy left on the + /// Safe. + /// @dev Split from the test body because `via_ir` is off for this repo + /// and the combined frame exceeds the legacy codegen's stack limit. + /// @param safe The chain's token-owner Safe. + /// @param timelock The chain's governance timelock. + /// @param implsBefore Beacon implementations captured pre-run. + function _assertPostState(address safe, address timelock, address[3] memory implsBefore) internal view { + address authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; - // Post-state on the fork (run() already asserted these internally; - // re-asserted here so the test fails even if the script's own - // post-state checks rot). LibTokenInvariants.assertUniformOwnership(timelock); + LibBeaconInvariants.assertProdBeaconsOwnedBy(block.chainid, timelock); + + address[3] memory implsAfter = _beaconImplementations(); + for (uint256 i = 0; i < implsAfter.length; i++) { + assertEq( + implsAfter[i], + implsBefore[i], + "migration must move beacon ownership without changing what the beacon serves" + ); + } + LibAuthoriserInvariants.assertExpectedGrants(authoriser, safe, timelock); RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(safe, timelock); for (uint256 i = 0; i < ADMIN_ROLE_COUNT; i++) { @@ -99,31 +159,56 @@ contract MigrateGovernanceToTimelockTest is Test { "Safe must not retain any _ADMIN role post-migration" ); } + } + + /// @notice Parse the emitted Tx Builder JSON and assert it matches the + /// bundle derived independently from the pre-run state: 7 grants, then + /// the vault transfers in table order, then the beacon transfers in + /// `prodBeaconsForChainId` order, then 7 renounces. + /// @param safe The chain's token-owner Safe. + /// @param timelock The chain's governance timelock. + /// @param expectedTransfers The pre-run Safe-owned vault count. + /// @param expectedBeaconTransfers The pre-run Safe-owned beacon count. + function _assertArtifactMatchesDerivedBundle( + address safe, + address timelock, + uint256 expectedTransfers, + uint256 expectedBeaconTransfers + ) internal { + address authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(safe, timelock); - // The emitted artifact round-trips and matches the derived bundle: - // grants -> transfers -> renounces. (uint256 chainId, address firstTarget, SafeTx[] memory txs) = LibSafeOps.parseTxBuilderJson( string.concat("out/20260729-governance-timelock-migration-", vm.toString(block.chainid), ".json") ); assertEq(chainId, LibSafeInvariants.BASE_CHAIN_ID); assertEq(firstTarget, authoriser, "bundle must open with the authoriser grants"); - assertEq(txs.length, ADMIN_ROLE_COUNT + expectedTransfers + ADMIN_ROLE_COUNT); + assertEq(txs.length, ADMIN_ROLE_COUNT + expectedTransfers + expectedBeaconTransfers + ADMIN_ROLE_COUNT); + for (uint256 i = 0; i < ADMIN_ROLE_COUNT; i++) { assertEq(txs[i].to, authoriser); assertEq(txs[i].data, abi.encodeCall(IAccessControl.grantRole, (grants[i].role, timelock))); } - uint256 transferCursor = ADMIN_ROLE_COUNT; - for (uint256 i = 0; i < vaults.length; i++) { - // Pre-run every vault was Safe-owned (asserted above via count - // == table walk); each appears as a transfer in table order. - if (transferCursor - ADMIN_ROLE_COUNT < expectedTransfers) { - assertEq(txs[transferCursor].to, vaults[i]); - assertEq(txs[transferCursor].data, abi.encodeCall(Ownable.transferOwnership, (timelock))); - transferCursor++; - } + + address[] memory vaults = LibTokenInvariants.productionReceiptVaults(); + for (uint256 i = 0; i < expectedTransfers; i++) { + // Pre-run every vault was Safe-owned (asserted via the count), + // so each appears as a transfer in table order. + assertEq(txs[ADMIN_ROLE_COUNT + i].to, vaults[i]); + assertEq(txs[ADMIN_ROLE_COUNT + i].data, abi.encodeCall(Ownable.transferOwnership, (timelock))); } - for (uint256 i = 0; i < ADMIN_ROLE_COUNT; i++) { + + // Beacon transfers follow the vaults — the leg that closes the + // upgrade-path bypass. + address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + for (uint256 i = 0; i < expectedBeaconTransfers; i++) { uint256 idx = ADMIN_ROLE_COUNT + expectedTransfers + i; + assertEq(txs[idx].to, beacons[i]); + assertEq(txs[idx].data, abi.encodeCall(Ownable.transferOwnership, (timelock))); + } + + for (uint256 i = 0; i < ADMIN_ROLE_COUNT; i++) { + uint256 idx = ADMIN_ROLE_COUNT + expectedTransfers + expectedBeaconTransfers + i; assertEq(txs[idx].to, authoriser); assertEq(txs[idx].data, abi.encodeCall(IAccessControl.renounceRole, (grants[i].role, safe))); } @@ -155,4 +240,20 @@ contract MigrateGovernanceToTimelockTest is Test { vm.expectRevert(abi.encodeWithSelector(UnexpectedVaultOwner.selector, vaults[3], stranger)); script.run(); } + + /// @notice A production beacon owned by neither the Safe nor the + /// timelock aborts the authoring, on the same terms as an unknown vault + /// owner. Beacons carry the upgrade authority for every production + /// proxy, so an unrecognised owner must never be papered over. + function testRunRejectsUnknownBeaconOwner() external { + selectBaseFork(); + address timelock = deployTimelock(); + address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + address stranger = address(0xDEAD); + vm.mockCall(beacons[1], abi.encodeWithSignature("owner()"), abi.encode(stranger)); + + MigrateGovernanceToTimelockHarness script = new MigrateGovernanceToTimelockHarness(timelock); + vm.expectRevert(abi.encodeWithSelector(UnexpectedBeaconOwner.selector, beacons[1], stranger)); + script.run(); + } } diff --git a/test/src/concrete/deploy/GovernanceTimelockMigration.t.sol b/test/src/concrete/deploy/GovernanceTimelockMigration.t.sol index e4b10117..9f2b33e0 100644 --- a/test/src/concrete/deploy/GovernanceTimelockMigration.t.sol +++ b/test/src/concrete/deploy/GovernanceTimelockMigration.t.sol @@ -7,6 +7,8 @@ import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessContro import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; import {LibAuthoriserInvariants, RoleGrant} from "../../../../src/lib/LibAuthoriserInvariants.sol"; +import {LibBeaconInvariants} from "../../../../src/lib/LibBeaconInvariants.sol"; +import {LibBeaconInvariantsHarness} from "../../lib/LibBeaconInvariantsHarness.sol"; import {LibMigrationInvariant, MigrationStateDrift} from "../../../../src/lib/LibMigrationInvariant.sol"; import {LibSafeInvariantsHarness} from "../../lib/LibSafeInvariantsHarness.sol"; import {LibTimelockInvariantsHarness} from "../../lib/LibTimelockInvariantsHarness.sol"; @@ -27,6 +29,12 @@ import {LibTokenInvariants, TokenInstance} from "../../../../src/lib/LibTokenInv /// timelock (migration landed), until /// `GOVERNANCE_TIMELOCK_MIGRATION_DEADLINE`; from then on only the /// timelock is accepted. +/// - **Beacon ownership** — the same window over the chain's three in-use +/// upgrade beacons. This is the leg that makes the delay real: a beacon +/// owner can repoint every production proxy on the chain in one +/// transaction, so a rollout that moved vault ownership but left the +/// beacons on the Safe would leave the timelock bypassable, and the +/// deadline must force both. /// - **Authoriser `_ADMIN` roles** — each of the seven `_ADMIN` roles is /// held EXCLUSIVELY by either the Safe (pending) or the timelock /// (landed). Split holding ("both") and orphaned roles ("neither") are @@ -99,6 +107,10 @@ contract GovernanceTimelockMigrationTest is Test { tokens, safe, timelock, GOVERNANCE_TIMELOCK_MIGRATION_DEADLINE ); + LibBeaconInvariants.assertProdBeaconsOwnershipMigration( + block.chainid, safe, timelock, GOVERNANCE_TIMELOCK_MIGRATION_DEADLINE + ); + bytes32[] memory roles = adminRoles(); IAccessControl acl = IAccessControl(authoriser); for (uint256 i = 0; i < roles.length; i++) { @@ -262,4 +274,32 @@ contract GovernanceTimelockMigrationTest is Test { tokens, safe, LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK, GOVERNANCE_TIMELOCK_MIGRATION_DEADLINE ); } + + /// @notice A beacon owned by neither side of the migration trips + /// `MigrationStateDrift` immediately, deadline notwithstanding — the + /// beacon leg is two-valued on exactly the same terms as the vault leg. + /// Beacons are the surface that can repoint every production proxy, so + /// an unrecognised owner here is the highest-severity drift the suite + /// can see. + function testBeaconOwnershipMigrationRejectsThirdOwner() external { + vm.createSelectFork(LibRainDeploy.BASE); + address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; + address stranger = address(0xBAD); + address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + vm.mockCall(beacons[0], abi.encodeWithSignature("owner()"), abi.encode(stranger)); + + LibBeaconInvariantsHarness harness = new LibBeaconInvariantsHarness(); + vm.expectRevert( + abi.encodeWithSelector( + MigrationStateDrift.selector, + "beacon.owner()", + bytes32(uint256(uint160(safe))), + bytes32(uint256(uint160(address(0)))), + bytes32(uint256(uint160(stranger))) + ) + ); + harness.callAssertProdBeaconsOwnershipMigration( + block.chainid, safe, LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK, GOVERNANCE_TIMELOCK_MIGRATION_DEADLINE + ); + } } diff --git a/test/src/lib/LibBeaconInvariantsHarness.sol b/test/src/lib/LibBeaconInvariantsHarness.sol index 58a7f3b7..77550b33 100644 --- a/test/src/lib/LibBeaconInvariantsHarness.sol +++ b/test/src/lib/LibBeaconInvariantsHarness.sol @@ -21,4 +21,15 @@ contract LibBeaconInvariantsHarness { function callAssertProdBeaconsOwnedByChainSafe(uint256 chainId) external view { LibBeaconInvariants.assertProdBeaconsOwnedByChainSafe(chainId); } + + function callAssertProdBeaconsOwnedBy(uint256 chainId, address expectedOwner) external view { + LibBeaconInvariants.assertProdBeaconsOwnedBy(chainId, expectedOwner); + } + + function callAssertProdBeaconsOwnershipMigration(uint256 chainId, address pre, address post, uint256 deadline) + external + view + { + LibBeaconInvariants.assertProdBeaconsOwnershipMigration(chainId, pre, post, deadline); + } } From 4092cd9eed45aba6dda323a8efb6a67a24c83c22 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 10 Aug 2026 09:03:05 +0000 Subject: [PATCH 02/12] test(script): prove the migration authors for HyperEVM, not just Base The chain-generic script was only ever exercised against Base, so HyperEVM coverage rested on the chain map resolving rather than on the bundle being correct. Parameterises the happy-path assertions by active chain (authoriser clone, token table, artifact chain id) and adds the HyperEVM leg through the same shared assertions, so neither chain is proven by a copy that can drift. Verified locally against live HyperEVM: 29 vault transfers, 3 beacon transfers, 7 grants, 7 renounces. Soft-skips in CI until HYPEREVM_RPC_URL is provisioned (RAI-1511). --- ...60729-migrate-governance-to-timelock.t.sol | 81 +++++++++++++++++-- 1 file changed, 73 insertions(+), 8 deletions(-) diff --git a/test/script/20260729-migrate-governance-to-timelock.t.sol b/test/script/20260729-migrate-governance-to-timelock.t.sol index e053868a..161a7616 100644 --- a/test/script/20260729-migrate-governance-to-timelock.t.sol +++ b/test/script/20260729-migrate-governance-to-timelock.t.sol @@ -21,9 +21,10 @@ import {LibAuthoriserInvariants, RoleGrant} from "../../src/lib/LibAuthoriserInv import {LibBeaconInvariants} from "../../src/lib/LibBeaconInvariants.sol"; import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; +import {LibStoxDeployNetworks} from "../../src/lib/LibStoxDeployNetworks.sol"; import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol"; import {LibTimelockInvariants} from "../../src/lib/LibTimelockInvariants.sol"; -import {LibTokenInvariants} from "../../src/lib/LibTokenInvariants.sol"; +import {LibTokenInvariants, TokenInstance} from "../../src/lib/LibTokenInvariants.sol"; /// @title MigrateGovernanceToTimelockTest /// @notice Live Base head fork coverage for the governance migration @@ -37,6 +38,45 @@ contract MigrateGovernanceToTimelockTest is Test { /// @notice Number of authoriser `_ADMIN` roles the migration moves. uint256 internal constant ADMIN_ROLE_COUNT = 7; + /// @notice The active fork's production authoriser clone, so the + /// post-state and artifact assertions run against whichever chain the + /// test selected rather than assuming Base. + function _activeChainAuthoriser() internal view returns (address) { + if (block.chainid == LibSafeInvariants.BASE_CHAIN_ID) { + return LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + } + if (block.chainid == LibSafeInvariants.ETHEREUM_CHAIN_ID) { + return LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM; + } + if (block.chainid == LibSafeInvariants.HYPEREVM_CHAIN_ID) { + return LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_HYPEREVM; + } + revert("unsupported chain in migration test"); + } + + /// @notice The active fork's production token table. + function _activeChainTokens() internal view returns (TokenInstance[] memory) { + if (block.chainid == LibSafeInvariants.BASE_CHAIN_ID) { + return LibTokenInvariants.productionTokensBase(); + } + if (block.chainid == LibSafeInvariants.ETHEREUM_CHAIN_ID) { + return LibTokenInvariants.productionTokensEthereum(); + } + if (block.chainid == LibSafeInvariants.HYPEREVM_CHAIN_ID) { + return LibTokenInvariants.productionTokensHyperEvm(); + } + revert("unsupported chain in migration test"); + } + + /// @notice The active fork's production receipt vaults, in table order. + function _activeChainVaults() internal view returns (address[] memory vaults) { + TokenInstance[] memory tokens = _activeChainTokens(); + vaults = new address[](tokens.length); + for (uint256 i = 0; i < tokens.length; i++) { + vaults[i] = tokens[i].receiptVault; + } + } + function selectBaseFork() internal { vm.createSelectFork(LibRainDeploy.BASE); } @@ -73,7 +113,32 @@ contract MigrateGovernanceToTimelockTest is Test { /// then 7 renounces, in order. function testRunAuthorsFullMigration() external { selectBaseFork(); - address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; + _assertAuthorsFullMigration(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + } + + /// @notice The same full authoring against live HyperEVM state. HyperEVM + /// carries 29 production tokens behind the same Safe / authoriser / + /// beacon shape, so the chain-generic script must author there on + /// identical terms — this is the assertion that proves HyperEVM is + /// actually covered rather than merely reachable through the chain map. + /// @dev Soft-skips while `HYPEREVM_RPC_URL` is unprovisioned in CI + /// (RAI-1511); run locally with the RPC set before executing the + /// HyperEVM bundle, since CI cannot prove this leg yet. + function testRunAuthorsFullMigrationOnHyperevm() external { + if (bytes(vm.envOr("HYPEREVM_RPC_URL", string(""))).length == 0) { + emit log("PENDING: HYPEREVM_RPC_URL not available in this environment (RAI-1511)"); + return; + } + vm.createSelectFork(LibStoxDeployNetworks.HYPEREVM); + _assertAuthorsFullMigration(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_HYPEREVM); + } + + /// @notice Drive the full authoring on whichever fork is selected and + /// assert both the post-state and that the emitted artifact matches an + /// independently derived bundle. Shared so every chain is proven by the + /// same assertions rather than a per-chain copy that could drift. + /// @param safe The active chain's token-owner Safe. + function _assertAuthorsFullMigration(address safe) internal { address timelock = deployTimelock(); // Derive the expected target set from the PRE-run fork state — the @@ -95,7 +160,7 @@ contract MigrateGovernanceToTimelockTest is Test { /// @param safe The chain's token-owner Safe. /// @return count The Safe-owned vault count. function _safeOwnedVaultCount(address safe) internal view returns (uint256 count) { - address[] memory vaults = LibTokenInvariants.productionReceiptVaults(); + address[] memory vaults = _activeChainVaults(); for (uint256 i = 0; i < vaults.length; i++) { if (Ownable(vaults[i]).owner() == safe) { count++; @@ -137,9 +202,9 @@ contract MigrateGovernanceToTimelockTest is Test { /// @param timelock The chain's governance timelock. /// @param implsBefore Beacon implementations captured pre-run. function _assertPostState(address safe, address timelock, address[3] memory implsBefore) internal view { - address authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + address authoriser = _activeChainAuthoriser(); - LibTokenInvariants.assertUniformOwnership(timelock); + LibTokenInvariants.assertUniformOwnership(_activeChainTokens(), timelock); LibBeaconInvariants.assertProdBeaconsOwnedBy(block.chainid, timelock); address[3] memory implsAfter = _beaconImplementations(); @@ -175,13 +240,13 @@ contract MigrateGovernanceToTimelockTest is Test { uint256 expectedTransfers, uint256 expectedBeaconTransfers ) internal { - address authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + address authoriser = _activeChainAuthoriser(); RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(safe, timelock); (uint256 chainId, address firstTarget, SafeTx[] memory txs) = LibSafeOps.parseTxBuilderJson( string.concat("out/20260729-governance-timelock-migration-", vm.toString(block.chainid), ".json") ); - assertEq(chainId, LibSafeInvariants.BASE_CHAIN_ID); + assertEq(chainId, block.chainid, "artifact must be authored for the active chain"); assertEq(firstTarget, authoriser, "bundle must open with the authoriser grants"); assertEq(txs.length, ADMIN_ROLE_COUNT + expectedTransfers + expectedBeaconTransfers + ADMIN_ROLE_COUNT); @@ -190,7 +255,7 @@ contract MigrateGovernanceToTimelockTest is Test { assertEq(txs[i].data, abi.encodeCall(IAccessControl.grantRole, (grants[i].role, timelock))); } - address[] memory vaults = LibTokenInvariants.productionReceiptVaults(); + address[] memory vaults = _activeChainVaults(); for (uint256 i = 0; i < expectedTransfers; i++) { // Pre-run every vault was Safe-owned (asserted via the count), // so each appears as a transfer in table order. From 93fe7dcdfc8ff2ae4f313b6bce43883aefb644c7 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 11 Aug 2026 09:54:08 +0000 Subject: [PATCH 03/12] test(invariants): expect the hydrated pin in the beacon drift test too --- test/src/concrete/deploy/GovernanceTimelockMigration.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/src/concrete/deploy/GovernanceTimelockMigration.t.sol b/test/src/concrete/deploy/GovernanceTimelockMigration.t.sol index 9f2b33e0..150d0782 100644 --- a/test/src/concrete/deploy/GovernanceTimelockMigration.t.sol +++ b/test/src/concrete/deploy/GovernanceTimelockMigration.t.sol @@ -294,7 +294,7 @@ contract GovernanceTimelockMigrationTest is Test { MigrationStateDrift.selector, "beacon.owner()", bytes32(uint256(uint160(safe))), - bytes32(uint256(uint160(address(0)))), + bytes32(uint256(uint160(LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK))), bytes32(uint256(uint160(stranger))) ) ); From 2c74d5c3af2b8dbb35e80530800a090612d96f4e Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 11 Aug 2026 11:51:35 +0000 Subject: [PATCH 04/12] test(lib): pin the beacon codehash re-declaration to its V1 original MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UPGRADEABLE_BEACON_CODEHASH is documented as equal to LibProdDeployV1.PROD_BEACON_BASE_RUNTIME_CODEHASH_V1 but the two copies were tied only transitively — separate fork suites asserting each against the same live beacons. The structural equality is now pinned directly, so a drifted copy surfaces as the source defect it is rather than as an on-chain codehash 'mismatch'. Co-Authored-By: Claude Fable 5 --- test/src/lib/LibBeaconInvariants.t.sol | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/src/lib/LibBeaconInvariants.t.sol b/test/src/lib/LibBeaconInvariants.t.sol index 082987f0..a5bde670 100644 --- a/test/src/lib/LibBeaconInvariants.t.sol +++ b/test/src/lib/LibBeaconInvariants.t.sol @@ -115,6 +115,17 @@ contract LibBeaconInvariantsTest is Test { ); } + /// @notice `UPGRADEABLE_BEACON_CODEHASH` is a re-declaration of + /// `LibProdDeployV1.PROD_BEACON_BASE_RUNTIME_CODEHASH_V1` (kept local so + /// the beacon invariant does not reach into the V1 deploy lib). Pin the + /// equality structurally: without this the two copies are tied only + /// transitively, through separate fork suites asserting each against the + /// same live beacons, and a drifted copy would surface as an on-chain + /// "mismatch" instead of the source defect it is. + function testUpgradeableBeaconCodehashMatchesV1DeployPin() external pure { + assertEq(LibBeaconInvariants.UPGRADEABLE_BEACON_CODEHASH, LibProdDeployV1.PROD_BEACON_BASE_RUNTIME_CODEHASH_V1); + } + /// @notice Base's IN-USE beacons are the V1-generation addresses. The /// later 0.1.1-address beacons exist on Base but production never adopted /// them, so returning those would assert ownership of a deploy artifact From 784ba588292b9615accad4f21eef99aedc4ff425 Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 11 Aug 2026 14:41:07 +0000 Subject: [PATCH 05/12] test(script): Ethereum authoring parity + migrate-away successor proof Two coverage gaps on the governance loop: - The full authoring was proven on Base and HyperEVM but never Ethereum, which shares HyperEVM's Safe and timelock yet has its own token table and authoriser clone. testRunAuthorsFullMigrationOnEthereum drives the shared assertion set on a live Ethereum fork, so all three governed chains carry the same proof. - Nothing proved the timelock is not a one-way door. testGovernanceCanMigrateAwayToSuccessor executes the full migration on a Base fork, then hands every surface class onward to a successor principal through the real schedule -> 48h -> execute loop, each leg driven through the Safe's threshold-gated execTransaction (n+1 walk): a vault ownership transfer, a beacon ownership transfer, an authoriser _ADMIN handover with the timelock renouncing its own copy, and a role change on the timelock itself via self-administration. Each leg is proven pending-not-ready inside the window and Done after it. Co-Authored-By: Claude Fable 5 --- ...60729-migrate-governance-to-timelock.t.sol | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/test/script/20260729-migrate-governance-to-timelock.t.sol b/test/script/20260729-migrate-governance-to-timelock.t.sol index 161a7616..fa407fd3 100644 --- a/test/script/20260729-migrate-governance-to-timelock.t.sol +++ b/test/script/20260729-migrate-governance-to-timelock.t.sol @@ -5,6 +5,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; import {Ownable} from "@openzeppelin-contracts-5.6.1/access/Ownable.sol"; +import {TimelockController} from "@openzeppelin-contracts-5.6.1/governance/TimelockController.sol"; import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; @@ -22,6 +23,7 @@ import {LibBeaconInvariants} from "../../src/lib/LibBeaconInvariants.sol"; import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; import {LibStoxDeployNetworks} from "../../src/lib/LibStoxDeployNetworks.sol"; +import {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol"; import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol"; import {LibTimelockInvariants} from "../../src/lib/LibTimelockInvariants.sol"; import {LibTokenInvariants, TokenInstance} from "../../src/lib/LibTokenInvariants.sol"; @@ -133,6 +135,16 @@ contract MigrateGovernanceToTimelockTest is Test { _assertAuthorsFullMigration(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_HYPEREVM); } + /// @notice The same full authoring against live Ethereum state, so all + /// three governed chains carry the same proof — Ethereum shares + /// HyperEVM's Safe and timelock but has its own token table and + /// authoriser clone, and chain-generic code is only proven generic by + /// running it on every chain it claims. + function testRunAuthorsFullMigrationOnEthereum() external { + vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); + _assertAuthorsFullMigration(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM); + } + /// @notice Drive the full authoring on whichever fork is selected and /// assert both the post-state and that the emitted artifact matches an /// independently derived bundle. Shared so every chain is proven by the @@ -321,4 +333,86 @@ contract MigrateGovernanceToTimelockTest is Test { vm.expectRevert(abi.encodeWithSelector(UnexpectedBeaconOwner.selector, beacons[1], stranger)); script.run(); } + + /// @notice The timelock is not a one-way door: every surface class the + /// migration hands to it can be handed onward to a successor governance + /// principal through the same schedule -> 48h -> execute loop, each leg + /// driven through the Safe's threshold-gated `execTransaction` (the n+1 + /// walk, so undersigned attempts are proven rejected). Walks a vault + /// ownership transfer, a beacon ownership transfer, an authoriser + /// `_ADMIN` handover (grant to successor, then the timelock renounces + /// its own copy), and a role change on the timelock itself (successor + /// granted proposer through self-administration). + function testGovernanceCanMigrateAwayToSuccessor() external { + selectBaseFork(); + address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; + address authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + address timelock = deployTimelock(); + new MigrateGovernanceToTimelockHarness(timelock).run(); + + address successor = address(0x5000000000000000000000000000000000000001); + address vault = _activeChainVaults()[0]; + address beacon = LibBeaconInvariants.prodBeaconsForChainId(block.chainid)[0]; + bytes32 adminRole = LibAuthoriserInvariants.expectedGrants(safe, timelock)[0].role; + + scheduleAndExecuteViaTimelock(safe, timelock, vault, abi.encodeCall(Ownable.transferOwnership, (successor))); + scheduleAndExecuteViaTimelock(safe, timelock, beacon, abi.encodeCall(Ownable.transferOwnership, (successor))); + scheduleAndExecuteViaTimelock( + safe, timelock, authoriser, abi.encodeCall(IAccessControl.grantRole, (adminRole, successor)) + ); + scheduleAndExecuteViaTimelock( + safe, timelock, authoriser, abi.encodeCall(IAccessControl.renounceRole, (adminRole, timelock)) + ); + scheduleAndExecuteViaTimelock( + safe, + timelock, + timelock, + abi.encodeCall(IAccessControl.grantRole, (LibTimelockInvariants.TIMELOCK_PROPOSER_ROLE, successor)) + ); + + assertEq(Ownable(vault).owner(), successor, "vault ownership must be transferable onward"); + assertEq(Ownable(beacon).owner(), successor, "beacon ownership must be transferable onward"); + assertTrue( + IAccessControl(authoriser).hasRole(adminRole, successor), "successor must hold the handed-over _ADMIN" + ); + assertFalse( + IAccessControl(authoriser).hasRole(adminRole, timelock), "timelock must be able to renounce its _ADMIN" + ); + assertTrue( + IAccessControl(timelock).hasRole(LibTimelockInvariants.TIMELOCK_PROPOSER_ROLE, successor), + "self-administration must provision a successor proposer" + ); + } + + /// @notice One governance-loop leg: schedule the call on the timelock + /// through the Safe's threshold-gated exec path, prove it pending but + /// not ready inside the window, warp out the delay, execute the same + /// way, and prove the operation Done. Salted by the payload so + /// consecutive legs never collide. + function scheduleAndExecuteViaTimelock(address safe, address timelock, address target, bytes memory data) internal { + TimelockController controller = TimelockController(payable(timelock)); + bytes32 salt = keccak256(abi.encode("migrate-away-proof", target, data)); + bytes32 id = controller.hashOperation(target, 0, data, bytes32(0), salt); + + LibSafeOps.simulateNPlus1( + IGnosisSafe(safe), + timelock, + abi.encodeCall( + TimelockController.schedule, + (target, 0, data, bytes32(0), salt, LibTimelockInvariants.TIMELOCK_MIN_DELAY) + ), + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD + ); + assertTrue(controller.isOperationPending(id), "leg must be pending after schedule"); + assertFalse(controller.isOperationReady(id), "leg must not be executable inside the delay"); + + vm.warp(block.timestamp + LibTimelockInvariants.TIMELOCK_MIN_DELAY); + LibSafeOps.simulateNPlus1( + IGnosisSafe(safe), + timelock, + abi.encodeCall(TimelockController.execute, (target, 0, data, bytes32(0), salt)), + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD + ); + assertTrue(controller.isOperationDone(id), "leg must complete after the delay"); + } } From d8a38650d29211805bc03c7d9f322ba72947b9e8 Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 11 Aug 2026 14:47:47 +0000 Subject: [PATCH 06/12] test(script): a beacon upgrade runs through the timelock, and only through it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upgrade path itself now has a literal proof: post-migration a direct Safe upgradeTo reverts OwnableUnauthorizedAccount (the instant path is closed), and the sanctioned path — schedule upgradeTo through the Safe's threshold-gated exec, 48h, execute — completes with the beacon still serving the same implementation (idempotent upgradeTo(currentImpl)). Co-Authored-By: Claude Fable 5 --- ...60729-migrate-governance-to-timelock.t.sol | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/script/20260729-migrate-governance-to-timelock.t.sol b/test/script/20260729-migrate-governance-to-timelock.t.sol index fa407fd3..b0745553 100644 --- a/test/script/20260729-migrate-governance-to-timelock.t.sol +++ b/test/script/20260729-migrate-governance-to-timelock.t.sol @@ -7,6 +7,7 @@ import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessContro import {Ownable} from "@openzeppelin-contracts-5.6.1/access/Ownable.sol"; import {TimelockController} from "@openzeppelin-contracts-5.6.1/governance/TimelockController.sol"; import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; +import {UpgradeableBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/UpgradeableBeacon.sol"; import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; import {DeployGovernanceTimelockHarness} from "./DeployGovernanceTimelockHarness.sol"; @@ -384,6 +385,31 @@ contract MigrateGovernanceToTimelockTest is Test { ); } + /// @notice A beacon UPGRADE runs through the timelock: post-migration + /// the only path to `upgradeTo` is schedule -> 48h -> execute through + /// the Safe's threshold gate, proven literally with an idempotent + /// `upgradeTo(currentImpl)` — the operation completes, the beacon still + /// serves the same implementation, and no faster path exists (a direct + /// Safe call must revert: the Safe no longer owns the beacon). + function testBeaconUpgradeRunsThroughTimelock() external { + selectBaseFork(); + address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; + address timelock = deployTimelock(); + new MigrateGovernanceToTimelockHarness(timelock).run(); + + address beacon = LibBeaconInvariants.prodBeaconsForChainId(block.chainid)[0]; + address impl = IBeacon(beacon).implementation(); + + // The instant path is closed: the Safe is not the owner any more. + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, safe)); + vm.prank(safe); + UpgradeableBeacon(beacon).upgradeTo(impl); + + // The sanctioned path works end-to-end. + scheduleAndExecuteViaTimelock(safe, timelock, beacon, abi.encodeWithSignature("upgradeTo(address)", impl)); + assertEq(IBeacon(beacon).implementation(), impl, "idempotent upgrade must leave the implementation in place"); + } + /// @notice One governance-loop leg: schedule the call on the timelock /// through the Safe's threshold-gated exec path, prove it pending but /// not ready inside the window, warp out the delay, execute the same From 117905b7521f288d4672d33e823d468f8d0934ac Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 11 Aug 2026 14:50:37 +0000 Subject: [PATCH 07/12] test(script): setAuthorizer runs through the timelock, and only through it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surface that rewires which contract gates every deposit, withdrawal and transfer was asserted as STATE everywhere (authorizer() uniformity) but the PATH had no proof. Post-migration a direct Safe setAuthorizer reverts OwnableUnauthorizedAccount, and the sanctioned schedule -> 48h -> execute path — each leg through the Safe's threshold-gated exec (n+1 walk) — completes, proven with an idempotent re-set of the current authoriser and the wiring asserted unchanged. Co-Authored-By: Claude Fable 5 --- ...60729-migrate-governance-to-timelock.t.sol | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/script/20260729-migrate-governance-to-timelock.t.sol b/test/script/20260729-migrate-governance-to-timelock.t.sol index b0745553..26115a4c 100644 --- a/test/script/20260729-migrate-governance-to-timelock.t.sol +++ b/test/script/20260729-migrate-governance-to-timelock.t.sol @@ -24,6 +24,10 @@ import {LibBeaconInvariants} from "../../src/lib/LibBeaconInvariants.sol"; import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; import {LibStoxDeployNetworks} from "../../src/lib/LibStoxDeployNetworks.sol"; +import {OffchainAssetReceiptVault} from "rain-vats-0.1.6/src/concrete/vault/OffchainAssetReceiptVault.sol"; +import {IAuthorizeV1} from "rain-vats-0.1.6/src/interface/IAuthorizeV1.sol"; + +import {IAuthorisable} from "../../src/interface/IAuthorisable.sol"; import {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol"; import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol"; import {LibTimelockInvariants} from "../../src/lib/LibTimelockInvariants.sol"; @@ -410,6 +414,34 @@ contract MigrateGovernanceToTimelockTest is Test { assertEq(IBeacon(beacon).implementation(), impl, "idempotent upgrade must leave the implementation in place"); } + /// @notice `setAuthorizer` — the vault surface that rewires which + /// contract gates every deposit, withdrawal and transfer — runs through + /// the timelock, and only through it: post-migration a direct Safe call + /// reverts `OwnableUnauthorizedAccount`, and the sanctioned schedule -> + /// 48h -> execute path (each leg n+1 threshold-gated) completes, proven + /// with an idempotent re-set of the current authoriser so the wiring is + /// asserted unchanged. + function testSetAuthorizerRunsThroughTimelock() external { + selectBaseFork(); + address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; + address authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + address timelock = deployTimelock(); + new MigrateGovernanceToTimelockHarness(timelock).run(); + + address vault = _activeChainVaults()[0]; + + // The instant path is closed: the Safe no longer owns the vault. + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, safe)); + vm.prank(safe); + OffchainAssetReceiptVault(payable(vault)).setAuthorizer(IAuthorizeV1(authoriser)); + + // The sanctioned path works end-to-end and leaves the wiring intact. + scheduleAndExecuteViaTimelock( + safe, timelock, vault, abi.encodeCall(OffchainAssetReceiptVault.setAuthorizer, (IAuthorizeV1(authoriser))) + ); + assertEq(IAuthorisable(vault).authorizer(), authoriser, "idempotent re-set must leave the authoriser in place"); + } + /// @notice One governance-loop leg: schedule the call on the timelock /// through the Safe's threshold-gated exec path, prove it pending but /// not ready inside the window, warp out the delay, execute the same From e79c442511d65da482ee036c42c621d1a661b93d Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 11 Aug 2026 15:28:18 +0000 Subject: [PATCH 08/12] feat(script): signer-side verify(string) closes the authoring-to-signing window The last unguarded step in the migration was between CI authoring the bundle and signers executing it: the SafeTxHash cross-check caught a different bundle, but nothing re-checked the artifact against LIVE state at signing time. verify(string) does, locally on the signer's machine: re-runs run()'s full pre-flight, re-derives the expected bundle from current chain state, asserts the downloaded artifact matches byte-exactly (typed MigrationVerifyMismatch pinpoints the first divergent field), and recomputes the canonical MultiSend SafeTxHash at the live nonce for the Safe-UI cross-check. A nonce bump, a moved vault/beacon/role, or a tampered or stale artifact all surface before anyone signs. Deliberately not in the run-script dispatcher: it takes a local path and runs on the signer's machine, per the established verify(string) convention. The runbook's sign-and-execute step now instructs each signer to run it. Co-Authored-By: Claude Fable 5 --- docs/TIMELOCK.md | 9 ++- ...60729-migrate-governance-to-timelock.s.sol | 72 +++++++++++++++++++ ...60729-migrate-governance-to-timelock.t.sol | 34 ++++++++- 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/docs/TIMELOCK.md b/docs/TIMELOCK.md index 71104c27..6f22ae68 100644 --- a/docs/TIMELOCK.md +++ b/docs/TIMELOCK.md @@ -107,8 +107,13 @@ timelocks live. 48h → execute proof on the fork. The logged MultiSend `SafeTxHash` is the signer cross-check. 4. **Sign + execute** — import the CI-authored artifact into the Safe UI (never - a locally generated JSON), verify the hash, execute. The bundle is atomic: 7 - `_ADMIN` grants to the timelock → N vault `transferOwnership` → 3 beacon + a locally generated JSON). Before signing, each signer runs the local + integrity check against a live fork — + `forge script script/20260729-migrate-governance-to-timelock.s.sol --sig 'verify(string)' --rpc-url ` + — which re-derives the bundle from current chain state, asserts the artifact + matches byte-exactly, and prints the MultiSend `SafeTxHash` at the live nonce + to cross-check in the Safe UI. Then execute. The bundle is atomic: 7 `_ADMIN` + grants to the timelock → N vault `transferOwnership` → 3 beacon `transferOwnership` → 7 Safe renounces. 5. **Post-execution flip PR** — mark the migration script `**EXECUTED YYYY-MM-DD.**`, repoint the strict uniform-ownership invariants diff --git a/script/20260729-migrate-governance-to-timelock.s.sol b/script/20260729-migrate-governance-to-timelock.s.sol index 0c9ee93c..d55da677 100644 --- a/script/20260729-migrate-governance-to-timelock.s.sol +++ b/script/20260729-migrate-governance-to-timelock.s.sol @@ -114,6 +114,13 @@ struct MigrationTargets { bytes32[] renounceRoles; } +/// @notice A field of the artifact under signer-side verification does not +/// match the bundle re-derived from CURRENT live chain state. The artifact +/// is stale (state moved since authoring), tampered, or authored for a +/// different chain — either way it must not be signed. +/// @param field The first mismatching field. +error MigrationVerifyMismatch(string field); + /// @notice The end-to-end governance-loop proof did not leave the scheduled /// operation in the `Done` state — the schedule → delay → execute path a /// future admin action must take does not work against the post-migration @@ -338,6 +345,71 @@ contract MigrateGovernanceToTimelock is Script { _proveGovernanceLoop(safe, timelock, authoriser); } + /// @notice Signer-side integrity check for a CI-authored migration + /// artifact, run LOCALLY against a live fork before signing: re-runs + /// `run()`'s full pre-flight, re-derives the expected bundle from + /// CURRENT live chain state, asserts the artifact at `jsonPath` + /// matches it byte-exactly, and recomputes the canonical MultiSend + /// `SafeTxHash` at the live nonce for the Safe-UI cross-check. Any + /// drift between authoring and signing — a nonce bump from an executed + /// Safe tx, a vault/beacon/role that moved, a stale or tampered + /// artifact — surfaces as a typed mismatch before anyone signs. + /// Deliberately NOT in the run-script dispatcher: it takes a local + /// path and runs on the signer's machine. + /// @dev The governance-loop proof is not repeated here (it mutates + /// fork state and `verify` is view); it ran at authoring against the + /// same bundle this check proves byte-identical. + /// @param jsonPath Filesystem path to the downloaded Tx Builder JSON. + function verify(string calldata jsonPath) external view { + IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid)); + address timelock = activeChainTimelock(); + if (timelock == address(0)) revert TimelockNotPinned(block.chainid); + LibTimelockInvariants.assertTimelockState(timelock, address(safe)); + address authoriser = activeChainAuthoriser(); + _preflightAuthoriser(authoriser, address(safe), timelock); + + MigrationTargets memory targets; + targets.vaults = _selectVaultTargets(tokensForActiveChain(), address(safe), timelock); + targets.beacons = _selectBeaconTargets(address(safe), timelock); + (targets.grantRoles, targets.renounceRoles) = _selectRoleTargets(authoriser, address(safe), timelock); + if ( + targets.vaults.length == 0 && targets.beacons.length == 0 && targets.grantRoles.length == 0 + && targets.renounceRoles.length == 0 + ) { + revert NothingToMigrate(); + } + + _verifyArtifact(safe, _buildBundle(authoriser, timelock, address(safe), targets), jsonPath); + } + + /// @notice Compare a parsed artifact against the live-derived bundle + /// field by field, then log the canonical MultiSend `SafeTxHash` at the + /// live nonce for the Safe-UI cross-check. Split from `verify` for the + /// same legacy-codegen stack-limit reason as `_assertPostState`. + /// @param safe The chain's token-owner Safe. + /// @param expected The bundle derived from live state. + /// @param jsonPath Filesystem path to the artifact under verification. + function _verifyArtifact(IGnosisSafe safe, SafeTx[] memory expected, string calldata jsonPath) internal view { + (uint256 parsedChainId, address parsedFirstTarget, SafeTx[] memory parsed) = + LibSafeOps.parseTxBuilderJson(jsonPath); + if (parsedChainId != block.chainid) revert MigrationVerifyMismatch("chainId"); + if (parsed.length != expected.length) revert MigrationVerifyMismatch("txCount"); + if (parsedFirstTarget != expected[0].to) revert MigrationVerifyMismatch("firstTarget"); + for (uint256 i = 0; i < expected.length; i++) { + if (parsed[i].to != expected[i].to) revert MigrationVerifyMismatch("to"); + if (parsed[i].value != expected[i].value) revert MigrationVerifyMismatch("value"); + if (parsed[i].operation != expected[i].operation) revert MigrationVerifyMismatch("operation"); + if (keccak256(parsed[i].data) != keccak256(expected[i].data)) revert MigrationVerifyMismatch("data"); + } + + uint256 nonce = safe.nonce(); + console2.log("Artifact verified against live state."); + console2.log( + "Bundle MultiSend SafeTxHash:", vm.toString(LibSafeOps.computeMultiSendSafeTxHash(safe, expected, nonce)) + ); + console2.log("Nonce:", nonce); + } + /// @notice Write the Safe Tx Builder JSON for the bundle and log it /// alongside the values a signer cross-checks in the Safe UI: the /// canonical MultiSend `SafeTxHash`, the nonce it was computed against, diff --git a/test/script/20260729-migrate-governance-to-timelock.t.sol b/test/script/20260729-migrate-governance-to-timelock.t.sol index 26115a4c..9480210a 100644 --- a/test/script/20260729-migrate-governance-to-timelock.t.sol +++ b/test/script/20260729-migrate-governance-to-timelock.t.sol @@ -16,7 +16,8 @@ import { TimelockNotPinned, UnexpectedVaultOwner, UnexpectedBeaconOwner, - NothingToMigrate + NothingToMigrate, + MigrationVerifyMismatch } from "../../script/20260729-migrate-governance-to-timelock.s.sol"; import {MigrateGovernanceToTimelockHarness} from "./MigrateGovernanceToTimelockHarness.sol"; import {LibAuthoriserInvariants, RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; @@ -442,6 +443,37 @@ contract MigrateGovernanceToTimelockTest is Test { assertEq(IAuthorisable(vault).authorizer(), authoriser, "idempotent re-set must leave the authoriser in place"); } + /// @notice The signer-side `verify(string)` accepts a freshly authored + /// artifact against a fresh fork of the same live state, and rejects a + /// tampered copy with the exact mismatching field. Authoring simulates + /// the migration onto its own fork, so verification runs on a NEW fork + /// — exactly the signer's vantage point: artifact in hand, live chain + /// untouched. + function testVerifyAcceptsAuthoredArtifactAndRejectsTamper() external { + selectBaseFork(); + address timelock = deployTimelock(); + new MigrateGovernanceToTimelockHarness(timelock).run(); + string memory path = + string.concat("out/20260729-governance-timelock-migration-", vm.toString(block.chainid), ".json"); + + // Fresh fork: authoring mutated the previous fork's state; the live + // chain a signer verifies against is untouched. + selectBaseFork(); + assertEq(deployTimelock(), timelock, "derived timelock must be stable across forks"); + MigrateGovernanceToTimelockHarness verifier = new MigrateGovernanceToTimelockHarness(timelock); + verifier.verify(path); + + // Tamper one call's payload and re-emit: verify pinpoints the field. + (,, SafeTx[] memory txs) = LibSafeOps.parseTxBuilderJson(path); + txs[0].data = abi.encodeCall(IAccessControl.grantRole, (bytes32(uint256(1)), address(0xBAD))); + vm.writeFile( + "out/tampered-migration.json", + LibSafeOps.emitTxBuilderJson(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE, block.chainid, "tampered", txs) + ); + vm.expectRevert(abi.encodeWithSelector(MigrationVerifyMismatch.selector, "data")); + verifier.verify("out/tampered-migration.json"); + } + /// @notice One governance-loop leg: schedule the call on the timelock /// through the Safe's threshold-gated exec path, prove it pending but /// not ready inside the window, warp out the delay, execute the same From fb4bd38cac2ca29f55aacfa0ac6b106fb03a8f5b Mon Sep 17 00:00:00 2001 From: David Meister Date: Wed, 12 Aug 2026 14:19:04 +0000 Subject: [PATCH 09/12] fix(script): name the beacon leg in the signer-visible bundle title The Tx Builder meta.name signers read in the Safe UI still described the bundle as vault ownership + authoriser admin roles; the bundle also transfers the three in-use upgrade beacons. Co-Authored-By: Claude Fable 5 --- script/20260729-migrate-governance-to-timelock.s.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/script/20260729-migrate-governance-to-timelock.s.sol b/script/20260729-migrate-governance-to-timelock.s.sol index d55da677..629f21e9 100644 --- a/script/20260729-migrate-governance-to-timelock.s.sol +++ b/script/20260729-migrate-governance-to-timelock.s.sol @@ -213,7 +213,8 @@ contract MigrateGovernanceToTimelock is Script { /// @notice Human-readable name embedded in the emitted Tx Builder /// JSON's `meta.name`. Visible to signers in the Safe Tx Builder UI. - string internal constant BUNDLE_NAME = "ST0x governance to timelock: vault ownership + authoriser admin roles"; + string internal constant BUNDLE_NAME = + "ST0x governance to timelock: vault ownership + beacon ownership + authoriser admin roles"; /// @notice Salt for the governance-loop proof operation. Any constant /// works — the proof runs only in simulation. From 2b5e7d6977e2c88db3369d9a2d02117257c3d31d Mon Sep 17 00:00:00 2001 From: David Meister Date: Wed, 12 Aug 2026 14:19:04 +0000 Subject: [PATCH 10/12] fix(lib): pin the beacon codehash before every sweep's owner() read The migration script's selection pins the OZ UpgradeableBeacon codehash before trusting owner(); the ownership sweep and the migration-window drift detector read owner() after only a code-presence check, so a look-alike beacon shadowing the selector could pass either. Both loops now share one deployment+codehash gate, and inverted fork tests prove a look-alike reporting an accepted owner trips BeaconCodehashMismatch before the owner read is consulted. Co-Authored-By: Claude Fable 5 --- src/lib/LibBeaconInvariants.sol | 44 ++++++++++++++----- test/src/lib/LibBeaconInvariants.t.sol | 60 ++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/src/lib/LibBeaconInvariants.sol b/src/lib/LibBeaconInvariants.sol index 9921d641..196a9bcf 100644 --- a/src/lib/LibBeaconInvariants.sol +++ b/src/lib/LibBeaconInvariants.sol @@ -225,15 +225,18 @@ library LibBeaconInvariants { /// chain's timelock — so the same iteration serves the pre-state /// (Safe-owned), the post-state (timelock-owned), and the pre-flight of /// the migration that moves it. + /// Each beacon's runtime codehash is pinned to the OZ + /// `UpgradeableBeacon` bytecode BEFORE its `owner()` read is trusted — + /// the same trust order the migration script's selection applies, and + /// for the same reason: a look-alike contract can shadow the `owner()` + /// selector and report whatever owner passes this check. /// @param chainId The active chain id (`block.chainid`). /// @param expectedOwner The address every in-use beacon must report as /// `owner()`. function assertProdBeaconsOwnedBy(uint256 chainId, address expectedOwner) internal view { address[3] memory beacons = prodBeaconsForChainId(chainId); for (uint256 i = 0; i < beacons.length; i++) { - if (beacons[i].code.length == 0) { - revert BeaconNotDeployed(beacons[i]); - } + _assertDeployedPinnedBeacon(beacons[i]); address actualOwner = IOwnable(beacons[i]).owner(); if (actualOwner != expectedOwner) { revert BeaconOwnerMismatch(beacons[i], expectedOwner, actualOwner); @@ -255,10 +258,16 @@ library LibBeaconInvariants { /// bundle and is forced by the same deadline. /// @dev Mirrors `LibTokenInvariants.assertUniformOwnershipMigration` — /// same two-valued window, same drift semantics (any third owner trips - /// `MigrationStateDrift` immediately, deadline notwithstanding). Where - /// the beacons POINT is deliberately not asserted here; the migration - /// script pins implementation immutability across its own bundle, and - /// cross-chain implementation parity is the parity pin's concern. + /// `MigrationStateDrift` immediately, deadline notwithstanding). Each + /// beacon's runtime codehash is pinned to the OZ `UpgradeableBeacon` + /// bytecode BEFORE its `owner()` read is trusted, on the same grounds + /// as `assertProdBeaconsOwnedBy`: this is the cron-facing drift + /// detector for the surface, and a look-alike beacon shadowing + /// `owner()` must surface as a codehash break, not pass as migrated. + /// Where the beacons POINT is deliberately not asserted here; the + /// migration script pins implementation immutability across its own + /// bundle, and cross-chain implementation parity is the parity pin's + /// concern. /// @param chainId The active chain id (`block.chainid`). /// @param pre The accepted beacon owner before the migration runs. /// @param post The accepted beacon owner after the migration runs. @@ -269,10 +278,25 @@ library LibBeaconInvariants { { address[3] memory beacons = prodBeaconsForChainId(chainId); for (uint256 i = 0; i < beacons.length; i++) { - if (beacons[i].code.length == 0) { - revert BeaconNotDeployed(beacons[i]); - } + _assertDeployedPinnedBeacon(beacons[i]); LibMigrationInvariant.assertMigration("beacon.owner()", IOwnable(beacons[i]).owner(), pre, post, deadline); } } + + /// @notice Deployment + codehash gate shared by the in-use-beacon + /// sweeps: the address must carry runtime code, and that code must be + /// the pinned OZ `UpgradeableBeacon` bytecode. Runs before any + /// `owner()` read is trusted — a look-alike contract could shadow the + /// selector and report whatever owner passes the caller's check, so + /// the pin is what makes the subsequent read meaningful. + /// @param beacon The beacon to gate. + function _assertDeployedPinnedBeacon(address beacon) private view { + if (beacon.code.length == 0) { + revert BeaconNotDeployed(beacon); + } + bytes32 actualCodehash = beacon.codehash; + if (actualCodehash != UPGRADEABLE_BEACON_CODEHASH) { + revert BeaconCodehashMismatch(beacon, UPGRADEABLE_BEACON_CODEHASH, actualCodehash); + } + } } diff --git a/test/src/lib/LibBeaconInvariants.t.sol b/test/src/lib/LibBeaconInvariants.t.sol index a5bde670..b0ef6eda 100644 --- a/test/src/lib/LibBeaconInvariants.t.sol +++ b/test/src/lib/LibBeaconInvariants.t.sol @@ -197,4 +197,64 @@ contract LibBeaconInvariantsTest is Test { vm.expectRevert(abi.encodeWithSelector(BeaconNotDeployed.selector, beacon)); harness.callAssertProdBeaconsOwnedByChainSafe(LibSafeInvariants.BASE_CHAIN_ID); } + + /// @notice An in-use beacon whose runtime code drifts from the pinned OZ + /// `UpgradeableBeacon` bytecode trips `BeaconCodehashMismatch` BEFORE its + /// `owner()` is read: the look-alike here mocks `owner()` to the expected + /// Safe, so only the codehash pin stands between it and a pass. Without + /// the pin, a swapped beacon shadowing `owner()` satisfies the ownership + /// sweep while serving arbitrary `upgradeTo` semantics. + function testInvertedProdBeaconCodehashMismatch() external { + selectBaseFork(); + address beacon = LibProdBeaconsBase.beacons()[1]; + vm.etch(beacon, hex"FE"); + bytes32 mutatedCodehash; + assembly ("memory-safe") { + mutatedCodehash := extcodehash(beacon) + } + vm.mockCall( + beacon, + abi.encodeWithSelector(IOwnable.owner.selector), + abi.encode(LibSafeInvariants.safeForChainId(LibSafeInvariants.BASE_CHAIN_ID)) + ); + vm.expectRevert( + abi.encodeWithSelector( + BeaconCodehashMismatch.selector, + beacon, + LibBeaconInvariants.UPGRADEABLE_BEACON_CODEHASH, + mutatedCodehash + ) + ); + harness.callAssertProdBeaconsOwnedByChainSafe(LibSafeInvariants.BASE_CHAIN_ID); + } + + /// @notice The migration-window sweep applies the same trust order: a + /// look-alike beacon reporting an accepted owner (`post`, as if already + /// migrated) trips `BeaconCodehashMismatch` before the owner read is + /// consulted. The window variant is the cron-facing drift detector for + /// the surface, so a swapped beacon must surface as a codehash break, + /// not pass as migrated. + function testInvertedProdBeaconMigrationCodehashMismatch() external { + selectBaseFork(); + address safe = LibSafeInvariants.safeForChainId(LibSafeInvariants.BASE_CHAIN_ID); + address post = address(0x7157); + address beacon = LibProdBeaconsBase.beacons()[2]; + vm.etch(beacon, hex"FE"); + bytes32 mutatedCodehash; + assembly ("memory-safe") { + mutatedCodehash := extcodehash(beacon) + } + vm.mockCall(beacon, abi.encodeWithSelector(IOwnable.owner.selector), abi.encode(post)); + vm.expectRevert( + abi.encodeWithSelector( + BeaconCodehashMismatch.selector, + beacon, + LibBeaconInvariants.UPGRADEABLE_BEACON_CODEHASH, + mutatedCodehash + ) + ); + harness.callAssertProdBeaconsOwnershipMigration( + LibSafeInvariants.BASE_CHAIN_ID, safe, post, block.timestamp + 1 days + ); + } } From 6d9b98e995744be7042f78c33779ff071f39a1d1 Mon Sep 17 00:00:00 2001 From: David Meister Date: Wed, 12 Aug 2026 14:19:04 +0000 Subject: [PATCH 11/12] test(script): assert the artifact against the captured pre-run selection The artifact-order assertions compared transfers against a prefix of the vault/beacon tables, which only matches the script's skip-already- timelocked selection rule when every table entry is Safe-owned. Capture the Safe-owned addresses (not counts) before the run and assert the emitted transfers against that selection, so the proof holds on any fork state including a partial prior run. Co-Authored-By: Claude Fable 5 --- ...60729-migrate-governance-to-timelock.t.sol | 80 ++++++++++++------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/test/script/20260729-migrate-governance-to-timelock.t.sol b/test/script/20260729-migrate-governance-to-timelock.t.sol index 9480210a..e3a565d8 100644 --- a/test/script/20260729-migrate-governance-to-timelock.t.sol +++ b/test/script/20260729-migrate-governance-to-timelock.t.sol @@ -161,42 +161,62 @@ contract MigrateGovernanceToTimelockTest is Test { // Derive the expected target set from the PRE-run fork state — the // same derivation a reviewing signer performs. - uint256 expectedTransfers = _safeOwnedVaultCount(safe); - assertGt(expectedTransfers, 0, "fork state should have Safe-owned vaults pre-migration"); - uint256 expectedBeaconTransfers = _safeOwnedBeaconCount(safe); - assertGt(expectedBeaconTransfers, 0, "fork state should have Safe-owned beacons pre-migration"); + address[] memory vaultTargets = _safeOwnedVaults(safe); + assertGt(vaultTargets.length, 0, "fork state should have Safe-owned vaults pre-migration"); + address[] memory beaconTargets = _safeOwnedBeacons(safe); + assertGt(beaconTargets.length, 0, "fork state should have Safe-owned beacons pre-migration"); address[3] memory implsBefore = _beaconImplementations(); new MigrateGovernanceToTimelockHarness(timelock).run(); _assertPostState(safe, timelock, implsBefore); - _assertArtifactMatchesDerivedBundle(safe, timelock, expectedTransfers, expectedBeaconTransfers); + _assertArtifactMatchesDerivedBundle(safe, timelock, vaultTargets, beaconTargets); } - /// @notice Count the production receipt vaults still owned by `safe` on - /// the active fork. + /// @notice The production receipt vaults still owned by `safe` on the + /// active fork, in table order — the same subset, in the same order, + /// that the script's selection rule emits as transfers. Captured as + /// addresses (not a count) BEFORE the run, so the artifact assertion + /// tracks the selection even on a fork where part of the table is + /// already timelock-owned. /// @param safe The chain's token-owner Safe. - /// @return count The Safe-owned vault count. - function _safeOwnedVaultCount(address safe) internal view returns (uint256 count) { + /// @return targets The Safe-owned vaults, in table order. + function _safeOwnedVaults(address safe) internal view returns (address[] memory targets) { address[] memory vaults = _activeChainVaults(); + address[] memory candidates = new address[](vaults.length); + uint256 count = 0; for (uint256 i = 0; i < vaults.length; i++) { if (Ownable(vaults[i]).owner() == safe) { + candidates[count] = vaults[i]; count++; } } + targets = new address[](count); + for (uint256 i = 0; i < count; i++) { + targets[i] = candidates[i]; + } } - /// @notice Count the in-use production beacons still owned by `safe` on - /// the active fork. + /// @notice The in-use production beacons still owned by `safe` on the + /// active fork, in `prodBeaconsForChainId` order — the beacon half of + /// the pre-run selection capture, on the same terms as + /// `_safeOwnedVaults`. /// @param safe The chain's token-owner Safe. - /// @return count The Safe-owned beacon count. - function _safeOwnedBeaconCount(address safe) internal view returns (uint256 count) { + /// @return targets The Safe-owned beacons, in pinned order. + function _safeOwnedBeacons(address safe) internal view returns (address[] memory targets) { address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + address[] memory candidates = new address[](beacons.length); + uint256 count = 0; for (uint256 i = 0; i < beacons.length; i++) { if (Ownable(beacons[i]).owner() == safe) { + candidates[count] = beacons[i]; count++; } } + targets = new address[](count); + for (uint256 i = 0; i < count; i++) { + targets[i] = candidates[i]; + } } /// @notice The implementation each in-use beacon currently serves, in @@ -246,17 +266,21 @@ contract MigrateGovernanceToTimelockTest is Test { /// @notice Parse the emitted Tx Builder JSON and assert it matches the /// bundle derived independently from the pre-run state: 7 grants, then - /// the vault transfers in table order, then the beacon transfers in - /// `prodBeaconsForChainId` order, then 7 renounces. + /// the captured Safe-owned vault selection in table order, then the + /// captured Safe-owned beacon selection in `prodBeaconsForChainId` + /// order, then 7 renounces. The selections are the pre-run captures — + /// the script skips surfaces already timelock-owned, so asserting + /// against the captured subset (not a table prefix) tracks its + /// selection rule on any fork state. /// @param safe The chain's token-owner Safe. /// @param timelock The chain's governance timelock. - /// @param expectedTransfers The pre-run Safe-owned vault count. - /// @param expectedBeaconTransfers The pre-run Safe-owned beacon count. + /// @param vaultTargets The pre-run Safe-owned vaults, in table order. + /// @param beaconTargets The pre-run Safe-owned beacons, in pinned order. function _assertArtifactMatchesDerivedBundle( address safe, address timelock, - uint256 expectedTransfers, - uint256 expectedBeaconTransfers + address[] memory vaultTargets, + address[] memory beaconTargets ) internal { address authoriser = _activeChainAuthoriser(); RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(safe, timelock); @@ -266,32 +290,28 @@ contract MigrateGovernanceToTimelockTest is Test { ); assertEq(chainId, block.chainid, "artifact must be authored for the active chain"); assertEq(firstTarget, authoriser, "bundle must open with the authoriser grants"); - assertEq(txs.length, ADMIN_ROLE_COUNT + expectedTransfers + expectedBeaconTransfers + ADMIN_ROLE_COUNT); + assertEq(txs.length, ADMIN_ROLE_COUNT + vaultTargets.length + beaconTargets.length + ADMIN_ROLE_COUNT); for (uint256 i = 0; i < ADMIN_ROLE_COUNT; i++) { assertEq(txs[i].to, authoriser); assertEq(txs[i].data, abi.encodeCall(IAccessControl.grantRole, (grants[i].role, timelock))); } - address[] memory vaults = _activeChainVaults(); - for (uint256 i = 0; i < expectedTransfers; i++) { - // Pre-run every vault was Safe-owned (asserted via the count), - // so each appears as a transfer in table order. - assertEq(txs[ADMIN_ROLE_COUNT + i].to, vaults[i]); + for (uint256 i = 0; i < vaultTargets.length; i++) { + assertEq(txs[ADMIN_ROLE_COUNT + i].to, vaultTargets[i]); assertEq(txs[ADMIN_ROLE_COUNT + i].data, abi.encodeCall(Ownable.transferOwnership, (timelock))); } // Beacon transfers follow the vaults — the leg that closes the // upgrade-path bypass. - address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); - for (uint256 i = 0; i < expectedBeaconTransfers; i++) { - uint256 idx = ADMIN_ROLE_COUNT + expectedTransfers + i; - assertEq(txs[idx].to, beacons[i]); + for (uint256 i = 0; i < beaconTargets.length; i++) { + uint256 idx = ADMIN_ROLE_COUNT + vaultTargets.length + i; + assertEq(txs[idx].to, beaconTargets[i]); assertEq(txs[idx].data, abi.encodeCall(Ownable.transferOwnership, (timelock))); } for (uint256 i = 0; i < ADMIN_ROLE_COUNT; i++) { - uint256 idx = ADMIN_ROLE_COUNT + expectedTransfers + expectedBeaconTransfers + i; + uint256 idx = ADMIN_ROLE_COUNT + vaultTargets.length + beaconTargets.length + i; assertEq(txs[idx].to, authoriser); assertEq(txs[idx].data, abi.encodeCall(IAccessControl.renounceRole, (grants[i].role, safe))); } From c4692dd02a5bb4e5232629b25079bb8d29150f6c Mon Sep 17 00:00:00 2001 From: David Meister Date: Wed, 12 Aug 2026 14:19:21 +0000 Subject: [PATCH 12/12] docs(timelock): list the Safe-expecting beacon asserts in the flip PR The post-execution flip list predates the beacon leg; the StoxProdV4 and HyperEvmBeaconOwnership fork tests and the live deploy-script pre-flights assert Safe-owned beacons and red-line after execution until repointed. Co-Authored-By: Claude Fable 5 --- docs/TIMELOCK.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/TIMELOCK.md b/docs/TIMELOCK.md index 6f22ae68..37db52f5 100644 --- a/docs/TIMELOCK.md +++ b/docs/TIMELOCK.md @@ -118,8 +118,11 @@ timelocks live. 5. **Post-execution flip PR** — mark the migration script `**EXECUTED YYYY-MM-DD.**`, repoint the strict uniform-ownership invariants (`LibInvariants.assertAll`, `LibTokenInvariants` consumers, - `StoxProdV2`/`LibInvariants` fork tests, cross-chain parity) from the Safe to - the timelock, and retire the spent branch of the migration-window suite. + `StoxProdV2`/`LibInvariants` fork tests, cross-chain parity, and the + Safe-expecting beacon-owner asserts — the `StoxProdV4` and + `HyperEvmBeaconOwnership` fork tests plus the live deploy-script pre-flights + on `assertProdBeaconsOwnedByChainSafe`) from the Safe to the timelock, and + retire the spent branch of the migration-window suite. The forcing function: `GovernanceTimelockMigration.t.sol` accepts Safe-or-timelock per surface until **2026-10-01T00:00:00Z**, then demands the