Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions script/20260818-deploy-orchestrator.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ contract DeployOrchestrator is Script {
if (instance != LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE) {
revert InstanceAddressMismatch(LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE, instance);
}
LibOrchestratorInvariants.assertBeaconSet();
LibOrchestratorInvariants.assertBeaconSet(safe);
LibOrchestratorInvariants.assertInstance(safe);
if (IAccessControl(instance).hasRole(bytes32(0), deployer)) {
revert DeployKeyHoldsAdmin(instance, deployer);
Expand All @@ -163,8 +163,8 @@ contract DeployOrchestrator is Script {
/// chain, owned by its token-owner Safe, and assert the pinned end state.
function run() external {
_assertClosureReady();
LibOrchestratorInvariants.assertBeaconSet();
address safe = LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid);
LibOrchestratorInvariants.assertBeaconSet(safe);

address setDeployer = LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30;
_assertNoInstanceYet(setDeployer);
Expand Down
102 changes: 102 additions & 0 deletions script/20260818-migrate-orchestrator-beacon-owner.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: LicenseRef-DCL-1.0
// SPDX-FileCopyrightText: Copyright (c) 2026 S01 Issuer GmbH
pragma solidity =0.8.25;

import {Script} from "forge-std-1.16.1/src/Script.sol";
import {console2} from "forge-std-1.16.1/src/console2.sol";
import {Ownable} from "@openzeppelin-contracts-5.6.1/access/Ownable.sol";

import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol";
import {LibProdDeployV4} from "../src/generated/LibProdDeployV4.sol";
import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol";
import {LibBeaconInvariants} from "../src/lib/LibBeaconInvariants.sol";
import {LibOrchestratorInvariants} from "../src/lib/LibOrchestratorInvariants.sol";
import {LibSafeOps} from "../src/lib/LibSafeOps.sol";

/// @title MigrateOrchestratorBeaconOwner
/// @notice **PENDING.** Transfers ownership of the ST0x orchestrator beacon
/// (`LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON`) on whichever chain
/// this is broadcast against, from the deploy EOA
/// (`LibProdDeployV4.BEACON_INITIAL_OWNER`, rainlang.eth) to that chain's
/// token-owner Safe — the orchestrator-beacon analogue of
/// `MigrateBeaconOwners` (Base token beacons, #253) and
/// `20260716`/`20260722-migrate-beacon-owners-*` (Ethereum / HyperEVM). The
/// beacon-set deployer's constructor bakes `BEACON_INITIAL_OWNER` as the
/// beacon's initial owner, so every chain the 0.1.30 closure ships to starts
/// EOA-owned and needs this migration.
///
/// @dev This is an **EOA broadcast, not a Safe artifact and not a CI
/// deploy-key dispatch**: an `Ownable` beacon transfers by its current owner
/// calling `transferOwnership`, and the current owner is the EOA — which is
/// why this script is deliberately absent from the `manual-broadcast.yaml`
/// registry (that dispatcher broadcasts as the CI deploy key). Broadcast as
/// the EOA, once per chain:
///
/// forge script script/20260818-migrate-orchestrator-beacon-owner.s.sol \
/// --rpc-url <base|ethereum|hyperevm> --broadcast --private-key <EOA key>
///
/// (HyperEVM additionally needs `--legacy`.)
///
/// Ordering: run any time after the chain carries the 0.1.30 closure
/// (`manual-sol-artifacts-0-1-30.yaml`) — the beacon exists from the
/// beacon-set deployer's constructor. Independent of the instance deploy
/// (`20260818-deploy-orchestrator`): beacon ownership gates `upgradeTo`,
/// not `deploy()`. The live-fork invariant
/// (`LibOrchestratorInvariants.assertBeaconSet`) accepts either owner until
/// `ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE` and only the Safe
/// after it, so cron red-lines any chain this migration misses.
///
/// The flow is the beacon-owner-migration standard shape:
///
/// 1. **Pre-flight** — the beacon is asserted in the EOA-owned state via
/// `LibBeaconInvariants.assertBeaconInvariants` (deployed, pinned OZ
/// `UpgradeableBeacon` codehash, EOA owner, pointing at the audited 0.1.30
/// orchestrator implementation). A drifted or already-migrated beacon
/// aborts before any broadcast — re-running after execution reverts on
/// the owner check rather than doing anything.
/// 2. **Broadcast** — one `transferOwnership(safe)` from the EOA.
/// 3. **Post-state** — the beacon is re-asserted, now Safe-owned, with the
/// implementation unchanged.
/// 4. **n+1 reversibility** — `LibSafeOps.simulateBeaconNPlus1` proves the
/// Safe can act on the beacon post-migration by routing an idempotent
/// `upgradeTo(currentImpl)` through the Safe's threshold-gated
/// `execTransaction`. Fork-local simulation, not broadcast.
contract MigrateOrchestratorBeaconOwner is Script {
/// @notice Pre-flight the EOA-owned beacon, broadcast the ownership
/// transfer to the active chain's token-owner Safe, re-assert the
/// Safe-owned state, and prove the Safe can operate the beacon.
function run() external {
address beacon = LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON;
address impl = LibProdDeployV4.ST0X_ORCHESTRATOR_0_1_30;
address safe = LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Pre-flight: deployed, OZ beacon bytecode, still EOA-owned, pointing
// at the audited 0.1.30 orchestrator impl. Reverts with the relevant
// typed error on the first drift, before any broadcast happens.
LibBeaconInvariants.assertBeaconInvariants(
beacon, LibProdDeployV4.BEACON_INITIAL_OWNER, impl, LibBeaconInvariants.UPGRADEABLE_BEACON_CODEHASH_0_1_30
);

console2.log("Migrating orchestrator beacon owner on chain id", block.chainid);
console2.log("beacon:", beacon);
console2.log("from (deploy EOA):", LibProdDeployV4.BEACON_INITIAL_OWNER);
console2.log("to (token-owner Safe):", safe);

vm.startBroadcast();
Ownable(beacon).transferOwnership(safe);
vm.stopBroadcast();

// Post-state: Safe-owned, implementation unchanged.
LibBeaconInvariants.assertBeaconInvariants(
beacon, safe, impl, LibBeaconInvariants.UPGRADEABLE_BEACON_CODEHASH_0_1_30
);

// n+1: the Safe can drive the beacon through its threshold-gated
// exec — an idempotent upgradeTo(currentImpl), simulated on the fork.
LibSafeOps.simulateBeaconNPlus1(
IGnosisSafe(safe), beacon, impl, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD
);

console2.log("Orchestrator beacon ownership transferred to the Safe; n+1 upgrade path proven.");
}
}
53 changes: 47 additions & 6 deletions src/lib/LibBeaconInvariants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,33 @@ library LibBeaconInvariants {
/// @dev Equal to
/// `LibProdDeployV1.PROD_BEACON_BASE_RUNTIME_CODEHASH_V1`; re-declared
/// here so the beacon invariant does not have to reach into the V1 deploy
/// library for a value that is a property of the OZ bytecode rather than
/// of any one deployment generation. Verified on Base on 2026-05-22
/// against the three live V1 beacons (receipt, receipt vault, wrapped
/// token vault), all of which share this codehash.
/// library. Verified on Base on 2026-05-22 against the three live V1
/// beacons (receipt, receipt vault, wrapped token vault), all of which
/// share this codehash.
///
/// This value is NOT a property of the OZ source alone, whatever an
/// earlier revision of this comment claimed. It is the runtime of one
/// build: OZ's `UpgradeableBeacon` as compiled for the V1 generation, at
/// the optimizer settings of that day. `optimizer_runs` has since moved
/// 5000 -> 2000, so a beacon compiled from today's tree is 728 bytes
/// against these 858 and cannot match. Pin a NEW constant per generation;
/// never repoint this one, which the live V1 fleet is still asserted
/// against.
bytes32 internal constant UPGRADEABLE_BEACON_CODEHASH =
0x8e95867e52db417944afd90f3b6c3c980962831e8a944e7f6958ba8f8cc10630;

/// @notice Runtime codehash of OZ `UpgradeableBeacon` as compiled by the
/// CURRENT tree — OpenZeppelin 5.6.1 at `optimizer_runs = 2000`. This is
/// the beacon `ST0xOrchestratorBeaconSetDeployer`'s constructor builds,
/// so it is what the orchestrator beacon must be asserted against.
/// @dev Re-derived by `testOrchestratorBeaconCodehashPin`, which compiles
/// a beacon and compares. That test is the whole guarantee: without it
/// this constant silently rots the next time OZ or the optimizer moves,
/// which is exactly how the V1 pin came to be asserted against a beacon
/// that could never match it.
bytes32 internal constant UPGRADEABLE_BEACON_CODEHASH_0_1_30 =
0x448cd06335de9e79ecdc51aa7c6647926860a1976f407de6f79f363b85ccaf2b;

/// @notice Assert the invariants of an OpenZeppelin `UpgradeableBeacon`
/// at `beacon`: it is a deployed contract, its runtime codehash matches
/// the pinned OZ `UpgradeableBeacon` bytecode, its `owner()` matches
Expand Down Expand Up @@ -152,6 +172,27 @@ library LibBeaconInvariants {
/// @param expectedImpl The implementation the beacon is expected to
/// point at.
function assertBeaconInvariants(address beacon, address expectedOwner, address expectedImpl) internal view {
assertBeaconInvariants(beacon, expectedOwner, expectedImpl, UPGRADEABLE_BEACON_CODEHASH);
}

/// @notice As `assertBeaconInvariants`, for a beacon of a generation other
/// than V1.
/// @dev The codehash is a parameter because it belongs to a build, not to
/// the OZ source: the V1 fleet and the 0.1.30 orchestrator beacon are both
/// canonical OZ `UpgradeableBeacon`s and their runtimes still differ. A
/// single hardcoded pin forced every caller onto V1's, which made the
/// orchestrator migration revert `BeaconCodehashMismatch` in pre-flight on
/// every chain.
/// @param beacon The beacon to assert.
/// @param expectedOwner The address `owner()` must return.
/// @param expectedImpl The address `implementation()` must return.
/// @param expectedCodehash The runtime codehash for this beacon's build.
function assertBeaconInvariants(
address beacon,
address expectedOwner,
address expectedImpl,
bytes32 expectedCodehash
) internal view {
if (beacon.code.length == 0) {
revert BeaconNotDeployed(beacon);
}
Expand All @@ -160,8 +201,8 @@ library LibBeaconInvariants {
assembly ("memory-safe") {
actualCodehash := extcodehash(beacon)
}
if (actualCodehash != UPGRADEABLE_BEACON_CODEHASH) {
revert BeaconCodehashMismatch(beacon, UPGRADEABLE_BEACON_CODEHASH, actualCodehash);
if (actualCodehash != expectedCodehash) {
revert BeaconCodehashMismatch(beacon, expectedCodehash, actualCodehash);
}

address actualOwner = IOwnable(beacon).owner();
Expand Down
34 changes: 22 additions & 12 deletions src/lib/LibOrchestratorInvariants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol";
import {Ownable} from "@openzeppelin-contracts-5.6.1/access/Ownable.sol";
import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol";
import {LibProdDeployV4} from "../generated/LibProdDeployV4.sol";
import {LibMigrationInvariant} from "./LibMigrationInvariant.sol";
import {IST0xOrchestratorV1} from "../interface/IST0xOrchestratorV1.sol";

/// @notice The orchestrator beacon-set deployer has no runtime code at its
Expand All @@ -25,11 +26,6 @@ error OrchestratorBeaconMismatch(address expected, address actual);
/// @param actual The implementation the beacon reports.
error OrchestratorBeaconImplMismatch(address expected, address actual);

/// @notice The orchestrator beacon's owner does not match the pinned owner.
/// @param expected The pinned beacon owner.
/// @param actual The owner the beacon reports.
error OrchestratorBeaconOwnerMismatch(address expected, address actual);

/// @notice The pinned orchestrator instance has no runtime code on the
/// active chain.
/// @param instance The pinned instance address.
Expand Down Expand Up @@ -79,11 +75,22 @@ library LibOrchestratorInvariants {
/// of truth, emitted by `BuildPointers`).
address internal constant ST0X_ORCHESTRATOR_INSTANCE = LibProdDeployV4.ST0X_ORCHESTRATOR_INSTANCE;

/// @notice Unix timestamp (2026-10-01T00:00:00Z) by which
/// `20260818-migrate-orchestrator-beacon-owner` must have moved the
/// orchestrator beacon's owner from the deploy EOA to the chain's
/// token-owner Safe. Until then either owner passes; after it only the
/// Safe does (see `LibMigrationInvariant`).
uint256 internal constant ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE = 1_790_812_800;

/// @notice Assert the orchestrator beacon set on the active chain: the
/// 0.1.30 beacon-set deployer is live, reports the pinned beacon, and the
/// beacon points at the audited 0.1.30 orchestrator implementation under
/// the pinned owner.
function assertBeaconSet() internal view {
/// beacon points at the audited 0.1.30 orchestrator implementation. The
/// beacon's owner is asserted through the owner-migration window: the
/// deploy EOA (`BEACON_INITIAL_OWNER`, pre) or `chainSafe` (post) until
/// the migration deadline, only `chainSafe` after it.
/// @param chainSafe The chain's token-owner Safe — the post-migration
/// beacon owner.
function assertBeaconSet(address chainSafe) internal view {
address setDeployer = LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30;
if (setDeployer.code.length == 0) {
revert OrchestratorSetDeployerMissing(setDeployer);
Expand All @@ -99,10 +106,13 @@ library LibOrchestratorInvariants {
revert OrchestratorBeaconImplMismatch(LibProdDeployV4.ST0X_ORCHESTRATOR_0_1_30, impl);
}

address owner = Ownable(beacon).owner();
if (owner != LibProdDeployV4.BEACON_INITIAL_OWNER) {
revert OrchestratorBeaconOwnerMismatch(LibProdDeployV4.BEACON_INITIAL_OWNER, owner);
}
LibMigrationInvariant.assertMigration(
"ST0X_ORCHESTRATOR_BEACON.owner()",
Ownable(beacon).owner(),
LibProdDeployV4.BEACON_INITIAL_OWNER,
chainSafe,
ST0X_ORCHESTRATOR_BEACON_OWNER_MIGRATION_DEADLINE
);
}

/// @notice Assert the pinned orchestrator instance on the active chain:
Expand Down
2 changes: 1 addition & 1 deletion test/script/20260818-deploy-orchestrator.prod.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ contract DeployOrchestratorProdTest is Test {
function assertInstanceLanded(string memory label) internal {
address instance = LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE;
address safe = LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid);
LibOrchestratorInvariants.assertBeaconSet();
LibOrchestratorInvariants.assertBeaconSet(safe);
LibOrchestratorInvariants.assertInstance(safe);
assertEq(
address(uint160(uint256(vm.load(instance, ERC1967_BEACON_SLOT)))),
Expand Down
35 changes: 35 additions & 0 deletions test/script/20260818-deploy-orchestrator.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
DeployKeyHoldsAdmin
} from "../../script/20260818-deploy-orchestrator.s.sol";
import {DeployOrchestratorHarness} from "./DeployOrchestratorHarness.sol";
import {MigrationStateDrift} from "../../src/lib/LibMigrationInvariant.sol";
import {
LibOrchestratorInvariants,
ST0xOrchestratorBeaconSetDeployerLike,
Expand Down Expand Up @@ -227,4 +228,38 @@ contract DeployOrchestratorTest is Test {
mockLandedInstance();
harness.assertDeployLanded(LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE, SAFE, DEPLOY_KEY);
}

/// The beacon-owner invariant accepts the post-migration state too: a
/// beacon already owned by the Safe (after
/// `20260818-migrate-orchestrator-beacon-owner`) passes.
function testLandedAcceptsASafeOwnedBeacon() external {
mockBeaconSet();
mockLandedInstance();
vm.mockCall(
LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON, abi.encodeCall(Ownable.owner, ()), abi.encode(SAFE)
);
harness.assertDeployLanded(LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE, SAFE, DEPLOY_KEY);
}

/// A beacon owner that is neither the deploy EOA nor the Safe is
/// migration-state drift, before or after the deadline.
function testLandedRefusesADriftedBeaconOwner() external {
mockBeaconSet();
mockLandedInstance();
vm.mockCall(
LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON,
abi.encodeCall(Ownable.owner, ()),
abi.encode(address(0xBAD))
);
vm.expectRevert(
abi.encodeWithSelector(
MigrationStateDrift.selector,
"ST0X_ORCHESTRATOR_BEACON.owner()",
bytes32(uint256(uint160(LibProdDeployV4.BEACON_INITIAL_OWNER))),
bytes32(uint256(uint160(SAFE))),
bytes32(uint256(uint160(address(0xBAD))))
)
);
harness.assertDeployLanded(LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE, SAFE, DEPLOY_KEY);
}
}
31 changes: 31 additions & 0 deletions test/src/lib/LibBeaconInvariants.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {LibProdBeacons0_1_1} from "../../../src/lib/LibProdBeacons0_1_1.sol";
import {LibProdDeployV1} from "../../../src/lib/LibProdDeployV1.sol";
import {LibStoxDeployNetworks} from "../../../src/lib/LibStoxDeployNetworks.sol";
import {LibBeaconInvariantsHarness} from "./LibBeaconInvariantsHarness.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 {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol";

Expand Down Expand Up @@ -257,4 +258,34 @@ contract LibBeaconInvariantsTest is Test {
LibSafeInvariants.BASE_CHAIN_ID, safe, post, block.timestamp + 1 days
);
}

/// @notice `UPGRADEABLE_BEACON_CODEHASH_0_1_30` is the runtime of a beacon
/// this tree actually compiles, re-derived rather than transcribed.
/// @dev The defect this exists to stop: the V1 pin was documented as "a
/// property of the OZ bytecode rather than of any one deployment
/// generation" and reused for the orchestrator beacon, which OZ 5.6.1 at
/// `optimizer_runs = 2000` builds 130 bytes shorter. Nothing compared the
/// two, so `MigrateOrchestratorBeaconOwner.run()` reverted
/// `BeaconCodehashMismatch` in pre-flight on every chain while the suite
/// stayed green. Constructing the beacon here means an OZ bump or an
/// optimizer change fails at build time instead of at a broadcast.
function testOrchestratorBeaconCodehashPin() external {
UpgradeableBeacon beacon = new UpgradeableBeacon(address(this), address(this));
assertEq(
address(beacon).codehash,
LibBeaconInvariants.UPGRADEABLE_BEACON_CODEHASH_0_1_30,
"0.1.30 beacon pin no longer matches a beacon this tree compiles"
);
}

/// @notice The two generation pins are distinct, and the V1 pin cannot be
/// used for a beacon this tree builds.
/// @dev Guards the shortcut of "fixing" a mismatch by repointing the V1
/// constant, which the live V1 fleet is still asserted against.
function testBeaconCodehashPinsAreDistinct() external pure {
assertTrue(
LibBeaconInvariants.UPGRADEABLE_BEACON_CODEHASH != LibBeaconInvariants.UPGRADEABLE_BEACON_CODEHASH_0_1_30,
"generation pins collapsed into one value"
);
}
}
Loading