From 3c35a76fb377f3d3ec467826bdb061c8845d0619 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Sat, 29 Aug 2026 15:19:39 +0000 Subject: [PATCH 1/7] wip: widen prod beacon set to include the orchestrator beacon --- src/lib/LibBeaconInvariants.sol | 9 ++++++--- src/lib/LibProdBeacons0_1_1.sol | 5 +++-- src/lib/LibProdBeaconsBase.sol | 5 +++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/lib/LibBeaconInvariants.sol b/src/lib/LibBeaconInvariants.sol index 3dd0b24a..68fa7a11 100644 --- a/src/lib/LibBeaconInvariants.sol +++ b/src/lib/LibBeaconInvariants.sol @@ -230,6 +230,9 @@ library LibBeaconInvariants { /// `prodBeaconsForChainId`. uint256 internal constant WRAPPED_TOKEN_VAULT_BEACON_INDEX = 2; + /// @notice Position of the orchestrator beacon in `prodBeaconsForChainId`. + uint256 internal constant ORCHESTRATOR_BEACON_INDEX = 3; + /// @notice The three production beacons IN USE on the active chain, in a /// fixed order (receipt, receipt vault, wrapped token vault). Beacon /// addresses are per-chain deploy artifacts that never change once a @@ -240,7 +243,7 @@ library LibBeaconInvariants { /// order); this map only dispatches by chain id. /// @param chainId The active chain id (`block.chainid`). /// @return The chain's three in-use beacon addresses. - function prodBeaconsForChainId(uint256 chainId) internal view returns (address[3] memory) { + function prodBeaconsForChainId(uint256 chainId) internal view returns (address[4] memory) { if (chainId == LibSafeInvariants.BASE_CHAIN_ID) { return LibProdBeaconsBase.beacons(); } @@ -285,7 +288,7 @@ library LibBeaconInvariants { /// @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[4] memory beacons = prodBeaconsForChainId(chainId); for (uint256 i = 0; i < beacons.length; i++) { _assertDeployedPinnedBeacon(beacons[i]); address actualOwner = IOwnable(beacons[i]).owner(); @@ -327,7 +330,7 @@ library LibBeaconInvariants { internal view { - address[3] memory beacons = prodBeaconsForChainId(chainId); + address[4] 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); diff --git a/src/lib/LibProdBeacons0_1_1.sol b/src/lib/LibProdBeacons0_1_1.sol index ae095151..22234178 100644 --- a/src/lib/LibProdBeacons0_1_1.sol +++ b/src/lib/LibProdBeacons0_1_1.sol @@ -50,13 +50,14 @@ library LibProdBeacons0_1_1 { /// generated `0_1_1` pin. `view` because the first two are live reads from /// the deployer (which is why callers run against an Ethereum fork). /// @return The three Ethereum beacon addresses. - function beacons() internal view returns (address[3] memory) { + function beacons() internal view returns (address[4] memory) { IST0xVaultBeaconSet deployer = IST0xVaultBeaconSet(LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_BEACON_SET_DEPLOYER_0_1_1); return [ address(deployer.iReceiptBeacon()), address(deployer.iOffchainAssetReceiptVaultBeacon()), - LibProdDeployV4.STOX_WRAPPED_TOKEN_VAULT_BEACON_0_1_1 + LibProdDeployV4.STOX_WRAPPED_TOKEN_VAULT_BEACON_0_1_1, + LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON ]; } diff --git a/src/lib/LibProdBeaconsBase.sol b/src/lib/LibProdBeaconsBase.sol index 23457613..40a12985 100644 --- a/src/lib/LibProdBeaconsBase.sol +++ b/src/lib/LibProdBeaconsBase.sol @@ -29,11 +29,12 @@ library LibProdBeaconsBase { /// receipt vault, wrapped token vault) — index-aligned with /// `implementations()` and with `LibProdBeacons0_1_1.beacons()`. /// @return The three Base beacon addresses. - function beacons() internal pure returns (address[3] memory) { + function beacons() internal pure returns (address[4] memory) { return [ LibProdDeployV1.STOX_RECEIPT_BEACON_V1, LibProdDeployV1.STOX_RECEIPT_VAULT_BEACON_V1, - LibProdDeployV1.STOX_WRAPPED_TOKEN_VAULT_BEACON_V1 + LibProdDeployV1.STOX_WRAPPED_TOKEN_VAULT_BEACON_V1, + LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON ]; } From 40fb3ed5daf3c538cf83cf917cd981a14af48125 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Sat, 29 Aug 2026 15:32:38 +0000 Subject: [PATCH 2/7] Add the orchestrator beacon to the governed beacon set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prodBeaconsForChainId` returned three beacons while production has four. The fourth is the orchestrator beacon, which after #324 holds mint/burn authority over the other three, and the governance-timelock migration walks that array — so the most governance-sensitive beacon in the system was the one surface the migration could never reach. Nothing failed: `GovernanceTimelockMigration.t.sol` only checks the enumerated three, so it stayed green. Widened to `address[4]` in `LibProdBeaconsBase` / `LibProdBeacons0_1_1` and `LibBeaconInvariants`, appended so the existing indices hold, with `ORCHESTRATOR_BEACON_INDEX` beside the three named in #325. Every consumer that iterates picks the new member up unchanged. `prodBeaconCodehashesForChainId` is new and index-aligned. The set now spans two build generations — the token beacons are the V1 build at 858 bytes, the orchestrator beacon is OZ 5.6.1 at the current optimizer settings and 728 bytes — so a single pin reverts on it. Both the library's own check and `_selectBeaconTargets` in the timelock migration took one constant; both now take the per-beacon value. Without this the widened migration reverts `MigrationBeaconCodehashMismatch` and no chain migrates at all. `MigrateBeaconOwners` keeps its own three-element list: it is a historical executed script with its own literals rather than a consumer of this accessor. Co-Authored-By: Claude Opus 5 (1M context) --- ...60716-migrate-beacon-owners-ethereum.s.sol | 4 +-- ...60722-migrate-beacon-owners-hyperevm.s.sol | 4 +-- ...60729-migrate-governance-to-timelock.s.sol | 23 ++++++++------- script/20260825-upgrade-fleet-to-0-1-30.s.sol | 4 +-- .../20260831-enable-orchestrator-roles.s.sol | 2 +- src/lib/LibBeaconInvariants.sol | 29 +++++++++++++++---- src/lib/LibProdBeacons0_1_1.sol | 5 ++-- src/lib/LibProdBeaconsBase.sol | 5 ++-- ...60729-migrate-governance-to-timelock.t.sol | 14 ++++----- ...0260825-upgrade-fleet-to-0-1-30.prod.t.sol | 2 +- .../20260825-upgrade-fleet-to-0-1-30.t.sol | 2 +- ...60831-enable-orchestrator-roles.prod.t.sol | 2 +- .../20260831-enable-orchestrator-roles.t.sol | 4 +-- test/script/UpgradeFleetHarness.sol | 2 +- .../deploy/EthereumBeaconOwnership.t.sol | 4 +-- .../deploy/GovernanceTimelockMigration.t.sol | 2 +- .../deploy/HyperEvmBeaconOwnership.t.sol | 4 +-- test/src/lib/LibBeaconInvariants.t.sol | 6 ++-- test/src/lib/LibBeaconInvariantsHarness.sol | 2 +- test/src/lib/LibProdBeaconsBase.t.sol | 4 +-- 20 files changed, 73 insertions(+), 51 deletions(-) diff --git a/script/20260716-migrate-beacon-owners-ethereum.s.sol b/script/20260716-migrate-beacon-owners-ethereum.s.sol index 1b08b943..3161416b 100644 --- a/script/20260716-migrate-beacon-owners-ethereum.s.sol +++ b/script/20260716-migrate-beacon-owners-ethereum.s.sol @@ -39,8 +39,8 @@ contract MigrateBeaconOwnersEthereum is Script { function run() external { address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM; require(safe != address(0), "Ethereum token-owner Safe not pinned"); - address[3] memory beaconList = LibProdBeacons0_1_1.beacons(); - address[3] memory implList = LibProdBeacons0_1_1.implementations(); + address[4] memory beaconList = LibProdBeacons0_1_1.beacons(); + address[4] memory implList = LibProdBeacons0_1_1.implementations(); // Pre-flight: every beacon is deployed, is the OZ UpgradeableBeacon, // is still owned by the deploy EOA, and points at its pinned impl. diff --git a/script/20260722-migrate-beacon-owners-hyperevm.s.sol b/script/20260722-migrate-beacon-owners-hyperevm.s.sol index 70ba9146..c0f53f27 100644 --- a/script/20260722-migrate-beacon-owners-hyperevm.s.sol +++ b/script/20260722-migrate-beacon-owners-hyperevm.s.sol @@ -47,8 +47,8 @@ contract MigrateBeaconOwnersHyperEvm is Script { address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_HYPEREVM; require(safe != address(0), "HyperEVM token-owner Safe not pinned"); require(block.chainid == LibSafeInvariants.HYPEREVM_CHAIN_ID, "not HyperEVM - wrong --rpc-url"); - address[3] memory beaconList = LibProdBeacons0_1_1.beacons(); - address[3] memory implList = LibProdBeacons0_1_1.implementations(); + address[4] memory beaconList = LibProdBeacons0_1_1.beacons(); + address[4] memory implList = LibProdBeacons0_1_1.implementations(); // Pre-flight: every beacon is deployed, is the OZ UpgradeableBeacon, // is still owned by the deploy EOA, and points at its pinned impl. diff --git a/script/20260729-migrate-governance-to-timelock.s.sol b/script/20260729-migrate-governance-to-timelock.s.sol index 98f25d88..e70d8278 100644 --- a/script/20260729-migrate-governance-to-timelock.s.sol +++ b/script/20260729-migrate-governance-to-timelock.s.sol @@ -308,7 +308,7 @@ contract MigrateGovernanceToTimelock is Script { // What the beacons serve, captured before the bundle so the // post-state can prove the migration moved ownership only. - address[3] memory beaconImplsBefore = _beaconImplementations(); + address[4] memory beaconImplsBefore = _beaconImplementations(); // --- Build the bundle --------------------------------------------- @@ -447,14 +447,14 @@ contract MigrateGovernanceToTimelock is Script { address timelock, address authoriser, TokenInstance[] memory tokens, - address[3] memory beaconImplsBefore + address[4] memory beaconImplsBefore ) internal view { LibTokenInvariants.assertUniformOwnership(tokens, timelock); LibTokenInvariants.assertUniformAuthoriser(tokens, authoriser); LibBeaconInvariants.assertProdBeaconsOwnedBy(block.chainid, timelock); - address[3] memory beaconsAfter = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); - address[3] memory beaconImplsAfter = _beaconImplementations(); + address[4] memory beaconsAfter = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + address[4] memory beaconImplsAfter = _beaconImplementations(); for (uint256 i = 0; i < beaconsAfter.length; i++) { if (beaconImplsAfter[i] != beaconImplsBefore[i]) { revert BeaconImplementationMoved(beaconsAfter[i], beaconImplsBefore[i], beaconImplsAfter[i]); @@ -550,15 +550,16 @@ contract MigrateGovernanceToTimelock is Script { /// @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[4] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + // Index-aligned: the set spans two build generations, so one pin + // cannot cover it. See `prodBeaconCodehashesForChainId`. + bytes32[4] memory expectedCodehashes = LibBeaconInvariants.prodBeaconCodehashesForChainId(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 - ); + if (codehash != expectedCodehashes[i]) { + revert MigrationBeaconCodehashMismatch(beacons[i], expectedCodehashes[i], codehash); } address actual = Ownable(beacons[i]).owner(); if (actual == timelock) { @@ -583,8 +584,8 @@ contract MigrateGovernanceToTimelock is Script { /// 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); + function _beaconImplementations() internal view returns (address[4] memory impls) { + address[4] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); for (uint256 i = 0; i < beacons.length; i++) { impls[i] = IBeacon(beacons[i]).implementation(); } diff --git a/script/20260825-upgrade-fleet-to-0-1-30.s.sol b/script/20260825-upgrade-fleet-to-0-1-30.s.sol index 23e8e81b..7674d2ec 100644 --- a/script/20260825-upgrade-fleet-to-0-1-30.s.sol +++ b/script/20260825-upgrade-fleet-to-0-1-30.s.sol @@ -107,7 +107,7 @@ contract UpgradeFleetTo0_1_30 is Script { /// @param beacons The chain's in-use beacons (receipt, receipt vault, /// wrapped token vault) — index order pinned by `LibProdBeacons*`. /// @return txs The self-scoped upgrade transactions. - function authorBundle(address[3] memory beacons) internal view returns (SafeTx[] memory txs) { + function authorBundle(address[4] memory beacons) internal view returns (SafeTx[] memory txs) { address[2] memory gated = [ beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX], beacons[LibBeaconInvariants.RECEIPT_VAULT_BEACON_INDEX] ]; @@ -213,7 +213,7 @@ contract UpgradeFleetTo0_1_30 is Script { // The in-use beacons are deployed, OZ bytecode, Safe-owned. LibBeaconInvariants.assertProdBeaconsOwnedByChainSafe(block.chainid); - address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + address[4] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); // --- Build the bundle ---------------------------------------------- diff --git a/script/20260831-enable-orchestrator-roles.s.sol b/script/20260831-enable-orchestrator-roles.s.sol index 11c4f505..55b03ef6 100644 --- a/script/20260831-enable-orchestrator-roles.s.sol +++ b/script/20260831-enable-orchestrator-roles.s.sol @@ -141,7 +141,7 @@ contract EnableOrchestratorRoles is Script { // Index order pinned by LibProdBeacons*: receipt, receipt vault, // wrapped token vault. The wrapped-token-vault beacon is not part of // the orchestrator's surface and is not gated here. - address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + address[4] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); address receiptImpl = IBeacon(beacons[0]).implementation(); if (receiptImpl != LibProdDeployV4.STOX_RECEIPT_0_1_30) { revert FleetNotUpgraded(beacons[0], LibProdDeployV4.STOX_RECEIPT_0_1_30, receiptImpl); diff --git a/src/lib/LibBeaconInvariants.sol b/src/lib/LibBeaconInvariants.sol index 68fa7a11..0cb83826 100644 --- a/src/lib/LibBeaconInvariants.sol +++ b/src/lib/LibBeaconInvariants.sol @@ -233,6 +233,23 @@ library LibBeaconInvariants { /// @notice Position of the orchestrator beacon in `prodBeaconsForChainId`. uint256 internal constant ORCHESTRATOR_BEACON_INDEX = 3; + /// @notice Expected runtime codehash of each beacon in + /// `prodBeaconsForChainId`, index-aligned with it. + /// @dev The set spans two build generations and a single pin cannot cover + /// both: the three token beacons are the V1 build (858 bytes), the + /// orchestrator beacon is OZ 5.6.1 at the current `optimizer_runs` + /// (728 bytes). Asserting the whole set against one constant reverts on + /// the orchestrator beacon and takes every chain's migration with it. + /// @return The expected codehash per beacon. + function prodBeaconCodehashesForChainId(uint256) internal pure returns (bytes32[4] memory) { + return [ + UPGRADEABLE_BEACON_CODEHASH, + UPGRADEABLE_BEACON_CODEHASH, + UPGRADEABLE_BEACON_CODEHASH, + UPGRADEABLE_BEACON_CODEHASH_0_1_30 + ]; + } + /// @notice The three production beacons IN USE on the active chain, in a /// fixed order (receipt, receipt vault, wrapped token vault). Beacon /// addresses are per-chain deploy artifacts that never change once a @@ -289,8 +306,9 @@ library LibBeaconInvariants { /// `owner()`. function assertProdBeaconsOwnedBy(uint256 chainId, address expectedOwner) internal view { address[4] memory beacons = prodBeaconsForChainId(chainId); + bytes32[4] memory codehashes = prodBeaconCodehashesForChainId(chainId); for (uint256 i = 0; i < beacons.length; i++) { - _assertDeployedPinnedBeacon(beacons[i]); + _assertDeployedPinnedBeacon(beacons[i], codehashes[i]); address actualOwner = IOwnable(beacons[i]).owner(); if (actualOwner != expectedOwner) { revert BeaconOwnerMismatch(beacons[i], expectedOwner, actualOwner); @@ -331,8 +349,9 @@ library LibBeaconInvariants { view { address[4] memory beacons = prodBeaconsForChainId(chainId); + bytes32[4] memory codehashes = prodBeaconCodehashesForChainId(chainId); for (uint256 i = 0; i < beacons.length; i++) { - _assertDeployedPinnedBeacon(beacons[i]); + _assertDeployedPinnedBeacon(beacons[i], codehashes[i]); LibMigrationInvariant.assertMigration("beacon.owner()", IOwnable(beacons[i]).owner(), pre, post, deadline); } } @@ -344,13 +363,13 @@ library LibBeaconInvariants { /// 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 { + function _assertDeployedPinnedBeacon(address beacon, bytes32 expectedCodehash) 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); + if (actualCodehash != expectedCodehash) { + revert BeaconCodehashMismatch(beacon, expectedCodehash, actualCodehash); } } } diff --git a/src/lib/LibProdBeacons0_1_1.sol b/src/lib/LibProdBeacons0_1_1.sol index 22234178..ed0f5761 100644 --- a/src/lib/LibProdBeacons0_1_1.sol +++ b/src/lib/LibProdBeacons0_1_1.sol @@ -66,11 +66,12 @@ library LibProdBeacons0_1_1 { /// deterministic addresses on every chain, so no separate Ethereum copy. /// Asserted unchanged across the ownership transfer. /// @return The three implementation addresses. - function implementations() internal pure returns (address[3] memory) { + function implementations() internal pure returns (address[4] memory) { return [ LibProdDeployV4.STOX_RECEIPT_0_1_1, LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1, - LibProdDeployV4.STOX_WRAPPED_TOKEN_VAULT_0_1_1 + LibProdDeployV4.STOX_WRAPPED_TOKEN_VAULT_0_1_1, + LibProdDeployV4.ST0X_ORCHESTRATOR_0_1_30 ]; } } diff --git a/src/lib/LibProdBeaconsBase.sol b/src/lib/LibProdBeaconsBase.sol index 40a12985..45796e8d 100644 --- a/src/lib/LibProdBeaconsBase.sol +++ b/src/lib/LibProdBeaconsBase.sol @@ -43,11 +43,12 @@ library LibProdBeaconsBase { /// same deterministic addresses `LibProdBeacons0_1_1.implementations()` /// resolves, because implementation parity across chains is the goal. /// @return The three implementation addresses. - function implementations() internal pure returns (address[3] memory) { + function implementations() internal pure returns (address[4] memory) { return [ LibProdDeployV4.STOX_RECEIPT_0_1_1, LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1, - LibProdDeployV4.STOX_WRAPPED_TOKEN_VAULT_0_1_1 + LibProdDeployV4.STOX_WRAPPED_TOKEN_VAULT_0_1_1, + LibProdDeployV4.ST0X_ORCHESTRATOR_0_1_30 ]; } } diff --git a/test/script/20260729-migrate-governance-to-timelock.t.sol b/test/script/20260729-migrate-governance-to-timelock.t.sol index 00f7f953..e6c2ffed 100644 --- a/test/script/20260729-migrate-governance-to-timelock.t.sol +++ b/test/script/20260729-migrate-governance-to-timelock.t.sol @@ -164,7 +164,7 @@ contract MigrateGovernanceToTimelockTest is Test { 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(); + address[4] memory implsBefore = _beaconImplementations(); new MigrateGovernanceToTimelockHarness(timelock).run(); @@ -203,7 +203,7 @@ contract MigrateGovernanceToTimelockTest is Test { /// @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[4] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); address[] memory candidates = new address[](beacons.length); uint256 count = 0; for (uint256 i = 0; i < beacons.length; i++) { @@ -221,8 +221,8 @@ contract MigrateGovernanceToTimelockTest is Test { /// @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); + function _beaconImplementations() internal view returns (address[4] memory impls) { + address[4] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); for (uint256 i = 0; i < beacons.length; i++) { impls[i] = IBeacon(beacons[i]).implementation(); } @@ -238,13 +238,13 @@ contract MigrateGovernanceToTimelockTest is Test { /// @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 { + function _assertPostState(address safe, address timelock, address[4] memory implsBefore) internal view { address authoriser = _activeChainAuthoriser(); LibTokenInvariants.assertUniformOwnership(_activeChainTokens(), timelock); LibBeaconInvariants.assertProdBeaconsOwnedBy(block.chainid, timelock); - address[3] memory implsAfter = _beaconImplementations(); + address[4] memory implsAfter = _beaconImplementations(); for (uint256 i = 0; i < implsAfter.length; i++) { assertEq( implsAfter[i], @@ -350,7 +350,7 @@ contract MigrateGovernanceToTimelockTest is Test { function testRunRejectsUnknownBeaconOwner() external { selectBaseFork(); address timelock = deployTimelock(); - address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + address[4] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); address stranger = address(0xDEAD); vm.mockCall(beacons[1], abi.encodeWithSignature("owner()"), abi.encode(stranger)); diff --git a/test/script/20260825-upgrade-fleet-to-0-1-30.prod.t.sol b/test/script/20260825-upgrade-fleet-to-0-1-30.prod.t.sol index f7df03dd..4bb3163a 100644 --- a/test/script/20260825-upgrade-fleet-to-0-1-30.prod.t.sol +++ b/test/script/20260825-upgrade-fleet-to-0-1-30.prod.t.sol @@ -45,7 +45,7 @@ contract UpgradeFleetProdTest is Test { /// @param label Human chain name, surfaced in logs and messages. function assertFleetRollout(string memory label) internal { UpgradeFleetTo0_1_30 script = new UpgradeFleetTo0_1_30(); - address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + address[4] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); bool targetsLive = LibProdDeployV4.STOX_RECEIPT_0_1_30.code.length != 0 && LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30.code.length != 0 diff --git a/test/script/20260825-upgrade-fleet-to-0-1-30.t.sol b/test/script/20260825-upgrade-fleet-to-0-1-30.t.sol index 2296f9df..778f7787 100644 --- a/test/script/20260825-upgrade-fleet-to-0-1-30.t.sol +++ b/test/script/20260825-upgrade-fleet-to-0-1-30.t.sol @@ -24,7 +24,7 @@ import {SafeTx} from "../../src/lib/LibSafeOps.sol"; /// The live-fork walk is in `20260825-upgrade-fleet-to-0-1-30.prod.t.sol`. contract UpgradeFleetTest is Test { UpgradeFleetHarness internal harness; - address[3] internal beacons; + address[4] internal beacons; function setUp() external { vm.chainId(LibSafeInvariants.BASE_CHAIN_ID); diff --git a/test/script/20260831-enable-orchestrator-roles.prod.t.sol b/test/script/20260831-enable-orchestrator-roles.prod.t.sol index e92a9fc7..4771b059 100644 --- a/test/script/20260831-enable-orchestrator-roles.prod.t.sol +++ b/test/script/20260831-enable-orchestrator-roles.prod.t.sol @@ -68,7 +68,7 @@ contract EnableOrchestratorRolesProdTest is Test { return; } - address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + address[4] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); bool fleetUpgraded = IBeacon(beacons[0]).implementation() == LibProdDeployV4.STOX_RECEIPT_0_1_30 && IBeacon(beacons[1]).implementation() == LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30; if (!fleetUpgraded) { diff --git a/test/script/20260831-enable-orchestrator-roles.t.sol b/test/script/20260831-enable-orchestrator-roles.t.sol index 751ecb11..99d6652a 100644 --- a/test/script/20260831-enable-orchestrator-roles.t.sol +++ b/test/script/20260831-enable-orchestrator-roles.t.sol @@ -40,7 +40,7 @@ contract EnableOrchestratorRolesTest is Test { /// @notice Mock Base's three in-use production beacons pointing at the /// 0.1.30 receipt/vault impls (the post-fleet-upgrade state). function mockUpgradedFleet() internal { - address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(LibSafeInvariants.BASE_CHAIN_ID); + address[4] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(LibSafeInvariants.BASE_CHAIN_ID); vm.etch(beacons[0], hex"fe"); vm.etch(beacons[1], hex"fe"); vm.mockCall( @@ -90,7 +90,7 @@ contract EnableOrchestratorRolesTest is Test { /// The fleet-upgrade interlock refuses beacons still on pre-0.1.30 /// impls, naming the beacon and both impls. function testFleetGateRefusesUnupgradedBeacons() external { - address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(LibSafeInvariants.BASE_CHAIN_ID); + address[4] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(LibSafeInvariants.BASE_CHAIN_ID); vm.etch(beacons[0], hex"fe"); vm.mockCall( beacons[0], abi.encodeCall(IBeacon.implementation, ()), abi.encode(LibProdDeployV4.STOX_RECEIPT_0_1_1) diff --git a/test/script/UpgradeFleetHarness.sol b/test/script/UpgradeFleetHarness.sol index b22f7df0..3e601608 100644 --- a/test/script/UpgradeFleetHarness.sol +++ b/test/script/UpgradeFleetHarness.sol @@ -14,7 +14,7 @@ contract UpgradeFleetHarness is UpgradeFleetTo0_1_30 { } /// @notice The script's `authorBundle()`, externally callable. - function callAuthorBundle(address[3] memory beacons) external view returns (SafeTx[] memory) { + function callAuthorBundle(address[4] memory beacons) external view returns (SafeTx[] memory) { return authorBundle(beacons); } } diff --git a/test/src/concrete/deploy/EthereumBeaconOwnership.t.sol b/test/src/concrete/deploy/EthereumBeaconOwnership.t.sol index c7d0e352..7da3f55e 100644 --- a/test/src/concrete/deploy/EthereumBeaconOwnership.t.sol +++ b/test/src/concrete/deploy/EthereumBeaconOwnership.t.sol @@ -35,8 +35,8 @@ contract EthereumBeaconOwnershipTest is Test { } vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); - address[3] memory beacons = LibProdBeacons0_1_1.beacons(); - address[3] memory impls = LibProdBeacons0_1_1.implementations(); + address[4] memory beacons = LibProdBeacons0_1_1.beacons(); + address[4] memory impls = LibProdBeacons0_1_1.implementations(); // The wrapped-token-vault beacon (index 2) still serves its 0.1.1 // impl; the receipt + receipt-vault beacons ride the fleet-upgrade // migration window (20260825-upgrade-fleet-to-0-1-30): 0.1.1 OR diff --git a/test/src/concrete/deploy/GovernanceTimelockMigration.t.sol b/test/src/concrete/deploy/GovernanceTimelockMigration.t.sol index 150d0782..f271ca96 100644 --- a/test/src/concrete/deploy/GovernanceTimelockMigration.t.sol +++ b/test/src/concrete/deploy/GovernanceTimelockMigration.t.sol @@ -285,7 +285,7 @@ contract GovernanceTimelockMigrationTest is Test { vm.createSelectFork(LibRainDeploy.BASE); address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; address stranger = address(0xBAD); - address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + address[4] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); vm.mockCall(beacons[0], abi.encodeWithSignature("owner()"), abi.encode(stranger)); LibBeaconInvariantsHarness harness = new LibBeaconInvariantsHarness(); diff --git a/test/src/concrete/deploy/HyperEvmBeaconOwnership.t.sol b/test/src/concrete/deploy/HyperEvmBeaconOwnership.t.sol index 613c49fc..c2639ece 100644 --- a/test/src/concrete/deploy/HyperEvmBeaconOwnership.t.sol +++ b/test/src/concrete/deploy/HyperEvmBeaconOwnership.t.sol @@ -32,8 +32,8 @@ contract HyperEvmBeaconOwnershipTest is Test { address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_HYPEREVM; vm.createSelectFork(LibStoxDeployNetworks.HYPEREVM); - address[3] memory beacons = LibProdBeacons0_1_1.beacons(); - address[3] memory impls = LibProdBeacons0_1_1.implementations(); + address[4] memory beacons = LibProdBeacons0_1_1.beacons(); + address[4] memory impls = LibProdBeacons0_1_1.implementations(); // The wrapped-token-vault beacon (index 2) still serves its 0.1.1 // impl; the receipt + receipt-vault beacons ride the fleet-upgrade // migration window (20260825-upgrade-fleet-to-0-1-30): 0.1.1 OR diff --git a/test/src/lib/LibBeaconInvariants.t.sol b/test/src/lib/LibBeaconInvariants.t.sol index 95a05ed4..cc565de1 100644 --- a/test/src/lib/LibBeaconInvariants.t.sol +++ b/test/src/lib/LibBeaconInvariants.t.sol @@ -133,7 +133,7 @@ contract LibBeaconInvariantsTest is Test { /// nothing runs on while leaving the live beacons unpinned. function testProdBeaconsForChainIdBaseIsTheV1Generation() external { selectBaseFork(); - address[3] memory beacons = harness.callProdBeaconsForChainId(LibSafeInvariants.BASE_CHAIN_ID); + address[4] memory beacons = harness.callProdBeaconsForChainId(LibSafeInvariants.BASE_CHAIN_ID); assertEq(beacons[0], LibProdDeployV1.STOX_RECEIPT_BEACON_V1, "receipt beacon"); assertEq(beacons[1], LibProdDeployV1.STOX_RECEIPT_VAULT_BEACON_V1, "receipt vault beacon"); assertEq(beacons[2], LibProdDeployV1.STOX_WRAPPED_TOKEN_VAULT_BEACON_V1, "wrapped vault beacon"); @@ -146,8 +146,8 @@ contract LibBeaconInvariantsTest is Test { function testProdBeaconsForChainIdEthereumIsThe011Set() external { vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); harness = new LibBeaconInvariantsHarness(); - address[3] memory beacons = harness.callProdBeaconsForChainId(LibSafeInvariants.ETHEREUM_CHAIN_ID); - address[3] memory expected = LibProdBeacons0_1_1.beacons(); + address[4] memory beacons = harness.callProdBeaconsForChainId(LibSafeInvariants.ETHEREUM_CHAIN_ID); + address[4] memory expected = LibProdBeacons0_1_1.beacons(); assertEq(beacons[0], expected[0], "receipt beacon"); assertEq(beacons[1], expected[1], "receipt vault beacon"); assertEq(beacons[2], expected[2], "wrapped vault beacon"); diff --git a/test/src/lib/LibBeaconInvariantsHarness.sol b/test/src/lib/LibBeaconInvariantsHarness.sol index 77550b33..83984b7b 100644 --- a/test/src/lib/LibBeaconInvariantsHarness.sol +++ b/test/src/lib/LibBeaconInvariantsHarness.sol @@ -14,7 +14,7 @@ contract LibBeaconInvariantsHarness { LibBeaconInvariants.assertBeaconInvariants(beacon, expectedOwner, expectedImpl); } - function callProdBeaconsForChainId(uint256 chainId) external view returns (address[3] memory) { + function callProdBeaconsForChainId(uint256 chainId) external view returns (address[4] memory) { return LibBeaconInvariants.prodBeaconsForChainId(chainId); } diff --git a/test/src/lib/LibProdBeaconsBase.t.sol b/test/src/lib/LibProdBeaconsBase.t.sol index b8c597d7..604c57e1 100644 --- a/test/src/lib/LibProdBeaconsBase.t.sol +++ b/test/src/lib/LibProdBeaconsBase.t.sol @@ -34,7 +34,7 @@ contract LibProdBeaconsBaseTest is Test { /// satisfies them just as well as the right one. function testProductionTokensRunOnTheseBeacons() external { vm.createSelectFork(LibRainDeploy.BASE); - address[3] memory beacons = LibProdBeaconsBase.beacons(); + address[4] memory beacons = LibProdBeaconsBase.beacons(); TokenInstance[] memory tokens = LibTokenInvariants.productionTokensBase(); assertTrue(tokens.length > 0, "no production tokens to check"); for (uint256 i = 0; i < tokens.length; i++) { @@ -57,7 +57,7 @@ contract LibProdBeaconsBaseTest is Test { /// pass every ownership check while quietly asserting nothing about the /// slot it displaced. function testTheThreeBeaconsAreDistinct() external pure { - address[3] memory beacons = LibProdBeaconsBase.beacons(); + address[4] memory beacons = LibProdBeaconsBase.beacons(); assertTrue(beacons[0] != beacons[1], "receipt == receipt vault"); assertTrue(beacons[1] != beacons[2], "receipt vault == wrapped vault"); assertTrue(beacons[0] != beacons[2], "receipt == wrapped vault"); From 2f96db0a1dcc16a3f5c70e98845090b0bcf4e3de Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 31 Aug 2026 15:54:20 +0000 Subject: [PATCH 3/7] Window the orchestrator beacon's ownership within the governed set The orchestrator beacon joins the governed set mid-lifecycle: its constructor bakes the deploy EOA as owner and the 20260818-migrate-orchestrator-beacon-owner EOA broadcast moves it to the chain's Safe. The set's two ownership sweeps (assertProdBeaconsOwnedBy and assertProdBeaconsOwnershipMigration) now accept that baked pre-state for the orchestrator index until the beacon-owner migration deadline - the same window LibOrchestratorInvariants.assertBeaconSet applies - and hard-require the expected owner after it. Live today on all three chains (closure + instance dispatched, beacon still EOA-owned) the sweeps otherwise red-lined every prod surface; with the window the fleet prod walk reaches the authoring state and proves all 41 production tokens state-preserving across the simulated upgrade per chain. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KbsbYN4C4YDa8pu9DdudoX --- src/lib/LibBeaconInvariants.sol | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/lib/LibBeaconInvariants.sol b/src/lib/LibBeaconInvariants.sol index 0cb83826..78d32914 100644 --- a/src/lib/LibBeaconInvariants.sol +++ b/src/lib/LibBeaconInvariants.sol @@ -4,6 +4,8 @@ pragma solidity ^0.8.25; import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; import {LibMigrationInvariant} from "./LibMigrationInvariant.sol"; +import {LibOrchestratorInvariants} from "./LibOrchestratorInvariants.sol"; +import {LibProdDeployV4} from "../generated/LibProdDeployV4.sol"; import {LibProdBeaconsBase} from "./LibProdBeaconsBase.sol"; import {LibProdBeacons0_1_1} from "./LibProdBeacons0_1_1.sol"; import {LibSafeInvariants} from "./LibSafeInvariants.sol"; @@ -310,6 +312,19 @@ library LibBeaconInvariants { for (uint256 i = 0; i < beacons.length; i++) { _assertDeployedPinnedBeacon(beacons[i], codehashes[i]); address actualOwner = IOwnable(beacons[i]).owner(); + // The orchestrator beacon joins the governed set mid-lifecycle: + // its constructor bakes the deploy EOA as owner and the + // `20260818-migrate-orchestrator-beacon-owner` EOA broadcast + // moves it to the chain's Safe. Until that migration's deadline, + // the EOA is an accepted pre-state; after it, only the expected + // owner passes — the same window `LibOrchestratorInvariants. + // assertBeaconSet` applies. + if ( + i == ORCHESTRATOR_BEACON_INDEX && actualOwner == LibProdDeployV4.BEACON_INITIAL_OWNER + && block.timestamp < LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE + ) { + continue; + } if (actualOwner != expectedOwner) { revert BeaconOwnerMismatch(beacons[i], expectedOwner, actualOwner); } @@ -352,7 +367,18 @@ library LibBeaconInvariants { bytes32[4] memory codehashes = prodBeaconCodehashesForChainId(chainId); for (uint256 i = 0; i < beacons.length; i++) { _assertDeployedPinnedBeacon(beacons[i], codehashes[i]); - LibMigrationInvariant.assertMigration("beacon.owner()", IOwnable(beacons[i]).owner(), pre, post, deadline); + address actualOwner = IOwnable(beacons[i]).owner(); + // See `assertProdBeaconsOwnedBy`: the orchestrator beacon's own + // EOA -> Safe migration window overlaps the governance window, + // so its baked initial owner is an accepted extra pre-state + // until that migration's deadline. + if ( + i == ORCHESTRATOR_BEACON_INDEX && actualOwner == LibProdDeployV4.BEACON_INITIAL_OWNER + && block.timestamp < LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE + ) { + continue; + } + LibMigrationInvariant.assertMigration("beacon.owner()", actualOwner, pre, post, deadline); } } From 13eef5448112be8cdc04ac2763fa484baeba38f8 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 31 Aug 2026 17:00:59 +0000 Subject: [PATCH 4/7] Skip the EOA-owned orchestrator beacon in the governance migration Third surface of the same mid-lifecycle join: the governance-timelock migration's beacon selection required every governed beacon Safe-owned before authoring, so the still-EOA-owned orchestrator beacon tripped UnexpectedBeaconOwner on every chain. The selection now skips it while its own EOA -> Safe migration is pending (same deadline window as the ownership sweeps); a later dispatch picks it up once Safe-owned. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KbsbYN4C4YDa8pu9DdudoX --- .../20260729-migrate-governance-to-timelock.s.sol | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/script/20260729-migrate-governance-to-timelock.s.sol b/script/20260729-migrate-governance-to-timelock.s.sol index e70d8278..fcff9fac 100644 --- a/script/20260729-migrate-governance-to-timelock.s.sol +++ b/script/20260729-migrate-governance-to-timelock.s.sol @@ -13,6 +13,7 @@ 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 {LibOrchestratorInvariants} from "../src/lib/LibOrchestratorInvariants.sol"; import {LibProdDeployV4} from "../src/generated/LibProdDeployV4.sol"; import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; @@ -565,6 +566,19 @@ contract MigrateGovernanceToTimelock is Script { if (actual == timelock) { continue; } + // The orchestrator beacon joins the governed set mid-lifecycle: + // until its own EOA -> Safe migration + // (20260818-migrate-orchestrator-beacon-owner) executes, it is + // not the Safe's to move — skip it; a later dispatch of this + // script picks it up once Safe-owned. After that migration's + // deadline the baked EOA stops being an accepted state and the + // refusal below fires. + if ( + i == LibBeaconInvariants.ORCHESTRATOR_BEACON_INDEX && actual == LibProdDeployV4.BEACON_INITIAL_OWNER + && block.timestamp < LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE + ) { + continue; + } if (actual != safe) { revert UnexpectedBeaconOwner(beacons[i], actual); } From ce49fed4b5ea2d41bb85e6e3eb22d180d24bc0f3 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Thu, 3 Sep 2026 12:39:17 +0000 Subject: [PATCH 5/7] One predicate for the orchestrator beacon's pending owner, with tests The deploy-key-owner skip was written out three times: both ownership sweeps in LibBeaconInvariants and the timelock migration's target selection. It is now isOrchestratorBeaconAwaitingSafe, and the three sites call it. Nothing discriminated the skip. Now: both sweeps accept the deploy key at the orchestrator index today and refuse it at the beacon-owner migration deadline; the deploy key on a token beacon is drift; and the timelock bundle selects three beacons while the deploy key holds the orchestrator beacon, four once the Safe does, and refuses at the deadline. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EyWWorieTJV9FmMm2JM6Ua --- ...60729-migrate-governance-to-timelock.s.sol | 13 +--- src/lib/LibBeaconInvariants.sol | 34 +++++----- ...60729-migrate-governance-to-timelock.t.sol | 39 +++++++++++ .../MigrateGovernanceToTimelockHarness.sol | 5 ++ test/src/lib/LibBeaconInvariants.t.sol | 65 +++++++++++++++++++ 5 files changed, 126 insertions(+), 30 deletions(-) diff --git a/script/20260729-migrate-governance-to-timelock.s.sol b/script/20260729-migrate-governance-to-timelock.s.sol index fcff9fac..10e0aa3f 100644 --- a/script/20260729-migrate-governance-to-timelock.s.sol +++ b/script/20260729-migrate-governance-to-timelock.s.sol @@ -566,17 +566,8 @@ contract MigrateGovernanceToTimelock is Script { if (actual == timelock) { continue; } - // The orchestrator beacon joins the governed set mid-lifecycle: - // until its own EOA -> Safe migration - // (20260818-migrate-orchestrator-beacon-owner) executes, it is - // not the Safe's to move — skip it; a later dispatch of this - // script picks it up once Safe-owned. After that migration's - // deadline the baked EOA stops being an accepted state and the - // refusal below fires. - if ( - i == LibBeaconInvariants.ORCHESTRATOR_BEACON_INDEX && actual == LibProdDeployV4.BEACON_INITIAL_OWNER - && block.timestamp < LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE - ) { + // Not the Safe's to move yet; a later dispatch picks it up. + if (LibBeaconInvariants.isOrchestratorBeaconAwaitingSafe(i, actual)) { continue; } if (actual != safe) { diff --git a/src/lib/LibBeaconInvariants.sol b/src/lib/LibBeaconInvariants.sol index 78d32914..f0999256 100644 --- a/src/lib/LibBeaconInvariants.sol +++ b/src/lib/LibBeaconInvariants.sol @@ -235,6 +235,19 @@ library LibBeaconInvariants { /// @notice Position of the orchestrator beacon in `prodBeaconsForChainId`. uint256 internal constant ORCHESTRATOR_BEACON_INDEX = 3; + /// @notice The orchestrator beacon joins the governed set mid-lifecycle: + /// its constructor bakes the deploy key as owner and + /// `20260818-migrate-orchestrator-beacon-owner` moves it to the chain's + /// Safe. Until that migration's deadline every ownership sweep over the + /// set treats the baked owner at that index as pending, not drift. + /// @param index Position in `prodBeaconsForChainId`. + /// @param owner The owner read from the beacon. + /// @return Whether the sweep skips this beacon. + function isOrchestratorBeaconAwaitingSafe(uint256 index, address owner) internal view returns (bool) { + return index == ORCHESTRATOR_BEACON_INDEX && owner == LibProdDeployV4.BEACON_INITIAL_OWNER + && block.timestamp < LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE; + } + /// @notice Expected runtime codehash of each beacon in /// `prodBeaconsForChainId`, index-aligned with it. /// @dev The set spans two build generations and a single pin cannot cover @@ -312,17 +325,7 @@ library LibBeaconInvariants { for (uint256 i = 0; i < beacons.length; i++) { _assertDeployedPinnedBeacon(beacons[i], codehashes[i]); address actualOwner = IOwnable(beacons[i]).owner(); - // The orchestrator beacon joins the governed set mid-lifecycle: - // its constructor bakes the deploy EOA as owner and the - // `20260818-migrate-orchestrator-beacon-owner` EOA broadcast - // moves it to the chain's Safe. Until that migration's deadline, - // the EOA is an accepted pre-state; after it, only the expected - // owner passes — the same window `LibOrchestratorInvariants. - // assertBeaconSet` applies. - if ( - i == ORCHESTRATOR_BEACON_INDEX && actualOwner == LibProdDeployV4.BEACON_INITIAL_OWNER - && block.timestamp < LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE - ) { + if (isOrchestratorBeaconAwaitingSafe(i, actualOwner)) { continue; } if (actualOwner != expectedOwner) { @@ -368,14 +371,7 @@ library LibBeaconInvariants { for (uint256 i = 0; i < beacons.length; i++) { _assertDeployedPinnedBeacon(beacons[i], codehashes[i]); address actualOwner = IOwnable(beacons[i]).owner(); - // See `assertProdBeaconsOwnedBy`: the orchestrator beacon's own - // EOA -> Safe migration window overlaps the governance window, - // so its baked initial owner is an accepted extra pre-state - // until that migration's deadline. - if ( - i == ORCHESTRATOR_BEACON_INDEX && actualOwner == LibProdDeployV4.BEACON_INITIAL_OWNER - && block.timestamp < LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE - ) { + if (isOrchestratorBeaconAwaitingSafe(i, actualOwner)) { continue; } LibMigrationInvariant.assertMigration("beacon.owner()", actualOwner, 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 e6c2ffed..9047a09d 100644 --- a/test/script/20260729-migrate-governance-to-timelock.t.sol +++ b/test/script/20260729-migrate-governance-to-timelock.t.sol @@ -3,6 +3,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {LibOrchestratorInvariants} from "../../src/lib/LibOrchestratorInvariants.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"; @@ -524,4 +525,42 @@ contract MigrateGovernanceToTimelockTest is Test { ); assertTrue(controller.isOperationDone(id), "leg must complete after the delay"); } + + /// @notice The orchestrator beacon stays out of the bundle while its + /// baked deploy-key owner is pending, joins it once the Safe owns it, + /// and is refused as an unknown owner once its migration deadline + /// passes with the deploy key still holding it. + function testRunSkipsTheOrchestratorBeaconUntilTheSafeOwnsIt() external { + selectBaseFork(); + address timelock = deployTimelock(); + address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; + address[4] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + address orchestratorBeacon = beacons[LibBeaconInvariants.ORCHESTRATOR_BEACON_INDEX]; + MigrateGovernanceToTimelockHarness script = new MigrateGovernanceToTimelockHarness(timelock); + + vm.mockCall( + orchestratorBeacon, abi.encodeWithSignature("owner()"), abi.encode(LibProdDeployV4.BEACON_INITIAL_OWNER) + ); + address[] memory targets = script.callSelectBeaconTargets(safe, timelock); + assertEq(targets.length, 3, "deploy-key-owned orchestrator beacon must be skipped"); + for (uint256 i = 0; i < targets.length; i++) { + assertTrue(targets[i] != orchestratorBeacon, "orchestrator beacon selected while deploy-key-owned"); + } + + vm.mockCall(orchestratorBeacon, abi.encodeWithSignature("owner()"), abi.encode(safe)); + targets = script.callSelectBeaconTargets(safe, timelock); + assertEq(targets.length, 4, "Safe-owned orchestrator beacon must be selected"); + assertEq(targets[3], orchestratorBeacon, "orchestrator beacon must be last, in pinned order"); + + vm.mockCall( + orchestratorBeacon, abi.encodeWithSignature("owner()"), abi.encode(LibProdDeployV4.BEACON_INITIAL_OWNER) + ); + vm.warp(LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE); + vm.expectRevert( + abi.encodeWithSelector( + UnexpectedBeaconOwner.selector, orchestratorBeacon, LibProdDeployV4.BEACON_INITIAL_OWNER + ) + ); + script.callSelectBeaconTargets(safe, timelock); + } } diff --git a/test/script/MigrateGovernanceToTimelockHarness.sol b/test/script/MigrateGovernanceToTimelockHarness.sol index 4b3476a5..4f834439 100644 --- a/test/script/MigrateGovernanceToTimelockHarness.sol +++ b/test/script/MigrateGovernanceToTimelockHarness.sol @@ -21,4 +21,9 @@ contract MigrateGovernanceToTimelockHarness is MigrateGovernanceToTimelock { function activeChainTimelock() internal view override returns (address) { return I_TIMELOCK; } + + /// @notice The script's `_selectBeaconTargets()`, externally callable. + function callSelectBeaconTargets(address safe, address timelock) external view returns (address[] memory) { + return _selectBeaconTargets(safe, timelock); + } } diff --git a/test/src/lib/LibBeaconInvariants.t.sol b/test/src/lib/LibBeaconInvariants.t.sol index cc565de1..784293c7 100644 --- a/test/src/lib/LibBeaconInvariants.t.sol +++ b/test/src/lib/LibBeaconInvariants.t.sol @@ -3,6 +3,9 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {LibProdDeployV4} from "../../../src/generated/LibProdDeployV4.sol"; +import {LibOrchestratorInvariants} from "../../../src/lib/LibOrchestratorInvariants.sol"; +import {MigrationDeadlinePassed} from "../../../src/lib/LibMigrationInvariant.sol"; import { LibBeaconInvariants, IOwnable, @@ -288,4 +291,66 @@ contract LibBeaconInvariantsTest is Test { "generation pins collapsed into one value" ); } + + /// @notice The orchestrator beacon's baked deploy-key owner is pending, + /// not drift, until its own migration deadline. At the deadline both + /// sweeps refuse it, and the skip never reaches a token beacon. + function testOrchestratorBeaconDeployKeyOwnerIsPendingUntilItsDeadline() external { + selectBaseFork(); + address safe = LibSafeInvariants.safeForChainId(LibSafeInvariants.BASE_CHAIN_ID); + address timelock = address(0x7155); + uint256 deadline = LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE; + address[4] memory beacons = LibProdBeaconsBase.beacons(); + address orchestratorBeacon = beacons[LibBeaconInvariants.ORCHESTRATOR_BEACON_INDEX]; + vm.mockCall( + orchestratorBeacon, + abi.encodeWithSelector(IOwnable.owner.selector), + abi.encode(LibProdDeployV4.BEACON_INITIAL_OWNER) + ); + + harness.callAssertProdBeaconsOwnedByChainSafe(LibSafeInvariants.BASE_CHAIN_ID); + harness.callAssertProdBeaconsOwnershipMigration(LibSafeInvariants.BASE_CHAIN_ID, safe, timelock, deadline); + + vm.warp(deadline); + vm.expectRevert( + abi.encodeWithSelector( + BeaconOwnerMismatch.selector, orchestratorBeacon, safe, LibProdDeployV4.BEACON_INITIAL_OWNER + ) + ); + harness.callAssertProdBeaconsOwnedByChainSafe(LibSafeInvariants.BASE_CHAIN_ID); + // The token beacons already on the post-state, so the orchestrator + // index is the one the sweep refuses. + for (uint256 i = 0; i < LibBeaconInvariants.ORCHESTRATOR_BEACON_INDEX; i++) { + vm.mockCall(beacons[i], abi.encodeWithSelector(IOwnable.owner.selector), abi.encode(timelock)); + } + vm.expectRevert( + abi.encodeWithSelector( + MigrationDeadlinePassed.selector, + "beacon.owner()", + bytes32(uint256(uint160(timelock))), + bytes32(uint256(uint160(LibProdDeployV4.BEACON_INITIAL_OWNER))), + deadline + ) + ); + harness.callAssertProdBeaconsOwnershipMigration(LibSafeInvariants.BASE_CHAIN_ID, safe, timelock, deadline); + } + + /// @notice The deploy key is only an accepted owner at the orchestrator + /// index: a token beacon held by it is drift. + function testDeployKeyOwnerIsDriftOnATokenBeacon() external { + selectBaseFork(); + address beacon = LibProdBeaconsBase.beacons()[LibBeaconInvariants.RECEIPT_BEACON_INDEX]; + vm.mockCall( + beacon, abi.encodeWithSelector(IOwnable.owner.selector), abi.encode(LibProdDeployV4.BEACON_INITIAL_OWNER) + ); + vm.expectRevert( + abi.encodeWithSelector( + BeaconOwnerMismatch.selector, + beacon, + LibSafeInvariants.safeForChainId(LibSafeInvariants.BASE_CHAIN_ID), + LibProdDeployV4.BEACON_INITIAL_OWNER + ) + ); + harness.callAssertProdBeaconsOwnedByChainSafe(LibSafeInvariants.BASE_CHAIN_ID); + } } From e1e653c293e538843785a5c3acc26440ffe5d5e6 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Thu, 3 Sep 2026 12:48:50 +0000 Subject: [PATCH 6/7] ci: retrigger after Flare RPC 500 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EyWWorieTJV9FmMm2JM6Ua From cb2b15cf0f3a42cf0e3044522473ae828f60e24d Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Thu, 3 Sep 2026 13:00:25 +0000 Subject: [PATCH 7/7] Require the Safe on the orchestrator beacon; no deploy-key window The window encoded a state we do not want, the deploy key owning the orchestrator beacon, as an accepted one so this branch could go green before #317's broadcast. The sweeps now assert the state we want on all four beacons and the chain is brought to it by the broadcast. Until that lands on Base, Ethereum and HyperEVM every ownership sweep over the set refuses the orchestrator beacon, so the fleet upgrade and the timelock migration cannot author and this branch is red by design. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EyWWorieTJV9FmMm2JM6Ua --- ...60729-migrate-governance-to-timelock.s.sol | 5 -- src/lib/LibBeaconInvariants.sol | 21 ------ ...60729-migrate-governance-to-timelock.t.sol | 39 ----------- .../MigrateGovernanceToTimelockHarness.sol | 5 -- test/src/lib/LibBeaconInvariants.t.sol | 65 ------------------- 5 files changed, 135 deletions(-) diff --git a/script/20260729-migrate-governance-to-timelock.s.sol b/script/20260729-migrate-governance-to-timelock.s.sol index 10e0aa3f..e70d8278 100644 --- a/script/20260729-migrate-governance-to-timelock.s.sol +++ b/script/20260729-migrate-governance-to-timelock.s.sol @@ -13,7 +13,6 @@ 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 {LibOrchestratorInvariants} from "../src/lib/LibOrchestratorInvariants.sol"; import {LibProdDeployV4} from "../src/generated/LibProdDeployV4.sol"; import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; @@ -566,10 +565,6 @@ contract MigrateGovernanceToTimelock is Script { if (actual == timelock) { continue; } - // Not the Safe's to move yet; a later dispatch picks it up. - if (LibBeaconInvariants.isOrchestratorBeaconAwaitingSafe(i, actual)) { - continue; - } if (actual != safe) { revert UnexpectedBeaconOwner(beacons[i], actual); } diff --git a/src/lib/LibBeaconInvariants.sol b/src/lib/LibBeaconInvariants.sol index f0999256..6542539f 100644 --- a/src/lib/LibBeaconInvariants.sol +++ b/src/lib/LibBeaconInvariants.sol @@ -4,8 +4,6 @@ pragma solidity ^0.8.25; import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; import {LibMigrationInvariant} from "./LibMigrationInvariant.sol"; -import {LibOrchestratorInvariants} from "./LibOrchestratorInvariants.sol"; -import {LibProdDeployV4} from "../generated/LibProdDeployV4.sol"; import {LibProdBeaconsBase} from "./LibProdBeaconsBase.sol"; import {LibProdBeacons0_1_1} from "./LibProdBeacons0_1_1.sol"; import {LibSafeInvariants} from "./LibSafeInvariants.sol"; @@ -235,19 +233,6 @@ library LibBeaconInvariants { /// @notice Position of the orchestrator beacon in `prodBeaconsForChainId`. uint256 internal constant ORCHESTRATOR_BEACON_INDEX = 3; - /// @notice The orchestrator beacon joins the governed set mid-lifecycle: - /// its constructor bakes the deploy key as owner and - /// `20260818-migrate-orchestrator-beacon-owner` moves it to the chain's - /// Safe. Until that migration's deadline every ownership sweep over the - /// set treats the baked owner at that index as pending, not drift. - /// @param index Position in `prodBeaconsForChainId`. - /// @param owner The owner read from the beacon. - /// @return Whether the sweep skips this beacon. - function isOrchestratorBeaconAwaitingSafe(uint256 index, address owner) internal view returns (bool) { - return index == ORCHESTRATOR_BEACON_INDEX && owner == LibProdDeployV4.BEACON_INITIAL_OWNER - && block.timestamp < LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE; - } - /// @notice Expected runtime codehash of each beacon in /// `prodBeaconsForChainId`, index-aligned with it. /// @dev The set spans two build generations and a single pin cannot cover @@ -325,9 +310,6 @@ library LibBeaconInvariants { for (uint256 i = 0; i < beacons.length; i++) { _assertDeployedPinnedBeacon(beacons[i], codehashes[i]); address actualOwner = IOwnable(beacons[i]).owner(); - if (isOrchestratorBeaconAwaitingSafe(i, actualOwner)) { - continue; - } if (actualOwner != expectedOwner) { revert BeaconOwnerMismatch(beacons[i], expectedOwner, actualOwner); } @@ -371,9 +353,6 @@ library LibBeaconInvariants { for (uint256 i = 0; i < beacons.length; i++) { _assertDeployedPinnedBeacon(beacons[i], codehashes[i]); address actualOwner = IOwnable(beacons[i]).owner(); - if (isOrchestratorBeaconAwaitingSafe(i, actualOwner)) { - continue; - } LibMigrationInvariant.assertMigration("beacon.owner()", actualOwner, 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 9047a09d..e6c2ffed 100644 --- a/test/script/20260729-migrate-governance-to-timelock.t.sol +++ b/test/script/20260729-migrate-governance-to-timelock.t.sol @@ -3,7 +3,6 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {LibOrchestratorInvariants} from "../../src/lib/LibOrchestratorInvariants.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"; @@ -525,42 +524,4 @@ contract MigrateGovernanceToTimelockTest is Test { ); assertTrue(controller.isOperationDone(id), "leg must complete after the delay"); } - - /// @notice The orchestrator beacon stays out of the bundle while its - /// baked deploy-key owner is pending, joins it once the Safe owns it, - /// and is refused as an unknown owner once its migration deadline - /// passes with the deploy key still holding it. - function testRunSkipsTheOrchestratorBeaconUntilTheSafeOwnsIt() external { - selectBaseFork(); - address timelock = deployTimelock(); - address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; - address[4] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); - address orchestratorBeacon = beacons[LibBeaconInvariants.ORCHESTRATOR_BEACON_INDEX]; - MigrateGovernanceToTimelockHarness script = new MigrateGovernanceToTimelockHarness(timelock); - - vm.mockCall( - orchestratorBeacon, abi.encodeWithSignature("owner()"), abi.encode(LibProdDeployV4.BEACON_INITIAL_OWNER) - ); - address[] memory targets = script.callSelectBeaconTargets(safe, timelock); - assertEq(targets.length, 3, "deploy-key-owned orchestrator beacon must be skipped"); - for (uint256 i = 0; i < targets.length; i++) { - assertTrue(targets[i] != orchestratorBeacon, "orchestrator beacon selected while deploy-key-owned"); - } - - vm.mockCall(orchestratorBeacon, abi.encodeWithSignature("owner()"), abi.encode(safe)); - targets = script.callSelectBeaconTargets(safe, timelock); - assertEq(targets.length, 4, "Safe-owned orchestrator beacon must be selected"); - assertEq(targets[3], orchestratorBeacon, "orchestrator beacon must be last, in pinned order"); - - vm.mockCall( - orchestratorBeacon, abi.encodeWithSignature("owner()"), abi.encode(LibProdDeployV4.BEACON_INITIAL_OWNER) - ); - vm.warp(LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE); - vm.expectRevert( - abi.encodeWithSelector( - UnexpectedBeaconOwner.selector, orchestratorBeacon, LibProdDeployV4.BEACON_INITIAL_OWNER - ) - ); - script.callSelectBeaconTargets(safe, timelock); - } } diff --git a/test/script/MigrateGovernanceToTimelockHarness.sol b/test/script/MigrateGovernanceToTimelockHarness.sol index 4f834439..4b3476a5 100644 --- a/test/script/MigrateGovernanceToTimelockHarness.sol +++ b/test/script/MigrateGovernanceToTimelockHarness.sol @@ -21,9 +21,4 @@ contract MigrateGovernanceToTimelockHarness is MigrateGovernanceToTimelock { function activeChainTimelock() internal view override returns (address) { return I_TIMELOCK; } - - /// @notice The script's `_selectBeaconTargets()`, externally callable. - function callSelectBeaconTargets(address safe, address timelock) external view returns (address[] memory) { - return _selectBeaconTargets(safe, timelock); - } } diff --git a/test/src/lib/LibBeaconInvariants.t.sol b/test/src/lib/LibBeaconInvariants.t.sol index 784293c7..cc565de1 100644 --- a/test/src/lib/LibBeaconInvariants.t.sol +++ b/test/src/lib/LibBeaconInvariants.t.sol @@ -3,9 +3,6 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {LibProdDeployV4} from "../../../src/generated/LibProdDeployV4.sol"; -import {LibOrchestratorInvariants} from "../../../src/lib/LibOrchestratorInvariants.sol"; -import {MigrationDeadlinePassed} from "../../../src/lib/LibMigrationInvariant.sol"; import { LibBeaconInvariants, IOwnable, @@ -291,66 +288,4 @@ contract LibBeaconInvariantsTest is Test { "generation pins collapsed into one value" ); } - - /// @notice The orchestrator beacon's baked deploy-key owner is pending, - /// not drift, until its own migration deadline. At the deadline both - /// sweeps refuse it, and the skip never reaches a token beacon. - function testOrchestratorBeaconDeployKeyOwnerIsPendingUntilItsDeadline() external { - selectBaseFork(); - address safe = LibSafeInvariants.safeForChainId(LibSafeInvariants.BASE_CHAIN_ID); - address timelock = address(0x7155); - uint256 deadline = LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE; - address[4] memory beacons = LibProdBeaconsBase.beacons(); - address orchestratorBeacon = beacons[LibBeaconInvariants.ORCHESTRATOR_BEACON_INDEX]; - vm.mockCall( - orchestratorBeacon, - abi.encodeWithSelector(IOwnable.owner.selector), - abi.encode(LibProdDeployV4.BEACON_INITIAL_OWNER) - ); - - harness.callAssertProdBeaconsOwnedByChainSafe(LibSafeInvariants.BASE_CHAIN_ID); - harness.callAssertProdBeaconsOwnershipMigration(LibSafeInvariants.BASE_CHAIN_ID, safe, timelock, deadline); - - vm.warp(deadline); - vm.expectRevert( - abi.encodeWithSelector( - BeaconOwnerMismatch.selector, orchestratorBeacon, safe, LibProdDeployV4.BEACON_INITIAL_OWNER - ) - ); - harness.callAssertProdBeaconsOwnedByChainSafe(LibSafeInvariants.BASE_CHAIN_ID); - // The token beacons already on the post-state, so the orchestrator - // index is the one the sweep refuses. - for (uint256 i = 0; i < LibBeaconInvariants.ORCHESTRATOR_BEACON_INDEX; i++) { - vm.mockCall(beacons[i], abi.encodeWithSelector(IOwnable.owner.selector), abi.encode(timelock)); - } - vm.expectRevert( - abi.encodeWithSelector( - MigrationDeadlinePassed.selector, - "beacon.owner()", - bytes32(uint256(uint160(timelock))), - bytes32(uint256(uint160(LibProdDeployV4.BEACON_INITIAL_OWNER))), - deadline - ) - ); - harness.callAssertProdBeaconsOwnershipMigration(LibSafeInvariants.BASE_CHAIN_ID, safe, timelock, deadline); - } - - /// @notice The deploy key is only an accepted owner at the orchestrator - /// index: a token beacon held by it is drift. - function testDeployKeyOwnerIsDriftOnATokenBeacon() external { - selectBaseFork(); - address beacon = LibProdBeaconsBase.beacons()[LibBeaconInvariants.RECEIPT_BEACON_INDEX]; - vm.mockCall( - beacon, abi.encodeWithSelector(IOwnable.owner.selector), abi.encode(LibProdDeployV4.BEACON_INITIAL_OWNER) - ); - vm.expectRevert( - abi.encodeWithSelector( - BeaconOwnerMismatch.selector, - beacon, - LibSafeInvariants.safeForChainId(LibSafeInvariants.BASE_CHAIN_ID), - LibProdDeployV4.BEACON_INITIAL_OWNER - ) - ); - harness.callAssertProdBeaconsOwnedByChainSafe(LibSafeInvariants.BASE_CHAIN_ID); - } }