diff --git a/docs/TIMELOCK.md b/docs/TIMELOCK.md index 77d8edc3..37db52f5 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. @@ -97,14 +107,22 @@ 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` → 7 Safe - renounces. + 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 (`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 @@ -149,13 +167,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..629f21e9 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,61 @@ 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 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 @@ -86,12 +138,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 +184,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 @@ -148,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. @@ -230,17 +296,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 +335,100 @@ 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); + _emitArtifact(address(safe), txs, bundleSafeTxHash, nonce, timelock); + + // --- Governance-loop proof ---------------------------------------- + + _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); - // --- Artifact ----------------------------------------------------- + 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, + /// 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 +438,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 +549,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 +651,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..196a9bcf 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,16 +214,89 @@ 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. + /// 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); - address expectedOwner = LibSafeInvariants.safeForChainId(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); } } } + + /// @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). 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. + /// @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++) { + _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/script/20260729-migrate-governance-to-timelock.t.sol b/test/script/20260729-migrate-governance-to-timelock.t.sol index 65dbba86..e3a565d8 100644 --- a/test/script/20260729-migrate-governance-to-timelock.t.sol +++ b/test/script/20260729-migrate-governance-to-timelock.t.sol @@ -5,6 +5,9 @@ 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 {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"; @@ -12,15 +15,24 @@ import { MigrateGovernanceToTimelock, TimelockNotPinned, UnexpectedVaultOwner, - NothingToMigrate + UnexpectedBeaconOwner, + NothingToMigrate, + MigrationVerifyMismatch } 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 {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"; -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 @@ -34,6 +46,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); } @@ -63,34 +114,146 @@ 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; + _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 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 + /// 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 // same derivation a reviewing signer performs. - address[] memory vaults = LibTokenInvariants.productionReceiptVaults(); - uint256 expectedTransfers = 0; + 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, vaultTargets, beaconTargets); + } + + /// @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 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) { - expectedTransfers++; + candidates[count] = vaults[i]; + count++; } } - assertGt(expectedTransfers, 0, "fork state should have Safe-owned vaults pre-migration"); + targets = new address[](count); + for (uint256 i = 0; i < count; i++) { + targets[i] = candidates[i]; + } + } - MigrateGovernanceToTimelockHarness script = new MigrateGovernanceToTimelockHarness(timelock); - script.run(); + /// @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 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 + /// `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 = _activeChainAuthoriser(); + + LibTokenInvariants.assertUniformOwnership(_activeChainTokens(), 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" + ); + } - // 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); LibAuthoriserInvariants.assertExpectedGrants(authoriser, safe, timelock); RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(safe, timelock); for (uint256 i = 0; i < ADMIN_ROLE_COUNT; i++) { @@ -99,31 +262,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 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 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, + address[] memory vaultTargets, + address[] memory beaconTargets + ) internal { + address authoriser = _activeChainAuthoriser(); + 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(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 + 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))); } - 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++; - } + + 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. + 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 + 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))); } @@ -155,4 +343,186 @@ 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(); + } + + /// @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 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 `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 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 + /// 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"); + } } diff --git a/test/src/concrete/deploy/GovernanceTimelockMigration.t.sol b/test/src/concrete/deploy/GovernanceTimelockMigration.t.sol index e4b10117..150d0782 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(LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK))), + bytes32(uint256(uint160(stranger))) + ) + ); + harness.callAssertProdBeaconsOwnershipMigration( + block.chainid, safe, LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK, GOVERNANCE_TIMELOCK_MIGRATION_DEADLINE + ); + } } diff --git a/test/src/lib/LibBeaconInvariants.t.sol b/test/src/lib/LibBeaconInvariants.t.sol index 082987f0..b0ef6eda 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 @@ -186,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 + ); + } } 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); + } }