From 76a8311ef50e71c251b5142e15006a60a5b5a8a1 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 18 Aug 2026 09:14:10 +0000 Subject: [PATCH] Orchestrator instance deploy script + pinned invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20260818-deploy-orchestrator (manual-broadcast, per network): one deploy-key broadcast that calls the 0.1.8 beacon-set deployer's deploy(owner) with the chain's token-owner Safe, so the Safe holds DEFAULT_ADMIN_ROLE from initialisation and the deploy key never holds anything (asserted). Unlike the token deploys there is no owner-gated wiring step, so no transient deploy-key ownership window exists. The whole surface is deterministic and pinned UP FRONT in the new LibOrchestratorInvariants: the beacon is the set deployer's CREATE at nonce 1 (0xbA0d89f6, matches live Base) and the first deploy()'s BeaconProxy is its CREATE at nonce 2 (0x21372a9D), identical on every chain. Pre-flight enforces the audited 0.1.8 closure by codehash, refuses a live pinned instance, and refuses a set-deployer nonce that shows an unpinned earlier deploy(). Prod fork test walks the rollout's three states per chain with a 2026-10-01 deadline: closure pending (Ethereum/HyperEVM — pins the ClosureNotDeployed refusal), instance pending (Base — drives run() end to end on the fork and asserts the pinned end state incl. the ERC-1967 beacon slot), executed (steady-state instance asserts, the orchestrator analogue of the token-instance prod asserts, plus the re-dispatch refusal). Guard coverage without a fork in the unit test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KbsbYN4C4YDa8pu9DdudoX --- .github/workflows/manual-broadcast.yaml | 1 + script/20260818-deploy-orchestrator.s.sol | 192 +++++++++++++++ script/BuildPointers.sol | 18 ++ src/generated/LibProdDeployV4.sol | 2 + src/lib/LibOrchestratorInvariants.sol | 134 ++++++++++ .../20260818-deploy-orchestrator.prod.t.sol | 131 ++++++++++ .../script/20260818-deploy-orchestrator.t.sol | 230 ++++++++++++++++++ test/script/DeployOrchestratorHarness.sol | 20 ++ 8 files changed, 728 insertions(+) create mode 100644 script/20260818-deploy-orchestrator.s.sol create mode 100644 src/lib/LibOrchestratorInvariants.sol create mode 100644 test/script/20260818-deploy-orchestrator.prod.t.sol create mode 100644 test/script/20260818-deploy-orchestrator.t.sol create mode 100644 test/script/DeployOrchestratorHarness.sol diff --git a/.github/workflows/manual-broadcast.yaml b/.github/workflows/manual-broadcast.yaml index b88e508b..de0c725b 100644 --- a/.github/workflows/manual-broadcast.yaml +++ b/.github/workflows/manual-broadcast.yaml @@ -35,6 +35,7 @@ on: - 20260807-deploy-missing-tokens - 20260729-deploy-governance-timelock - 20260813-execute-timelock-operations + - 20260818-deploy-orchestrator network: description: 'Network to broadcast against (default: base)' required: true diff --git a/script/20260818-deploy-orchestrator.s.sol b/script/20260818-deploy-orchestrator.s.sol new file mode 100644 index 00000000..42717041 --- /dev/null +++ b/script/20260818-deploy-orchestrator.s.sol @@ -0,0 +1,192 @@ +// 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 {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; +import {LibProdDeployV4} from "../src/generated/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; +import {LibOrchestratorInvariants} from "../src/lib/LibOrchestratorInvariants.sol"; +import {IST0xOrchestratorBeaconSetDeployerV1} from "../src/interface/IST0xOrchestratorBeaconSetDeployerV1.sol"; + +/// @notice Pre-flight failed: a contract of the audited 0.1.30 orchestrator +/// closure has no runtime code at its pinned address on the active chain. +/// Ship the closure first via `manual-sol-artifacts-0-1-30.yaml`. +/// @param pinned The pinned closure address that is missing. +error ClosureNotDeployed(address pinned); + +/// @notice Pre-flight failed: a closure contract's runtime codehash does not +/// match its 0.1.30 pin — something other than the audited bytecode sits at +/// the pinned address. +/// @param pinned The pinned closure address inspected. +/// @param expected The pinned 0.1.30 codehash. +/// @param actual The codehash read from the chain. +error ClosureCodehashMismatch(address pinned, bytes32 expected, bytes32 actual); + +/// @notice The pinned orchestrator instance already has code on this chain — +/// the production instance exists and there is nothing to deploy. This is +/// also what makes a re-dispatch a refusal rather than a duplicate. +/// @param instance The pinned instance address. +error OrchestratorAlreadyDeployed(address instance); + +/// @notice The beacon-set deployer's account nonce is not the fresh-deploy +/// value, yet the pinned instance has no code. Someone has driven the +/// deployer onto a state this script does not understand (an unpinned +/// instance, or worse) — resolve manually rather than deploying a second +/// instance at an unpinned address. +/// @param setDeployer The beacon-set deployer inspected. +/// @param nonce The account nonce read from the chain. +error UnexpectedSetDeployerNonce(address setDeployer, uint64 nonce); + +/// @notice `deploy()` returned an address other than the pinned instance. +/// The pin's nonce-2 derivation and the live deploy disagree — nothing about +/// the deployed contract should be trusted. +/// @param expected The pinned instance address. +/// @param actual The address `deploy()` returned. +error InstanceAddressMismatch(address expected, address actual); + +/// @notice The CI deploy key ended up holding `DEFAULT_ADMIN_ROLE` on the +/// deployed instance. `deploy(safe)` grants the Safe alone, so this firing +/// means the instance was initialised with the wrong owner. +/// @param instance The orchestrator instance inspected. +/// @param deployer The deploy key that must NOT hold the role. +error DeployKeyHoldsAdmin(address instance, address deployer); + +/// @title DeployOrchestrator +/// @notice **PENDING.** Deploys the production `ST0xOrchestrator` instance on +/// whichever chain this is dispatched against and lands its +/// `DEFAULT_ADMIN_ROLE` on that chain's token-owner Safe — one deploy-key +/// broadcast, no Safe signature. +/// +/// @dev Dispatch via `Actions → manual-broadcast` with +/// `script = 20260818-deploy-orchestrator` and `network` set to the target +/// chain (`base` / `ethereum` / `hyperevm`). One dispatch covers one chain. +/// Pre-requisite per chain: the audited 0.1.30 orchestrator closure must be +/// live (`manual-sol-artifacts-0-1-30.yaml`) — pre-flight enforces it by +/// codehash, and `initialize`'s vault-logic version lock would revert the +/// deploy anyway if the OARV beacon-set deployer were absent. +/// +/// Unlike the token deploys (`20260807-deploy-missing-tokens`), no transient +/// deploy-key ownership window exists: token vaults need the deploy key as +/// `initialAdmin` to call the owner-gated `setAuthorizer` before handing +/// ownership to the Safe, but the orchestrator has no owner-gated wiring +/// step, so the Safe is passed straight to `deploy(owner)` and the deploy +/// key never holds `DEFAULT_ADMIN_ROLE` at all (asserted after the deploy). +/// Operational role grants (`MINT_ROLE` / `BURN_ROLE` for the issuance bot's +/// signer) are Safe transactions performed later per the issuance-side +/// onboarding runbook — deliberately not this script's business. +/// +/// The instance address is deterministic and pinned UP FRONT +/// (`LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE`): the first +/// `deploy()` is the beacon-set deployer's `CREATE` at account nonce 2, and +/// the deployer itself is a Zoltu deploy, so the same instance address lands +/// on every chain. The nonce guard refuses to broadcast against a deployer +/// that has already served a `deploy()` — the pin, not event discovery, is +/// the source of truth (`deploy` is permissionless, so an attacker can emit +/// lookalike `Deployment` events; see the interface's event NatSpec). +contract DeployOrchestrator is Script { + /// @notice Assert one closure contract is live at its pin with the + /// audited 0.1.30 codehash. + /// @param pinned The pinned closure address. + /// @param codehash The pinned 0.1.30 codehash. + function _assertClosureContract(address pinned, bytes32 codehash) internal view { + if (pinned.code.length == 0) { + revert ClosureNotDeployed(pinned); + } + if (pinned.codehash != codehash) { + revert ClosureCodehashMismatch(pinned, codehash, pinned.codehash); + } + } + + /// @notice Assert the full audited 0.1.30 orchestrator closure is live on + /// the active chain, by codehash, in dependency order. + function _assertClosureReady() internal view { + _assertClosureContract( + LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_0_1_30, + LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_CODEHASH_0_1_30 + ); + _assertClosureContract(LibProdDeployV4.STOX_RECEIPT_0_1_30, LibProdDeployV4.STOX_RECEIPT_CODEHASH_0_1_30); + _assertClosureContract( + LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30, LibProdDeployV4.STOX_RECEIPT_VAULT_CODEHASH_0_1_30 + ); + _assertClosureContract( + LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_BEACON_SET_DEPLOYER_0_1_30, + LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_BEACON_SET_DEPLOYER_CODEHASH_0_1_30 + ); + _assertClosureContract( + LibProdDeployV4.ST0X_ORCHESTRATOR_0_1_30, LibProdDeployV4.ST0X_ORCHESTRATOR_CODEHASH_0_1_30 + ); + _assertClosureContract( + LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30, + LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_CODEHASH_0_1_30 + ); + } + + /// @notice Assert the beacon-set deployer is in the fresh-deploy state + /// the instance pin's nonce-2 derivation assumes: no code at the pinned + /// instance and the deployer's account nonce still at 2 (its constructor + /// `CREATE`d the beacon at nonce 1). + /// @param setDeployer The beacon-set deployer to inspect. + function _assertNoInstanceYet(address setDeployer) internal view { + address instance = LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE; + if (instance.code.length != 0) { + revert OrchestratorAlreadyDeployed(instance); + } + uint64 nonce = vm.getNonce(setDeployer); + if (nonce != 2) { + revert UnexpectedSetDeployerNonce(setDeployer, nonce); + } + } + + /// @notice Assert the deployed instance landed exactly as pinned: at the + /// pinned address, beacon set intact, Safe holding `DEFAULT_ADMIN_ROLE`, + /// vault-logic lock passing, and the deploy key holding nothing. + /// @dev Public so the failure modes can be driven directly from a test — + /// an assertion reachable only from inside a broadcast cannot be shown to + /// fire. Mirrors `20260807-deploy-missing-tokens.assertHandoffLanded`. + /// @param instance The address `deploy()` returned. + /// @param safe The chain's token-owner Safe that must hold admin. + /// @param deployer The deploy key that must hold nothing. + function assertDeployLanded(address instance, address safe, address deployer) public view { + if (instance != LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE) { + revert InstanceAddressMismatch(LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE, instance); + } + LibOrchestratorInvariants.assertBeaconSet(); + LibOrchestratorInvariants.assertInstance(safe); + if (IAccessControl(instance).hasRole(bytes32(0), deployer)) { + revert DeployKeyHoldsAdmin(instance, deployer); + } + } + + /// @notice Deploy the production orchestrator instance on the active + /// 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); + + address setDeployer = LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30; + _assertNoInstanceYet(setDeployer); + + vm.startBroadcast(); + + // Deployer identity — inside `vm.startBroadcast()` msg.sender + // resolves to the broadcast address (`--private-key` in production). + address deployer = msg.sender; + + console2.log("Deploying ST0xOrchestrator instance on chain id", block.chainid); + console2.log("beacon-set deployer:", setDeployer); + console2.log("deploy key (holds no role):", deployer); + console2.log("DEFAULT_ADMIN_ROLE (token-owner Safe):", safe); + + address instance = IST0xOrchestratorBeaconSetDeployerV1(setDeployer).deploy(safe); + assertDeployLanded(instance, safe, deployer); + + vm.stopBroadcast(); + + console2.log("==== ORCHESTRATOR DEPLOYED ===="); + console2.log("instance:", vm.toString(instance)); + console2.log("Admin is the token-owner Safe; the deploy key holds no role."); + } +} diff --git a/script/BuildPointers.sol b/script/BuildPointers.sol index 26d26882..6a970d52 100644 --- a/script/BuildPointers.sol +++ b/script/BuildPointers.sol @@ -356,6 +356,24 @@ contract BuildPointers is Script { "address constant STOX_PROD_AUTHORISER_V4_CLONE_HYPEREVM = address(0x66566cc91dEAf818859bD4b09B7903ac48998157);" ); vm.writeLine(GEN_V4_PATH, "uint256 constant V4_SWAP_DEADLINE = 1_793_491_200;"); + // ST0x orchestrator beacon + production instance — CREATE-derived + // from the 0.1.30 orchestrator beacon-set deployer (itself a Zoltu + // deploy), so chain-invariant like every Zoltu pin: the beacon is the + // deployer constructor's CREATE at account nonce 1, and the instance + // is the first `deploy()` call's BeaconProxy at nonce 2 + // (`20260818-deploy-orchestrator` refuses any other deployer state). + // Carried as literals like the authoriser clone above; + // `testOrchestratorBeaconPin` / `testOrchestratorInstancePin` + // re-derive both from the deployer pin so a drifted literal fails a + // test. + vm.writeLine( + GEN_V4_PATH, + "address constant ST0X_ORCHESTRATOR_BEACON = address(0xb9DCd744b0413Dff0EDC70A5B229c7aa03734613);" + ); + vm.writeLine( + GEN_V4_PATH, + "address constant ST0X_ORCHESTRATOR_INSTANCE = address(0x3A7387a484d87Aa8bBA45E98AAB401Ce4FBF03E2);" + ); for (uint256 t = 0; t < tags.length; t++) { for (uint256 c = 0; c < 12; c++) { if (pointerExists(tags[t], names[c])) { diff --git a/src/generated/LibProdDeployV4.sol b/src/generated/LibProdDeployV4.sol index db5d5169..4fd4fdf3 100644 --- a/src/generated/LibProdDeployV4.sol +++ b/src/generated/LibProdDeployV4.sol @@ -180,6 +180,8 @@ library LibProdDeployV4 { address constant STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM = address(0x66566cc91dEAf818859bD4b09B7903ac48998157); address constant STOX_PROD_AUTHORISER_V4_CLONE_HYPEREVM = address(0x66566cc91dEAf818859bD4b09B7903ac48998157); uint256 constant V4_SWAP_DEADLINE = 1_793_491_200; + address constant ST0X_ORCHESTRATOR_BEACON = address(0xb9DCd744b0413Dff0EDC70A5B229c7aa03734613); + address constant ST0X_ORCHESTRATOR_INSTANCE = address(0x3A7387a484d87Aa8bBA45E98AAB401Ce4FBF03E2); address constant STOX_RECEIPT_0_1_1 = STOX_RECEIPT_ADDRESS_0_1_1_GEN; bytes32 constant STOX_RECEIPT_CODEHASH_0_1_1 = STOX_RECEIPT_CODEHASH_0_1_1_GEN; bytes constant STOX_RECEIPT_CREATION_CODE_0_1_1 = STOX_RECEIPT_CREATION_0_1_1_GEN; diff --git a/src/lib/LibOrchestratorInvariants.sol b/src/lib/LibOrchestratorInvariants.sol new file mode 100644 index 00000000..b2bdeccb --- /dev/null +++ b/src/lib/LibOrchestratorInvariants.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2026 S01 Issuer GmbH +pragma solidity ^0.8.25; + +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 {IST0xOrchestratorV1} from "../interface/IST0xOrchestratorV1.sol"; + +/// @notice The orchestrator beacon-set deployer has no runtime code at its +/// pinned 0.1.30 address on the active chain. +/// @param setDeployer The pinned deployer address that is missing. +error OrchestratorSetDeployerMissing(address setDeployer); + +/// @notice The beacon the set deployer reports does not match the pinned +/// beacon address. +/// @param expected The pinned beacon address. +/// @param actual The beacon the set deployer reports. +error OrchestratorBeaconMismatch(address expected, address actual); + +/// @notice The orchestrator beacon does not point at the audited 0.1.30 +/// orchestrator implementation. +/// @param expected The pinned 0.1.30 implementation. +/// @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. +error OrchestratorInstanceMissing(address instance); + +/// @notice The expected admin does not hold `DEFAULT_ADMIN_ROLE` on the +/// orchestrator instance. An instance without the chain's Safe as admin is +/// ungovernable (or governed by the wrong key). +/// @param instance The orchestrator instance inspected. +/// @param expectedAdmin The address that must hold `DEFAULT_ADMIN_ROLE`. +error OrchestratorAdminMissing(address instance, address expectedAdmin); + +/// @notice The orchestrator instance's vault-logic version lock does not +/// pass: the OARV beacon-set deployer it was built against reports +/// implementations other than the ones the orchestrator was compiled for, +/// so `mint`/`burn` revert. +/// @param instance The orchestrator instance inspected. +error OrchestratorVaultLogicUnexpected(address instance); + +/// @title LibOrchestratorInvariants +/// @notice Pins and live-state invariants for the ST0x orchestrator +/// instance — the orchestrator analogue of the token pins in +/// `LibTokenInvariants` and the beacon pins in `LibProdBeacons*`. +/// +/// The whole surface is deterministic, so it is pinned up front rather than +/// hydrated from a broadcast: the beacon-set deployer is a Zoltu deploy (the +/// 0.1.30 pin), the beacon is the deployer constructor's first `CREATE` +/// (deployer nonce 1), and the first `deploy()` call's `BeaconProxy` is the +/// deployer's second `CREATE` (nonce 2). Identical deployer address + +/// identical nonces ⇒ identical beacon and instance addresses on every +/// chain. +library LibOrchestratorInvariants { + /// @notice The `UpgradeableBeacon` created by the 0.1.30 + /// `ST0xOrchestratorBeaconSetDeployer`'s constructor — its `CREATE` at + /// nonce 1, so the same address on every chain the deployer is on. + /// Aliases the `LibProdDeployV4` pin (single source of truth, emitted by + /// `BuildPointers`). + address internal constant ST0X_ORCHESTRATOR_BEACON = LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON; + + /// @notice The production orchestrator instance: the `BeaconProxy` minted + /// by the FIRST `deploy()` call on the 0.1.30 beacon-set deployer — its + /// `CREATE` at nonce 2, so the same address on every chain where the + /// instance is the first one deployed. + /// `20260818-deploy-orchestrator` refuses to broadcast against a deployer + /// whose nonce shows an earlier `deploy()`, so a pinned instance is + /// always this address. Aliases the `LibProdDeployV4` pin (single source + /// of truth, emitted by `BuildPointers`). + address internal constant ST0X_ORCHESTRATOR_INSTANCE = LibProdDeployV4.ST0X_ORCHESTRATOR_INSTANCE; + + /// @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 { + address setDeployer = LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30; + if (setDeployer.code.length == 0) { + revert OrchestratorSetDeployerMissing(setDeployer); + } + + address beacon = address(ST0xOrchestratorBeaconSetDeployerLike(setDeployer).iOrchestratorBeacon()); + if (beacon != ST0X_ORCHESTRATOR_BEACON) { + revert OrchestratorBeaconMismatch(ST0X_ORCHESTRATOR_BEACON, beacon); + } + + address impl = IBeacon(beacon).implementation(); + if (impl != LibProdDeployV4.ST0X_ORCHESTRATOR_0_1_30) { + 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); + } + } + + /// @notice Assert the pinned orchestrator instance on the active chain: + /// it has code, `expectedAdmin` holds `DEFAULT_ADMIN_ROLE`, and its + /// vault-logic version lock passes (so `mint`/`burn` are operable). + /// @param expectedAdmin The address that must hold `DEFAULT_ADMIN_ROLE` + /// — the chain's token-owner Safe. + function assertInstance(address expectedAdmin) internal view { + address instance = ST0X_ORCHESTRATOR_INSTANCE; + if (instance.code.length == 0) { + revert OrchestratorInstanceMissing(instance); + } + // DEFAULT_ADMIN_ROLE is 0x00 in OZ AccessControl. + if (!IAccessControl(instance).hasRole(bytes32(0), expectedAdmin)) { + revert OrchestratorAdminMissing(instance, expectedAdmin); + } + if (!IST0xOrchestratorV1(instance).vaultLogicIsExpected()) { + revert OrchestratorVaultLogicUnexpected(instance); + } + } +} + +/// @dev Local mirror of the set deployer's `iOrchestratorBeacon` immutable +/// getter — `IST0xOrchestratorBeaconSetDeployerV1` carries only the +/// `deploy` surface, and the getter is a concrete-contract detail the +/// interface deliberately omits. +interface ST0xOrchestratorBeaconSetDeployerLike { + function iOrchestratorBeacon() external view returns (IBeacon); +} diff --git a/test/script/20260818-deploy-orchestrator.prod.t.sol b/test/script/20260818-deploy-orchestrator.prod.t.sol new file mode 100644 index 00000000..db8ab56a --- /dev/null +++ b/test/script/20260818-deploy-orchestrator.prod.t.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2026 S01 Issuer GmbH +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {console2} from "forge-std-1.16.1/src/console2.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; + +import { + DeployOrchestrator, + ClosureNotDeployed, + OrchestratorAlreadyDeployed +} from "../../script/20260818-deploy-orchestrator.s.sol"; +import {LibOrchestratorInvariants} from "../../src/lib/LibOrchestratorInvariants.sol"; +import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; +import {LibStoxDeployNetworks} from "../../src/lib/LibStoxDeployNetworks.sol"; + +/// @notice The orchestrator rollout deadline passed with this chain still +/// pending — the 0.1.30 closure or the instance deploy never landed. Run the +/// outstanding dispatch, extend the deadline, or delete the invariant. +/// @param label The chain still pending. +error OrchestratorRolloutOverdue(string label); + +/// @title DeployOrchestratorProdTest +/// @notice PROD coverage for the orchestrator instance deploy: what +/// production IS on each chain, read from a real fork with no mocks. The +/// same test walks the rollout's three states, loudly, so it merges before +/// the broadcasts and keeps asserting after them: +/// +/// 1. **Closure pending** (every chain until `manual-sol-artifacts-0-1-30` +/// ships the audited set there): pins the pre-flight refusal — `run()` +/// reverts `ClosureNotDeployed` naming the first missing contract — and +/// logs the outstanding dispatch. +/// 2. **Instance pending** (closure live, no instance): drives +/// `run()` end to end on the fork and asserts the pinned end state — the +/// instance at its nonce-2 pin, `DEFAULT_ADMIN_ROLE` on the chain's +/// token-owner Safe, the vault-logic lock passing, and the proxy's +/// ERC-1967 beacon slot holding the pinned beacon. +/// 3. **Executed** (the broadcast landed): asserts the live instance exactly +/// as state 2 asserted the simulated one — the orchestrator analogue of +/// the token-instance prod asserts — and pins the re-dispatch refusal +/// (`OrchestratorAlreadyDeployed`). +/// +/// States 1 and 2 stop passing at `ORCHESTRATOR_ROLLOUT_DEADLINE`: a chain +/// still pending then red-lines cron CI, forcing an explicit operator choice +/// — run the outstanding dispatch, extend the deadline, or delete the +/// invariant. State 3 is the steady state and never expires. +contract DeployOrchestratorProdTest is Test { + /// @notice Unix timestamp (2026-10-01T00:00:00Z) past which a chain + /// still in a pending state fails instead of logging PENDING. + uint256 internal constant ORCHESTRATOR_ROLLOUT_DEADLINE = 1_790_812_800; + + /// @notice The ERC-1967 beacon slot + /// (`bytes32(uint256(keccak256("eip1967.proxy.beacon")) - 1)`). + bytes32 internal constant ERC1967_BEACON_SLOT = 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50; + + /// @notice Assert the pinned instance's live wiring: ERC-1967 beacon + /// slot holding the pinned beacon, the beacon set intact, admin on the + /// chain's token-owner Safe, and the vault-logic lock passing. + /// @param label Human chain name, surfaced in assertion messages. + function assertInstanceLanded(string memory label) internal { + address instance = LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE; + address safe = LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid); + LibOrchestratorInvariants.assertBeaconSet(); + LibOrchestratorInvariants.assertInstance(safe); + assertEq( + address(uint160(uint256(vm.load(instance, ERC1967_BEACON_SLOT)))), + LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON, + string.concat(label, ": instance ERC-1967 beacon slot is not the pinned beacon") + ); + } + + /// @notice Walk the active fork's rollout state (see the contract + /// NatSpec) and assert it. + /// @param label Human chain name, surfaced in logs and messages. + function assertOrchestratorRollout(string memory label) internal { + DeployOrchestrator script = new DeployOrchestrator(); + address setDeployer = LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30; + address instance = LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE; + + if (setDeployer.code.length == 0) { + if (block.timestamp >= ORCHESTRATOR_ROLLOUT_DEADLINE) { + revert OrchestratorRolloutOverdue(label); + } + console2.log(string.concat("PENDING [", label, "]: 0.1.30 closure not deployed")); + console2.log("-> dispatch manual-sol-artifacts-0-1-30 for every suite, then 20260818-deploy-orchestrator"); + // The pre-flight checks the closure in dependency order, so the + // corporate-actions facet is the first refusal on a chain with + // no closure at all. + vm.expectRevert( + abi.encodeWithSelector(ClosureNotDeployed.selector, LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_0_1_30) + ); + script.run(); + return; + } + + if (instance.code.length == 0) { + if (block.timestamp >= ORCHESTRATOR_ROLLOUT_DEADLINE) { + revert OrchestratorRolloutOverdue(label); + } + console2.log(string.concat("PENDING [", label, "]: orchestrator instance not deployed")); + console2.log("-> dispatch manual-broadcast 20260818-deploy-orchestrator against this chain"); + // Drive the deploy end to end on the fork; `run()`'s own + // `assertDeployLanded` plus the independent re-assert below pin + // the end state the real broadcast must reproduce. + script.run(); + assertInstanceLanded(label); + return; + } + + assertInstanceLanded(label); + vm.expectRevert(abi.encodeWithSelector(OrchestratorAlreadyDeployed.selector, instance)); + script.run(); + } + + function testOrchestratorRolloutBase() external { + vm.createSelectFork(LibRainDeploy.BASE); + assertOrchestratorRollout("base"); + } + + function testOrchestratorRolloutEthereum() external { + vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); + assertOrchestratorRollout("ethereum"); + } + + function testOrchestratorRolloutHyperEvm() external { + vm.createSelectFork(LibStoxDeployNetworks.HYPEREVM); + assertOrchestratorRollout("hyperevm"); + } +} diff --git a/test/script/20260818-deploy-orchestrator.t.sol b/test/script/20260818-deploy-orchestrator.t.sol new file mode 100644 index 00000000..131f4f2f --- /dev/null +++ b/test/script/20260818-deploy-orchestrator.t.sol @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2026 S01 Issuer GmbH +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 {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; +import {Ownable} from "@openzeppelin-contracts-5.6.1/access/Ownable.sol"; + +import { + ClosureNotDeployed, + ClosureCodehashMismatch, + OrchestratorAlreadyDeployed, + UnexpectedSetDeployerNonce, + InstanceAddressMismatch, + DeployKeyHoldsAdmin +} from "../../script/20260818-deploy-orchestrator.s.sol"; +import {DeployOrchestratorHarness} from "./DeployOrchestratorHarness.sol"; +import { + LibOrchestratorInvariants, + ST0xOrchestratorBeaconSetDeployerLike, + OrchestratorAdminMissing +} from "../../src/lib/LibOrchestratorInvariants.sol"; +import {IST0xOrchestratorV1} from "../../src/interface/IST0xOrchestratorV1.sol"; +import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; + +/// @title DeployOrchestratorTest +/// @notice Guard coverage for `20260818-deploy-orchestrator` without a fork: +/// each pre-flight refusal and each `assertDeployLanded` failure mode is +/// shown to fire. The live-fork walk of the rollout states is in +/// `20260818-deploy-orchestrator.prod.t.sol`. +contract DeployOrchestratorTest is Test { + DeployOrchestratorHarness internal harness; + + address internal constant SAFE = address(0x5AFE); + address internal constant DEPLOY_KEY = address(0xDEAD); + + function setUp() external { + harness = new DeployOrchestratorHarness(); + } + + /// @notice Etch every closure contract's frozen 0.1.30 runtime bytecode + /// at its pin, so the codehash pre-flight passes on a blank chain. + function etchClosure() internal { + vm.etch( + LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_0_1_30, + LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_RUNTIME_CODE_0_1_30 + ); + vm.etch(LibProdDeployV4.STOX_RECEIPT_0_1_30, LibProdDeployV4.STOX_RECEIPT_RUNTIME_CODE_0_1_30); + vm.etch(LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30, LibProdDeployV4.STOX_RECEIPT_VAULT_RUNTIME_CODE_0_1_30); + vm.etch( + LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_BEACON_SET_DEPLOYER_0_1_30, + LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_BEACON_SET_DEPLOYER_RUNTIME_CODE_0_1_30 + ); + vm.etch(LibProdDeployV4.ST0X_ORCHESTRATOR_0_1_30, LibProdDeployV4.ST0X_ORCHESTRATOR_RUNTIME_CODE_0_1_30); + vm.etch( + LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30, + LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_RUNTIME_CODE_0_1_30 + ); + } + + /// @notice Mock the pinned beacon set so `assertBeaconSet` passes: the + /// set deployer reports the pinned beacon, and the beacon reports the + /// 0.1.30 implementation under the pinned owner. + function mockBeaconSet() internal { + vm.mockCall( + LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30, + abi.encodeCall(ST0xOrchestratorBeaconSetDeployerLike.iOrchestratorBeacon, ()), + abi.encode(LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON) + ); + vm.mockCall( + LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON, + abi.encodeCall(IBeacon.implementation, ()), + abi.encode(LibProdDeployV4.ST0X_ORCHESTRATOR_0_1_30) + ); + vm.mockCall( + LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON, + abi.encodeCall(Ownable.owner, ()), + abi.encode(LibProdDeployV4.BEACON_INITIAL_OWNER) + ); + vm.etch(LibOrchestratorInvariants.ST0X_ORCHESTRATOR_BEACON, hex"fe"); + } + + /// @notice Mock the pinned instance in the fully landed state: code + /// present, Safe holding admin, deploy key holding nothing, vault-logic + /// lock passing. + function mockLandedInstance() internal { + address instance = LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE; + vm.etch(instance, hex"fe"); + vm.mockCall(instance, abi.encodeCall(IAccessControl.hasRole, (bytes32(0), SAFE)), abi.encode(true)); + vm.mockCall(instance, abi.encodeCall(IAccessControl.hasRole, (bytes32(0), DEPLOY_KEY)), abi.encode(false)); + vm.mockCall(instance, abi.encodeCall(IST0xOrchestratorV1.vaultLogicIsExpected, ()), abi.encode(true)); + } + + /// Closure pre-flight refuses a blank chain, naming the first missing + /// contract (the corporate-actions facet, first in dependency order). + function testClosureRefusesABlankChain() external { + vm.expectRevert( + abi.encodeWithSelector(ClosureNotDeployed.selector, LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_0_1_30) + ); + harness.assertClosureReady(); + } + + /// Closure pre-flight checks every contract: with all but the last + /// etched, the refusal names the orchestrator beacon-set deployer. + function testClosureChecksEveryContract() external { + etchClosure(); + vm.etch(LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30, ""); + vm.expectRevert( + abi.encodeWithSelector( + ClosureNotDeployed.selector, LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30 + ) + ); + harness.assertClosureReady(); + } + + /// Closure pre-flight refuses wrong bytecode at a pin — code presence is + /// not enough, the codehash must be the audited 0.1.30 one. + function testClosureRefusesWrongBytecode() external { + etchClosure(); + vm.etch(LibProdDeployV4.ST0X_ORCHESTRATOR_0_1_30, hex"fe"); + vm.expectRevert( + abi.encodeWithSelector( + ClosureCodehashMismatch.selector, + LibProdDeployV4.ST0X_ORCHESTRATOR_0_1_30, + LibProdDeployV4.ST0X_ORCHESTRATOR_CODEHASH_0_1_30, + keccak256(hex"fe") + ) + ); + harness.assertClosureReady(); + } + + /// Closure pre-flight passes with the frozen 0.1.30 runtime at every pin. + function testClosureAcceptsTheFrozenBytecode() external { + etchClosure(); + harness.assertClosureReady(); + } + + /// A live pinned instance refuses the deploy — re-dispatch cannot mint a + /// duplicate. + function testRefusesWhenInstanceAlreadyDeployed() external { + vm.etch(LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE, hex"fe"); + vm.expectRevert( + abi.encodeWithSelector( + OrchestratorAlreadyDeployed.selector, LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE + ) + ); + harness.assertNoInstanceYet(LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30); + } + + /// A set deployer whose nonce shows an earlier `deploy()` (without code + /// at the pin) is unknown drift, not a deploy target. + function testRefusesUnexpectedSetDeployerNonce() external { + address setDeployer = LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30; + vm.etch(setDeployer, hex"fe"); + vm.setNonce(setDeployer, 3); + vm.expectRevert(abi.encodeWithSelector(UnexpectedSetDeployerNonce.selector, setDeployer, 3)); + harness.assertNoInstanceYet(setDeployer); + } + + /// The fresh-deploy state — no instance code, deployer nonce 2 — passes. + function testAcceptsTheFreshDeployState() external { + address setDeployer = LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30; + vm.etch(setDeployer, hex"fe"); + vm.setNonce(setDeployer, 2); + harness.assertNoInstanceYet(setDeployer); + } + + /// `assertDeployLanded` refuses an instance address other than the pin — + /// a `deploy()` return value the nonce-2 derivation disagrees with. + function testLandedRefusesAnUnpinnedInstance() external { + vm.expectRevert( + abi.encodeWithSelector( + InstanceAddressMismatch.selector, LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE, address(0xBAD) + ) + ); + harness.assertDeployLanded(address(0xBAD), SAFE, DEPLOY_KEY); + } + + /// `assertDeployLanded` refuses an instance whose admin is not the Safe. + function testLandedRefusesWhenSafeLacksAdmin() external { + mockBeaconSet(); + mockLandedInstance(); + address instance = LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE; + vm.mockCall(instance, abi.encodeCall(IAccessControl.hasRole, (bytes32(0), SAFE)), abi.encode(false)); + vm.expectRevert(abi.encodeWithSelector(OrchestratorAdminMissing.selector, instance, SAFE)); + harness.assertDeployLanded(instance, SAFE, DEPLOY_KEY); + } + + /// `assertDeployLanded` refuses an instance still holding admin for the + /// deploy key. + function testLandedRefusesWhenDeployKeyHoldsAdmin() external { + mockBeaconSet(); + mockLandedInstance(); + address instance = LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE; + vm.mockCall(instance, abi.encodeCall(IAccessControl.hasRole, (bytes32(0), DEPLOY_KEY)), abi.encode(true)); + vm.expectRevert(abi.encodeWithSelector(DeployKeyHoldsAdmin.selector, instance, DEPLOY_KEY)); + harness.assertDeployLanded(instance, SAFE, DEPLOY_KEY); + } + + /// The beacon pin is the beacon-set deployer's `CREATE` at account nonce + /// 1 (its constructor's `new UpgradeableBeacon`). Re-derived here so a + /// drifted `BuildPointers` literal fails a test — the beacon analogue of + /// `testAuthoriserV4ClonePin`. + function testOrchestratorBeaconPin() external pure { + assertEq( + LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON, + vm.computeCreateAddress(LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30, 1), + "beacon pin drifted from the deployer nonce-1 CREATE derivation" + ); + } + + /// The instance pin is the beacon-set deployer's `CREATE` at account + /// nonce 2 (the first `deploy()` call's `BeaconProxy`). Re-derived here + /// so a drifted `BuildPointers` literal fails a test. + function testOrchestratorInstancePin() external pure { + assertEq( + LibProdDeployV4.ST0X_ORCHESTRATOR_INSTANCE, + vm.computeCreateAddress(LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_0_1_30, 2), + "instance pin drifted from the deployer nonce-2 CREATE derivation" + ); + } + + /// `assertDeployLanded` passes the fully landed state. + function testLandedAcceptsTheLandedState() external { + mockBeaconSet(); + mockLandedInstance(); + harness.assertDeployLanded(LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE, SAFE, DEPLOY_KEY); + } +} diff --git a/test/script/DeployOrchestratorHarness.sol b/test/script/DeployOrchestratorHarness.sol new file mode 100644 index 00000000..06d98402 --- /dev/null +++ b/test/script/DeployOrchestratorHarness.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2026 S01 Issuer GmbH +pragma solidity =0.8.25; + +import {DeployOrchestrator} from "../../script/20260818-deploy-orchestrator.s.sol"; + +/// @dev Exposes the script's internals so the guard tests can drive them +/// directly. +contract DeployOrchestratorHarness is DeployOrchestrator { + /// @notice The script's `_assertClosureReady()`, externally callable. + function assertClosureReady() external view { + _assertClosureReady(); + } + + /// @notice The script's `_assertNoInstanceYet()`, externally callable. + /// @param setDeployer The beacon-set deployer to inspect. + function assertNoInstanceYet(address setDeployer) external view { + _assertNoInstanceYet(setDeployer); + } +}