diff --git a/.github/workflows/manual-broadcast.yaml b/.github/workflows/manual-broadcast.yaml index 279bdf3d..e4dea0f5 100644 --- a/.github/workflows/manual-broadcast.yaml +++ b/.github/workflows/manual-broadcast.yaml @@ -34,6 +34,7 @@ on: - 20260706-deploy-tokens-ethereum - 20260807-deploy-missing-tokens - 20260729-deploy-governance-timelock + - 20260817-deploy-orchestrator network: description: 'Network to broadcast against (default: base)' required: true @@ -77,9 +78,9 @@ jobs: # network too (per-chain nonces cannot collide across networks), but a # MULTI-CHAIN script broadcasts on every chain regardless of the # selected network, so all its dispatches must share ONE group — list - # each multi-chain script in the ternary below. + # each multi-chain script in the expression below. concurrency: - group: manual-broadcast-${{ inputs.script }}-${{ inputs.script == '20260729-deploy-governance-timelock' && 'all-chains' || inputs.network }} + group: manual-broadcast-${{ inputs.script }}-${{ (inputs.script == '20260729-deploy-governance-timelock' || inputs.script == '20260817-deploy-orchestrator') && 'all-chains' || inputs.network }} cancel-in-progress: false steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -110,7 +111,7 @@ jobs: # broadcast to HyperEVM inside a single run regardless of which # network was dispatched, so they always take the legacy path. LEGACY_ARGS=() - if [[ "${NETWORK}" == "hyperevm" || "${SCRIPT}" == "20260729-deploy-governance-timelock" ]]; then + if [[ "${NETWORK}" == "hyperevm" || "${SCRIPT}" == "20260729-deploy-governance-timelock" || "${SCRIPT}" == "20260817-deploy-orchestrator" ]]; then LEGACY_ARGS+=(--legacy) fi nix develop --command forge script "script/${SCRIPT}.s.sol" \ @@ -189,3 +190,64 @@ jobs: echo "Etherscan: no ${KEY_VAR} configured, skipping ${NET}" fi done + # Same per-chain manifest-driven verification as the timelock step + # above, for the orchestrator singletons. The manifest carries the + # contract path per entry (two contracts land per chain), and both + # contracts have parameterless constructors, so no constructor-args + # guessing (and no RPC) is needed on the Etherscan leg. + - name: Verify orchestrator deployments + if: ${{ inputs.script == '20260817-deploy-orchestrator' }} + env: + # Etherscan-family keys, per chain. Absent or free-tier keys simply + # skip that chain's Etherscan leg; Sourcify still runs. + CI_DEPLOY_BASE_ETHERSCAN_API_KEY: ${{ secrets.EXPLORER_VERIFICATION_KEY || secrets.CI_DEPLOY_BASE_ETHERSCAN_API_KEY || vars.CI_DEPLOY_BASE_ETHERSCAN_API_KEY || '' }} + CI_DEPLOY_ETHEREUM_ETHERSCAN_API_KEY: ${{ secrets.EXPLORER_VERIFICATION_KEY || secrets.CI_DEPLOY_ETHEREUM_ETHERSCAN_API_KEY || vars.CI_DEPLOY_ETHEREUM_ETHERSCAN_API_KEY || '' }} + CI_DEPLOY_HYPEREVM_ETHERSCAN_API_KEY: ${{ secrets.EXPLORER_VERIFICATION_KEY || secrets.CI_DEPLOY_HYPEREVM_ETHERSCAN_API_KEY || vars.CI_DEPLOY_HYPEREVM_ETHERSCAN_API_KEY || '' }} + # foundry resolves EVERY `[etherscan]` entry's env var up front, even + # when `--verifier sourcify` is used, and errors on any that is + # undefined. Declaring the rest (empty when the secret is unset) keeps + # config resolution from failing on a chain this deploy never touches. + CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY: ${{ secrets.EXPLORER_VERIFICATION_KEY || secrets.CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY || vars.CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY || '' }} + CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY: ${{ secrets.EXPLORER_VERIFICATION_KEY || secrets.CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY || vars.CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY || '' }} + CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: ${{ secrets.EXPLORER_VERIFICATION_KEY || secrets.CI_DEPLOY_FLARE_ETHERSCAN_API_KEY || vars.CI_DEPLOY_FLARE_ETHERSCAN_API_KEY || '' }} + CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY: ${{ secrets.EXPLORER_VERIFICATION_KEY || secrets.CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY || vars.CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY || '' }} + run: | + MANIFEST=out/20260817-orchestrator-deployments.json + jq -c '.[]' "${MANIFEST}" | while read -r entry; do + CHAIN=$(echo "${entry}" | jq -r '.chainId') + NET=$(echo "${entry}" | jq -r '.network') + ADDR=$(echo "${entry}" | jq -r '.address') + CONTRACT=$(echo "${entry}" | jq -r '.contract') + + # Sourcify: no API key, and the only verifier that covers all + # three chains including HyperEVM. Matches on bytecode, so it + # needs neither constructor args nor an RPC endpoint. + echo "Sourcify: verifying ${ADDR} (${CONTRACT}) on chain ${CHAIN}" + nix develop --command forge verify-contract \ + --verifier sourcify \ + --chain "${CHAIN}" \ + --watch \ + "${ADDR}" \ + "${CONTRACT}" + + # Etherscan: what actually surfaces on Basescan/Etherscan, since + # they do NOT ingest Sourcify. Needs a paid v2 key for any chain + # beyond mainnet, so run it only where a key is configured rather + # than failing the whole deploy on a missing or free-tier key. + KEY_VAR="CI_DEPLOY_$(echo "${NET}" | tr '[:lower:]' '[:upper:]')_ETHERSCAN_API_KEY" + if [[ -n "${!KEY_VAR:-}" ]]; then + echo "Etherscan: verifying ${ADDR} (${CONTRACT}) on ${NET}" + # Best-effort: a free-tier key is accepted by the config but + # rejected per-chain at request time, and that must not abort + # the loop before the remaining chains reach Sourcify. Sourcify + # above is the leg that gates this step's success. + nix develop --command forge verify-contract \ + --chain "${NET}" \ + --watch \ + "${ADDR}" \ + "${CONTRACT}" \ + || echo "Etherscan: verification failed for ${NET} (paid v2 plan required); Sourcify stands" + else + echo "Etherscan: no ${KEY_VAR} configured, skipping ${NET}" + fi + done diff --git a/CHANGELOG.md b/CHANGELOG.md index f445d26b..d1c6ab12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,28 @@ ### Deploy scripts +- **New broadcast script `20260817-deploy-orchestrator` deploys the orchestrator + singletons on every production chain.** `ST0xOrchestrator` and + `ST0xOrchestratorBeaconSetDeployer` postdate the audited 0.1.1 set that + `script/DeployProdV4_0_1_1.sol` ships, so they get their own + `manual-broadcast.yaml` entry rather than a suite in the 0.1.1 bootstrap + workflow. One dispatch covers Base, Ethereum and HyperEVM (multi-chain like + `20260729-deploy-governance-timelock`: shared `all-chains` concurrency group, + always `--legacy`), Zoltu-deploying the checked-out ref's CANDIDATE creation + bytecode — implementation first, then the beacon-set deployer, whose + constructor bakes an `UpgradeableBeacon` over the implementation pin. + Idempotent per chain: an already-deployed pin is codehash-asserted and + skipped, and a pin carrying any other code reverts `DeployedCodehashMismatch` + rather than deploying over a ref/deployment mismatch. Post-state asserts both + candidate codehashes, the beacon's implementation wiring, and that the + beacon's owner is the pinned initial owner or the chain's governance timelock. + The orchestrator proxy instance (`deploy(owner)`) is deliberately NOT created + — the vault-logic version lock gates `initialize` on the candidate vault + + receipt beacons being live, so instance creation is sequenced with the + vault-logic rollout. The script writes + `out/20260817-orchestrator-deployments.json`, and a manifest-driven workflow + step verifies both contracts per chain (Sourcify, plus Etherscan where a key + is configured). - **The per-chain "deploy missing tokens" scripts are merged into one.** `20260722-deploy-missing-tokens-ethereum` and `-hyperevm` were byte-identical apart from the chain each hardcoded; both are replaced by @@ -93,11 +115,11 @@ reading as "copy everything". - **The canonical config table is allowed to run ahead of Base.** A row is authored when a ticker is chosen and Base is pinned when it is deployed, so - the config table leads in that window; only the rows Base carries are read, - so the excess is inert. The genuine error is a config table SHORTER than - Base — a deployed Base row with no name/symbol to deploy under — which - reverts `TokenTableTooShort(configsLength, baseLength)`. Row-for-row key - drift between the two tables still reverts `TokenTableMisaligned`. + the config table leads in that window; only the rows Base carries are read, so + the excess is inert. The genuine error is a config table SHORTER than Base — a + deployed Base row with no name/symbol to deploy under — which reverts + `TokenTableTooShort(configsLength, baseLength)`. Row-for-row key drift between + the two tables still reverts `TokenTableMisaligned`. - **Three checks the gap-fill scripts had dropped are back, matching `20260706-deploy-tokens-ethereum`.** Each deployed vault is now read back before the loop moves on — `AuthoriserNotWired` if it is not routed to the diff --git a/script/20260817-deploy-orchestrator.s.sol b/script/20260817-deploy-orchestrator.s.sol new file mode 100644 index 00000000..aa3607bb --- /dev/null +++ b/script/20260817-deploy-orchestrator.s.sol @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +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 {UpgradeableBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/UpgradeableBeacon.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; + +import {ST0xOrchestratorBeaconSetDeployer} from "../src/concrete/deploy/ST0xOrchestratorBeaconSetDeployer.sol"; +import {LibProdDeployV4} from "../src/generated/LibProdDeployV4.sol"; +import {LibStoxDeployNetworks} from "../src/lib/LibStoxDeployNetworks.sol"; +import {LibTimelockInvariants} from "../src/lib/LibTimelockInvariants.sol"; + +/// @notice The canonical Zoltu deterministic-deployment factory is not +/// deployed on this network. Without it the pinned address is unreachable. +/// @param factory The pinned factory address with no code. +error ZoltuFactoryNotDeployed(address factory); + +/// @notice The Zoltu factory's runtime codehash does not match the +/// rain-deploy pin. The contract at the pinned address is not the canonical +/// factory, so nothing may be deployed through it. +/// @param factory The factory address inspected. +/// @param expected The pinned factory codehash. +/// @param actual The codehash observed on-chain. +error ZoltuFactoryCodehashMismatch(address factory, bytes32 expected, bytes32 actual); + +/// @notice An orchestrator pin already carries code, but its runtime codehash +/// does not match the candidate pin this script was compiled with. Either the +/// dispatch checked out a ref whose candidate snapshot differs from the one +/// that deployed the chain, or something else entirely lives at the pin. +/// Never a state to deploy over — the Zoltu address is a pure function of the +/// creation code, so a codehash mismatch at the pinned address is a +/// contradiction to surface, not to route around. +/// @param target The pinned address inspected. +/// @param expected The candidate codehash pin. +/// @param actual The codehash observed on-chain. +error DeployedCodehashMismatch(address target, bytes32 expected, bytes32 actual); + +/// @notice The address the Zoltu factory returned does not match the +/// candidate pin. Should be unreachable behind the factory codehash guard — +/// tripping means the generated pointer snapshot has drifted from the +/// creation bytecode actually shipped. +/// @param expected The pinned candidate address. +/// @param actual The address the factory deployed to. +error DeployedAddressMismatch(address expected, address actual); + +/// @notice The beacon the set deployer constructed does not point at the +/// pinned orchestrator implementation. +/// @param beacon The beacon inspected. +/// @param expected The pinned orchestrator implementation. +/// @param actual The implementation the beacon points at. +error BeaconImplementationMismatch(address beacon, address expected, address actual); + +/// @notice The orchestrator beacon's owner is neither the pinned initial +/// owner multisig nor the chain's governance timelock. Ownership has moved +/// somewhere this script does not recognise. +/// @param beacon The beacon inspected. +/// @param owner The owner observed on-chain. +error UnexpectedBeaconOwner(address beacon, address owner); + +/// @title DeployOrchestrator +/// @notice **PENDING.** Broadcast script that deploys the two orchestrator +/// Zoltu singletons on every production chain in a single dispatch: +/// +/// 1. `ST0xOrchestrator` — the orchestrator implementation +/// (`_disableInitializers` in the constructor; only ever used behind a +/// beacon proxy). +/// 2. `ST0xOrchestratorBeaconSetDeployer` — whose constructor creates the +/// shared `UpgradeableBeacon` pointing at the implementation, owned by +/// `LibProdDeployV4.BEACON_INITIAL_OWNER`. Deployed second: the beacon +/// constructor refuses a code-less implementation. +/// +/// Both ship the CANDIDATE creation bytecode stored in the checked-out +/// commit's generated pointer snapshot (`LibProdDeployV4.*_CANDIDATE`) — the +/// orchestrator has no frozen numbered snapshot yet (it postdates the audited +/// 0.1.1 set `script/DeployProdV4_0_1_1.sol` ships), and +/// `testCandidateSelfConsistent` pins the candidate snapshot to current +/// source, so a dispatch deploys exactly what the dispatched ref compiles to. +/// Dispatch from the intended release ref, and re-dispatch from the SAME ref: +/// pre-flight asserts any already-deployed pin against this compile's +/// candidate codehash, so a later ref whose candidate has drifted refuses +/// rather than reporting a stale deployment as current. +/// +/// The Zoltu deploy is deterministic and idempotent per chain: a chain that +/// already carries a contract is codehash-asserted and skipped, never +/// redeployed, so a re-dispatch is a safe no-op and a partial rollout +/// finishes in one run. One dispatch covers every chain in `networks()` +/// (Base, Ethereum, HyperEVM) regardless of the workflow's selected network. +/// +/// This script does NOT create the orchestrator proxy instance +/// (`ST0xOrchestratorBeaconSetDeployer.deploy(owner)`): the orchestrator's +/// vault-logic version lock makes `initialize` refuse until the production +/// vault + receipt beacons point at the `LibProdDeployCurrent` +/// implementations this source was built against, so instance creation is a +/// separate, later operational step sequenced with the vault-logic rollout. +/// +/// Dispatched via `.github/workflows/manual-broadcast.yaml` with +/// `script = 20260817-deploy-orchestrator`, broadcasting as the CI deploy +/// key (`secrets.PRIVATE_KEY`). The deploy key is never granted anything: +/// both contracts are constructor-configured, and the beacon's owner is +/// baked in as the owner multisig. +contract DeployOrchestrator is Script { + /// @notice Where `run()` writes the per-chain deployed addresses and + /// contract paths, so a verification step can target every chain without + /// re-deriving them — including on a re-dispatch, where every chain is + /// skipped and no broadcast artifact exists to read them from. + string internal constant DEPLOYMENTS_PATH = "out/20260817-orchestrator-deployments.json"; + + /// @notice Fully qualified source path of the orchestrator + /// implementation, as the verification step passes to + /// `forge verify-contract`. + string internal constant ORCHESTRATOR_CONTRACT_PATH = "src/concrete/ST0xOrchestrator.sol:ST0xOrchestrator"; + + /// @notice Fully qualified source path of the beacon-set deployer. + string internal constant SET_DEPLOYER_CONTRACT_PATH = + "src/concrete/deploy/ST0xOrchestratorBeaconSetDeployer.sol:ST0xOrchestratorBeaconSetDeployer"; + + /// @notice Every chain the orchestrator singletons are deployed to, in a + /// fixed order. One dispatch covers all of them: the deploy is + /// deterministic and idempotent per chain, so a chain that already + /// carries the contracts is verified and skipped rather than redeployed. + /// @return nets The network names, matching `foundry.toml`'s + /// `[rpc_endpoints]` aliases. + function networks() internal pure returns (string[] memory nets) { + nets = new string[](3); + nets[0] = LibRainDeploy.BASE; + nets[1] = LibStoxDeployNetworks.ETHEREUM; + nets[2] = LibStoxDeployNetworks.HYPEREVM; + } + + /// @notice Deploy the orchestrator singletons across every chain in + /// `networks()` in a single dispatch, skipping any chain that already + /// carries them, then write the verification manifest. + function run() external { + string[] memory nets = networks(); + string memory manifest = ""; + for (uint256 i = 0; i < nets.length; i++) { + // The fork id is unused; bind it so the unused-return lint stays + // satisfied, matching `LibRainDeploy.deployToNetworks`. + uint256 forkId = vm.createSelectFork(nets[i]); + (forkId); + console2.log("==== NETWORK:", nets[i]); + (address orchestrator, address setDeployer) = _deployOnActiveChain(); + manifest = string.concat( + manifest, + i == 0 ? "" : ",", + manifestEntry(nets[i], orchestrator, ORCHESTRATOR_CONTRACT_PATH), + ",", + manifestEntry(nets[i], setDeployer, SET_DEPLOYER_CONTRACT_PATH) + ); + } + + vm.writeFile(DEPLOYMENTS_PATH, string.concat("[", manifest, "]")); + console2.log("Deployments manifest:", DEPLOYMENTS_PATH); + } + + /// @notice One manifest entry for the verification step: which contract + /// landed where, on which chain. + /// @param network The active chain's `foundry.toml` rpc alias. + /// @param deployed The deployed (or skipped-as-already-deployed) address. + /// @param contractPath The fully qualified source path to verify against. + /// @return entry The JSON object, unseparated. + function manifestEntry(string memory network, address deployed, string memory contractPath) + internal + view + returns (string memory entry) + { + entry = string.concat( + "{\"network\":\"", + network, + "\",\"chainId\":", + vm.toString(block.chainid), + ",\"address\":\"", + vm.toString(deployed), + "\",\"contract\":\"", + contractPath, + "\"}" + ); + } + + /// @notice Deploy (or codehash-assert and skip) both singletons on + /// whichever chain is currently selected. Pre-flight covers the chain + /// allowlist, the state of anything already at the pins, and the Zoltu + /// factory; post-state proves both pins carry the candidate runtime and + /// the beacon is wired to the pinned implementation under a recognised + /// owner. + /// @return orchestrator The orchestrator implementation's pinned address. + /// @return setDeployer The beacon-set deployer's pinned address. + function _deployOnActiveChain() internal returns (address orchestrator, address setDeployer) { + // Chain allowlist: resolving the chain's governance timelock reverts + // `UnsupportedChainForGovernanceTimelock` on any chain outside the + // production set, and the resolved address feeds the post-state + // beacon-owner check (ownership may legitimately have been migrated + // from the initial owner multisig to the timelock). + address timelock = LibTimelockInvariants.timelockForChainId(block.chainid); + + orchestrator = LibProdDeployV4.ST0X_ORCHESTRATOR_CANDIDATE; + setDeployer = LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_CANDIDATE; + + bool needOrchestrator = _needsDeploy(orchestrator, LibProdDeployV4.ST0X_ORCHESTRATOR_CODEHASH_CANDIDATE); + bool needSetDeployer = + _needsDeploy(setDeployer, LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_CODEHASH_CANDIDATE); + + if (needOrchestrator || needSetDeployer) { + // The canonical Zoltu factory is deployed with the pinned + // codehash. A missing or replaced factory would either revert or + // deploy through attacker-controlled machinery. + address factory = LibRainDeploy.ZOLTU_FACTORY; + if (factory.code.length == 0) revert ZoltuFactoryNotDeployed(factory); + bytes32 factoryCodehash = factory.codehash; + if (factoryCodehash != LibRainDeploy.ZOLTU_FACTORY_CODEHASH) { + revert ZoltuFactoryCodehashMismatch(factory, LibRainDeploy.ZOLTU_FACTORY_CODEHASH, factoryCodehash); + } + + vm.startBroadcast(); + + // Implementation first: the set deployer's constructor creates an + // `UpgradeableBeacon` over it, and OZ's beacon constructor + // refuses an implementation with no code. + if (needOrchestrator) { + address deployed = LibRainDeploy.deployZoltu(LibProdDeployV4.ST0X_ORCHESTRATOR_CREATION_CODE_CANDIDATE); + if (deployed != orchestrator) revert DeployedAddressMismatch(orchestrator, deployed); + } else { + console2.log(" - Orchestrator impl already deployed, skipping:", vm.toString(orchestrator)); + } + + if (needSetDeployer) { + address deployed = LibRainDeploy.deployZoltu( + LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_CREATION_CODE_CANDIDATE + ); + if (deployed != setDeployer) revert DeployedAddressMismatch(setDeployer, deployed); + } else { + console2.log(" - Beacon-set deployer already deployed, skipping:", vm.toString(setDeployer)); + } + + vm.stopBroadcast(); + } else { + console2.log(" - Both singletons already deployed, skipping chain"); + } + + _assertPostState(orchestrator, setDeployer, timelock); + + console2.log("==== ORCHESTRATOR SINGLETONS ===="); + console2.log("Chain:", block.chainid); + console2.log("Orchestrator impl:", vm.toString(orchestrator)); + console2.log("Beacon-set deployer:", vm.toString(setDeployer)); + console2.log( + "Beacon:", vm.toString(address(ST0xOrchestratorBeaconSetDeployer(setDeployer).iOrchestratorBeacon())) + ); + console2.log("================================="); + } + + /// @notice Whether `target` still needs its Zoltu deploy. No code means + /// yes; code with the pinned candidate codehash means no (idempotent + /// skip); code with any other codehash is a contradiction and reverts. + /// @param target The pinned candidate address. + /// @param expectedCodehash The pinned candidate codehash. + /// @return needsDeploy True when nothing is deployed at `target` yet. + function _needsDeploy(address target, bytes32 expectedCodehash) internal view returns (bool needsDeploy) { + if (target.code.length == 0) { + return true; + } + bytes32 actual = target.codehash; + if (actual != expectedCodehash) { + revert DeployedCodehashMismatch(target, expectedCodehash, actual); + } + return false; + } + + /// @notice Post-state assertion invoked after the deploy-or-skip pass: + /// both pins carry the candidate runtime, the set deployer's beacon + /// points at the pinned implementation, and the beacon's owner is either + /// the pinned initial owner multisig or the chain's governance timelock + /// (where ownership migrates post-deploy). Split from the deploy step so + /// tests can drive it against perturbed state. + /// @param orchestrator The orchestrator implementation's pinned address. + /// @param setDeployer The beacon-set deployer's pinned address. + /// @param timelock The active chain's governance timelock. + function _assertPostState(address orchestrator, address setDeployer, address timelock) internal view { + bytes32 orchestratorCodehash = orchestrator.codehash; + if (orchestratorCodehash != LibProdDeployV4.ST0X_ORCHESTRATOR_CODEHASH_CANDIDATE) { + revert DeployedCodehashMismatch( + orchestrator, LibProdDeployV4.ST0X_ORCHESTRATOR_CODEHASH_CANDIDATE, orchestratorCodehash + ); + } + bytes32 setDeployerCodehash = setDeployer.codehash; + if (setDeployerCodehash != LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_CODEHASH_CANDIDATE) { + revert DeployedCodehashMismatch( + setDeployer, + LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_CODEHASH_CANDIDATE, + setDeployerCodehash + ); + } + + UpgradeableBeacon beacon = + UpgradeableBeacon(address(ST0xOrchestratorBeaconSetDeployer(setDeployer).iOrchestratorBeacon())); + address implementation = beacon.implementation(); + if (implementation != orchestrator) { + revert BeaconImplementationMismatch(address(beacon), orchestrator, implementation); + } + address owner = beacon.owner(); + if (owner != LibProdDeployV4.BEACON_INITIAL_OWNER && owner != timelock) { + revert UnexpectedBeaconOwner(address(beacon), owner); + } + } +} diff --git a/test/script/20260817-deploy-orchestrator.t.sol b/test/script/20260817-deploy-orchestrator.t.sol new file mode 100644 index 00000000..b1a3e482 --- /dev/null +++ b/test/script/20260817-deploy-orchestrator.t.sol @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.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 { + DeployedCodehashMismatch, + UnexpectedBeaconOwner, + ZoltuFactoryCodehashMismatch, + ZoltuFactoryNotDeployed +} from "../../script/20260817-deploy-orchestrator.s.sol"; +import {DeployOrchestratorHarness} from "./DeployOrchestratorHarness.sol"; +import {ST0xOrchestratorBeaconSetDeployer} from "../../src/concrete/deploy/ST0xOrchestratorBeaconSetDeployer.sol"; +import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; +import {LibStoxDeployNetworks} from "../../src/lib/LibStoxDeployNetworks.sol"; +import {LibTimelockInvariants, UnsupportedChainForGovernanceTimelock} from "../../src/lib/LibTimelockInvariants.sol"; + +/// @title DeployOrchestratorTest +/// @notice Live-fork coverage for the orchestrator-singletons deploy +/// broadcast. The deploy is deterministic (Zoltu CREATE2 over the candidate +/// creation bytecode) and idempotent per chain, so each chain is driven on +/// its own fork and asserted with the same checks the production broadcast +/// makes. +/// @dev Per-chain rather than through `run()`: `run()` creates its own fork +/// per network, and deployments written inside those forks are not visible to +/// a fork a test creates afterwards. The tests therefore select a fork and +/// drive the per-chain step directly; `run()`'s own coverage is that it +/// iterates exactly `networks()`. +contract DeployOrchestratorTest is Test { + /// @notice Deploy on the selected fork and assert both singletons landed + /// at their candidate pins with the pinned runtimes, wired through the + /// beacon. + /// @return orchestrator The orchestrator implementation's pinned address. + /// @return setDeployer The beacon-set deployer's pinned address. + function deployOnSelectedFork() internal returns (address orchestrator, address setDeployer) { + (orchestrator, setDeployer) = new DeployOrchestratorHarness().callDeployOnActiveChain(); + assertEq(orchestrator, LibProdDeployV4.ST0X_ORCHESTRATOR_CANDIDATE, "impl landed off-pin"); + assertEq( + setDeployer, LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_CANDIDATE, "deployer landed off-pin" + ); + assertEq(orchestrator.codehash, LibProdDeployV4.ST0X_ORCHESTRATOR_CODEHASH_CANDIDATE, "impl codehash off-pin"); + assertEq( + setDeployer.codehash, + LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_CODEHASH_CANDIDATE, + "deployer codehash off-pin" + ); + UpgradeableBeacon beacon = + UpgradeableBeacon(address(ST0xOrchestratorBeaconSetDeployer(setDeployer).iOrchestratorBeacon())); + assertEq(beacon.implementation(), orchestrator, "beacon not pointing at the impl pin"); + assertEq(beacon.owner(), LibProdDeployV4.BEACON_INITIAL_OWNER, "beacon owner is not the initial owner"); + } + + /// @notice `run()` covers every chain that carries production tokens, so + /// one dispatch is the whole rollout. Pins the list rather than the loop + /// body, which the per-chain tests below exercise. + function testNetworksCoversEveryProductionChain() external { + string[] memory nets = new DeployOrchestratorHarness().callNetworks(); + assertEq(nets.length, 3); + assertEq(nets[0], LibRainDeploy.BASE); + assertEq(nets[1], LibStoxDeployNetworks.ETHEREUM); + assertEq(nets[2], LibStoxDeployNetworks.HYPEREVM); + } + + /// @notice A chain outside the allowlist refuses at pre-flight — the + /// chain's governance timelock is resolved before anything else, so a + /// chain with no timelock pin cannot reach the broadcast machinery at + /// all. Driven on the local EVM (chainid 31337), which is exactly such a + /// chain. + function testDeployRefusesUnsupportedChain() external { + DeployOrchestratorHarness harness = new DeployOrchestratorHarness(); + vm.expectRevert(abi.encodeWithSelector(UnsupportedChainForGovernanceTimelock.selector, uint256(31337))); + harness.callDeployOnActiveChain(); + } + + /// @notice The deploy lands both singletons at their candidate pins on + /// Base. + function testDeploysOnBaseFork() external { + vm.createSelectFork(LibRainDeploy.BASE); + deployOnSelectedFork(); + } + + /// @notice The deploy lands both singletons at their candidate pins on + /// Ethereum. + function testDeploysOnEthereumFork() external { + vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); + deployOnSelectedFork(); + } + + /// @notice The deploy lands both singletons at their candidate pins on + /// HyperEVM. + function testDeploysOnHyperevmFork() external { + vm.createSelectFork(LibStoxDeployNetworks.HYPEREVM); + deployOnSelectedFork(); + } + + /// @notice A chain that already carries both singletons is + /// codehash-asserted and skipped rather than redeployed or reverted — + /// the property that makes a re-dispatch a safe no-op and lets a partial + /// rollout finish in one run. + function testSkipsAChainThatIsAlreadyDeployed() external { + vm.createSelectFork(LibRainDeploy.BASE); + (address firstOrchestrator, address firstSetDeployer) = deployOnSelectedFork(); + + // Second pass over the same chain: no revert, same addresses. + (address secondOrchestrator, address secondSetDeployer) = deployOnSelectedFork(); + assertEq(secondOrchestrator, firstOrchestrator, "a re-run must not move the impl"); + assertEq(secondSetDeployer, firstSetDeployer, "a re-run must not move the deployer"); + } + + /// @notice A partial rollout — implementation landed, set deployer not — + /// finishes on the next pass: the deployed pin is codehash-asserted and + /// skipped, the missing one is deployed. The partial state is simulated + /// by etching the candidate runtime at the impl pin (as if a prior + /// dispatch landed it) rather than by stripping a deployed contract, + /// whose leftover nonce and already-created beacon would make a CREATE2 + /// re-deploy collide in a way no real partial rollout does. + function testFinishesAPartialRollout() external { + vm.createSelectFork(LibRainDeploy.BASE); + vm.etch(LibProdDeployV4.ST0X_ORCHESTRATOR_CANDIDATE, LibProdDeployV4.ST0X_ORCHESTRATOR_RUNTIME_CODE_CANDIDATE); + + (, address setDeployer) = deployOnSelectedFork(); + assertGt(setDeployer.code.length, 0, "set deployer must be deployed on the finishing pass"); + } + + /// @notice Pre-flight refuses a pin that carries code with a codehash + /// other than the candidate pin — a ref/deployment mismatch is a + /// contradiction to surface, never a state to deploy over. + function testRejectsCodehashDriftAtImplPin() external { + vm.createSelectFork(LibRainDeploy.BASE); + address impl = LibProdDeployV4.ST0X_ORCHESTRATOR_CANDIDATE; + bytes memory bogusCode = hex"60016000526001601ff3"; + vm.etch(impl, bogusCode); + DeployOrchestratorHarness harness = new DeployOrchestratorHarness(); + vm.expectRevert( + abi.encodeWithSelector( + DeployedCodehashMismatch.selector, + impl, + LibProdDeployV4.ST0X_ORCHESTRATOR_CODEHASH_CANDIDATE, + keccak256(bogusCode) + ) + ); + harness.callDeployOnActiveChain(); + } + + /// @notice Pre-flight refuses codehash drift at the set deployer's pin on + /// the same grounds. + function testRejectsCodehashDriftAtSetDeployerPin() external { + vm.createSelectFork(LibRainDeploy.BASE); + address setDeployer = LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_CANDIDATE; + bytes memory bogusCode = hex"60016000526001601ff3"; + vm.etch(setDeployer, bogusCode); + DeployOrchestratorHarness harness = new DeployOrchestratorHarness(); + vm.expectRevert( + abi.encodeWithSelector( + DeployedCodehashMismatch.selector, + setDeployer, + LibProdDeployV4.ST0X_ORCHESTRATOR_BEACON_SET_DEPLOYER_CODEHASH_CANDIDATE, + keccak256(bogusCode) + ) + ); + harness.callDeployOnActiveChain(); + } + + /// @notice Pre-flight refuses a missing Zoltu factory — nothing may be + /// deployed without the canonical machinery. + function testRejectsMissingZoltuFactory() external { + vm.createSelectFork(LibRainDeploy.BASE); + vm.etch(LibRainDeploy.ZOLTU_FACTORY, ""); + DeployOrchestratorHarness harness = new DeployOrchestratorHarness(); + vm.expectRevert(abi.encodeWithSelector(ZoltuFactoryNotDeployed.selector, LibRainDeploy.ZOLTU_FACTORY)); + harness.callDeployOnActiveChain(); + } + + /// @notice Pre-flight refuses a Zoltu factory whose codehash drifts from + /// the rain-deploy pin — whatever lives there is not the canonical + /// factory. + function testRejectsZoltuFactoryCodehashDrift() external { + vm.createSelectFork(LibRainDeploy.BASE); + bytes memory bogusCode = hex"60016000526001601ff3"; + vm.etch(LibRainDeploy.ZOLTU_FACTORY, bogusCode); + DeployOrchestratorHarness harness = new DeployOrchestratorHarness(); + vm.expectRevert( + abi.encodeWithSelector( + ZoltuFactoryCodehashMismatch.selector, + LibRainDeploy.ZOLTU_FACTORY, + LibRainDeploy.ZOLTU_FACTORY_CODEHASH, + keccak256(bogusCode) + ) + ); + harness.callDeployOnActiveChain(); + } + + /// @notice The post-state owner check accepts the two recognised owners + /// — the pinned initial owner (fresh deploy) and the chain's governance + /// timelock (post-migration) — and refuses anything else. + function testAssertPostStateOwnerAllowlist() external { + vm.createSelectFork(LibRainDeploy.BASE); + (address orchestrator, address setDeployer) = deployOnSelectedFork(); + DeployOrchestratorHarness harness = new DeployOrchestratorHarness(); + address timelock = LibTimelockInvariants.timelockForChainId(block.chainid); + UpgradeableBeacon beacon = + UpgradeableBeacon(address(ST0xOrchestratorBeaconSetDeployer(setDeployer).iOrchestratorBeacon())); + + // Fresh deploy: initial owner passes. + harness.callAssertPostState(orchestrator, setDeployer, timelock); + + // Ownership migrated to the chain's governance timelock: passes. + vm.prank(LibProdDeployV4.BEACON_INITIAL_OWNER); + beacon.transferOwnership(timelock); + harness.callAssertPostState(orchestrator, setDeployer, timelock); + + // Ownership moved anywhere else: refused. + address stranger = makeAddr("stranger"); + vm.prank(timelock); + beacon.transferOwnership(stranger); + vm.expectRevert(abi.encodeWithSelector(UnexpectedBeaconOwner.selector, address(beacon), stranger)); + harness.callAssertPostState(orchestrator, setDeployer, timelock); + } +} diff --git a/test/script/DeployOrchestratorHarness.sol b/test/script/DeployOrchestratorHarness.sol new file mode 100644 index 00000000..b9b9ccdb --- /dev/null +++ b/test/script/DeployOrchestratorHarness.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {DeployOrchestrator} from "../../script/20260817-deploy-orchestrator.s.sol"; + +/// @title DeployOrchestratorHarness +/// @notice Subclass of the orchestrator deploy script that exposes its +/// `internal` steps as `external` so tests can drive them and +/// `vm.expectRevert` can intercept the typed errors they raise. Mirrors the +/// `DeployGovernanceTimelockHarness` pattern — `run()` creates its own fork +/// per network and state written inside those forks is not visible to a fork +/// the test creates afterwards, so per-chain tests drive +/// `callDeployOnActiveChain` directly on their own fork. +contract DeployOrchestratorHarness is DeployOrchestrator { + function callDeployOnActiveChain() external returns (address orchestrator, address setDeployer) { + return _deployOnActiveChain(); + } + + /// @notice The network list `run()` iterates. + function callNetworks() external pure returns (string[] memory) { + return networks(); + } + + /// @notice Exposes the post-state assertion so tests can drive it + /// against perturbed state (e.g. a beacon whose ownership moved to an + /// unrecognised address). + function callAssertPostState(address orchestrator, address setDeployer, address timelock) external view { + _assertPostState(orchestrator, setDeployer, timelock); + } +}