Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
70844ae
feat(script): 20260729-deploy-governance-timelock broadcast
Jul 29, 2026
7f20e9d
fix(test): one contract per file - extract DeployGovernanceTimelockHa…
hardyjosh Aug 3, 2026
1d907ad
test(script): HyperEVM fork coverage for the timelock deploy
Aug 10, 2026
59490f4
test(script): soft-skip the HyperEVM deploy fork when no RPC is confi…
Aug 10, 2026
60341d2
feat(script): deploy the timelock to every chain in one dispatch
Aug 10, 2026
310a0e1
test(script): per-chain deploy coverage for the multi-chain dispatch
Aug 10, 2026
af84d22
ci: verify broadcast deploys, and add HyperEVM's explorer
Aug 10, 2026
6a2b610
ci: verify each chain from the deploy's own manifest
Aug 10, 2026
fea9706
ci: give the verify step RPC endpoints for constructor-arg recovery
Aug 10, 2026
92ac207
ci: verify via Sourcify instead of Etherscan
Aug 10, 2026
208175f
ci: verify on Sourcify and Etherscan, skipping Etherscan without a key
Aug 10, 2026
30736a9
ci: declare every etherscan key env so sourcify verification resolves…
Aug 10, 2026
0cd0c56
ci: make the Etherscan verify leg best-effort so Sourcify covers ever…
Aug 10, 2026
263e26a
ci: resolve explorer keys the way rainix does, via EXPLORER_VERIFICAT…
Aug 10, 2026
3accbdb
Merge remote-tracking branch 'origin/main' into fix-285
thedavidmeister Aug 11, 2026
b5313e5
fix(invariants): zero is a checked case, never a skipped one
thedavidmeister Aug 11, 2026
666c8a8
fix(ci+test): serialize multi-chain dispatches; cover unsupported-cha…
thedavidmeister Aug 11, 2026
315e94d
fix(script): a zero timelock pin refuses at pre-flight
thedavidmeister Aug 11, 2026
6fa57b7
docs(lib): pins are written with the chain arm, not hydrated post-deploy
thedavidmeister Aug 11, 2026
50c4400
test(lib): zero actual rejected under a zero PRE side too
thedavidmeister Aug 11, 2026
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
75 changes: 73 additions & 2 deletions .github/workflows/manual-broadcast.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ on:
- 20260619-deploy-v4-authoriser-clone
- 20260706-deploy-tokens-ethereum
- 20260807-deploy-missing-tokens
- 20260729-deploy-governance-timelock
Comment thread
coderabbitai[bot] marked this conversation as resolved.
network:
description: 'Network to broadcast against (default: base)'
required: true
Expand Down Expand Up @@ -101,9 +102,11 @@ jobs:
PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
run: |
# HyperEVM's RPC rejects forge's EIP-1559 fee-history estimation;
# type-0 transactions work on every network.
# type-0 transactions work on every network. Multi-chain scripts
# 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" ]]; then
if [[ "${NETWORK}" == "hyperevm" || "${SCRIPT}" == "20260729-deploy-governance-timelock" ]]; then
LEGACY_ARGS+=(--legacy)
fi
nix develop --command forge script "script/${SCRIPT}.s.sol" \
Expand All @@ -114,3 +117,71 @@ jobs:
--slow \
--broadcast \
--private-key "${PRIVATE_KEY}"
# `forge script --verify` cannot serve a multi-chain broadcast: it
# resolves one explorer from `--rpc-url` and then fails to locate the
# contracts that landed on the other chains. Verifying from the script's
# own per-chain manifest instead targets each explorer correctly, and
# keeps working on a re-dispatch where every chain is skipped and no
# broadcast artifact is produced.
- name: Verify deployments
if: ${{ inputs.script == '20260729-deploy-governance-timelock' }}
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 || '' }}
# `--guess-constructor-args` reads the creation transaction over RPC.
BASE_RPC_URL: ${{ secrets.RPC_URL_BASE_FORK }}
ETHEREUM_RPC_URL: ${{ secrets.RPC_URL_ETHEREUM_FORK }}
HYPEREVM_RPC_URL: ${{ secrets.RPC_URL_HYPEREVM_FORK }}
run: |
MANIFEST=out/20260729-governance-timelock-deployments.json
CONTRACT=dependencies/@openzeppelin-contracts-5.6.1/governance/TimelockController.sol:TimelockController
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')

# 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} 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} 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}" \
--rpc-url "${NET}" \
--watch \
--guess-constructor-args \
"${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
5 changes: 5 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,9 @@ base_sepolia = { key = "${CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY}" }
# verification fails with "unknown alias `ethereum`".
ethereum = { key = "${CI_DEPLOY_ETHEREUM_ETHERSCAN_API_KEY}", chain = 1 }
flare = { key = "${CI_DEPLOY_FLARE_ETHERSCAN_API_KEY}" }
# HyperEVM has no dedicated *scan deployment; it is served by Etherscan's
# multichain (v2) API, so the api url pins the chainid explicitly. `hyperevm`
# is not a built-in foundry chain alias either, so the chain id is pinned for
# the same reason `ethereum` is above.
hyperevm = { key = "${CI_DEPLOY_HYPEREVM_ETHERSCAN_API_KEY}", chain = 999, url = "https://api.etherscan.io/v2/api?chainid=999" }
polygon = { key = "${CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY}" }
245 changes: 245 additions & 0 deletions script/20260729-deploy-governance-timelock.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
// 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 {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol";
import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol";

import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol";
import {LibStoxDeployNetworks} from "../src/lib/LibStoxDeployNetworks.sol";
import {LibTimelockInvariants} from "../src/lib/LibTimelockInvariants.sol";

/// @notice A chain's hydrated governance-timelock pin does not equal the
/// address its own Safe derives. Either the pin records a different chain's
/// timelock or the Safe behind it has moved — deploying or asserting against
/// it would target the wrong contract.
/// @param chainId The chain whose pin disagrees with the derivation.
/// @param pinned The hydrated pin.
/// @param derived The address derived from that chain's Safe.
error TimelockPinMismatch(uint256 chainId, address pinned, address derived);

/// @notice The canonical Zoltu deterministic-deployment factory is not
/// deployed on this network. Without it the derived 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 The address the Zoltu factory returned does not match the
/// in-source CREATE2 derivation. Either the factory is not the canonical
/// one (should be unreachable behind the codehash pin) or the derivation
/// has drifted from the factory's semantics.
/// @param expected The derived timelock address.
/// @param actual The address the factory deployed to.
error TimelockAddressMismatch(address expected, address actual);

/// @notice The broadcasting deploy key holds a role on the freshly-deployed
/// timelock. The constructor was given `admin = address(0)` precisely so the
/// deployer is never granted anything; a role here means the deploy did not
/// produce the pinned configuration.
/// @param role The role the deployer unexpectedly holds.
/// @param deployer The broadcasting deploy key.
error DeployerHoldsTimelockRole(bytes32 role, address deployer);

/// @title DeployGovernanceTimelock
/// @notice **PENDING.** Broadcast script that deploys the ST0x governance
/// timelock — an unmodified, pre-audited OZ `TimelockController` — on the
/// active chain via the Zoltu deterministic factory, configured entirely by
/// its constructor:
///
/// - `minDelay` = `LibTimelockInvariants.TIMELOCK_MIN_DELAY` (48 hours),
/// - proposers = `[the chain's token-owner Safe]` (which the OZ
/// constructor also makes canceller),
/// - executors = `[the chain's token-owner Safe]`,
/// - `admin` = `address(0)` — the timelock self-administers from birth.
///
/// Because `admin` is zero the CI deploy key is NEVER granted any role on
/// the timelock: there is no configuration window and nothing to revoke —
/// the deploy-then-renounce ceremony the V4 authoriser clone needed
/// collapses into a single constructor-configured deploy. The post-state
/// assertion still proves the deployer holds nothing, closing the gap
/// structurally AND observably.
///
/// Deploying the timelock grants it no power: it becomes the governance
/// admin only when the Safe executes the separate
/// `20260729-migrate-governance-to-timelock` bundle (vault ownership +
/// authoriser `_ADMIN` roles), which is a Safe-signed artifact, not a
/// deploy-key action.
///
/// Dispatched via `.github/workflows/manual-broadcast.yaml` with
/// `script = 20260729-deploy-governance-timelock` and `network = base` /
/// `ethereum`, broadcasting as the CI deploy key (`secrets.PRIVATE_KEY`) —
/// the same key and flow the impl deploys use. The Zoltu deploy is
/// idempotent per network: the address is a pure function of the creation
/// code, and a second dispatch refuses at pre-flight.
///
/// After each chain's broadcast, the post-execution pin PR hydrates that
/// chain's `LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK*` pin with the
/// logged address (`testPinsMatchDerivedAddressesOnceHydrated` pins the
/// hydrated value to the derivation).
contract DeployGovernanceTimelock is Script {
/// @notice Where `run()` writes the per-chain deployed addresses, so a
/// verification step can target every chain without re-deriving them.
string internal constant DEPLOYMENTS_PATH = "out/20260729-governance-timelock-deployments.json";

/// @notice Every chain the governance timelock is 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 timelock 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 governance timelock across every chain in
/// `networks()` in a single dispatch, skipping any chain that already
/// carries it.
///
/// The address is a pure function of the chain's Safe (the only
/// constructor input that varies), so each chain has its own derived
/// address and the deploy is idempotent per chain: an already-deployed
/// chain is asserted into its pinned configuration and skipped, never
/// redeployed. That makes a re-dispatch a safe no-op, and lets a partial
/// rollout (one chain broadcast, another not) finish in a single run
/// rather than needing a separate dispatch per chain.
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 timelock = _deployOnActiveChain();
manifest = string.concat(
manifest,
i == 0 ? "" : ",",
"{\"network\":\"",
nets[i],
"\",\"chainId\":",
vm.toString(block.chainid),
",\"address\":\"",
vm.toString(timelock),
"\"}"
);
}

// Emit the per-chain addresses so verification can run against every
// chain — including on a re-dispatch, where every chain is skipped and
// no broadcast artifact exists to read them from.
vm.writeFile(DEPLOYMENTS_PATH, string.concat("[", manifest, "]"));
console2.log("Deployments manifest:", DEPLOYMENTS_PATH);
}

/// @notice Deploy (or verify-and-skip) the timelock on whichever chain is
/// currently selected. Pre-flight covers everything the deploy relies on:
/// the chain's Safe carries the pinned policy, any hydrated pin agrees
/// with the derivation, and the Zoltu factory is canonical. Post-state
/// proves the landed timelock carries the full pinned configuration and
/// that the deployer holds nothing.
function _deployOnActiveChain() internal returns (address) {
// Resolve THIS chain's token-owner Safe and assert it is in its
// expected state — the Safe is baked into the timelock's constructor
// as sole proposer + executor, so a drifted Safe would bake
// governance onto a shape we no longer recognise.
address safe = LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid);
address expected = LibTimelockInvariants.expectedTimelockAddress(safe);

// A hydrated pin must agree with what this chain's Safe derives.
// Disagreement means the pin records another chain's timelock, or the
// Safe behind it moved — either way the wrong contract.
address pinned = LibTimelockInvariants.timelockForChainId(block.chainid);
if (pinned != address(0) && pinned != expected) {
revert TimelockPinMismatch(block.chainid, pinned, expected);
}

// Already deployed: assert it is the timelock we expect, then skip.
// The Zoltu deploy is idempotent, so this keeps a re-dispatch a clean
// no-op while still proving the live contract's configuration.
if (expected.code.length != 0) {
LibTimelockInvariants.assertTimelockState(expected, safe);
console2.log(" - Timelock already deployed, skipping:", vm.toString(expected));
if (pinned == address(0)) {
console2.log(" - PIN OUTSTANDING: hydrate this chain's LibTimelockInvariants pin with the above");
}
return expected;
}

// 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();

// Deployer identity — inside `vm.startBroadcast()` msg.sender resolves
// to the broadcast address (the CI deploy key in production).
// Captured for the holds-nothing post-state check.
address deployer = msg.sender;

address timelock = LibRainDeploy.deployZoltu(LibTimelockInvariants.timelockInitCode(safe));

vm.stopBroadcast();

if (timelock != expected) revert TimelockAddressMismatch(expected, timelock);

_assertPostState(timelock, safe, deployer);

// Log the timelock address prominently so the operator can copy it
// into the post-execution pin PR (hydrate this chain's
// `LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK*` pin).
console2.log("==== GOVERNANCE TIMELOCK DEPLOYED ====");
console2.log("Chain:", block.chainid);
console2.log("Timelock:", vm.toString(timelock));
console2.log("Codehash:", vm.toString(timelock.codehash));
console2.log("MinDelay (seconds):", LibTimelockInvariants.TIMELOCK_MIN_DELAY);
console2.log("Proposer/Canceller/Executor Safe:", vm.toString(safe));
console2.log("======================================");

return timelock;
}

/// @notice Post-state assertion invoked after the deploy: the timelock
/// carries the full pinned configuration
/// (`LibTimelockInvariants.assertTimelockState` — codehash, minDelay,
/// Safe role set, self-administration, no open roles) and the deployer
/// holds none of the timelock's roles. Split from `run()` so tests can
/// call it against a timelock they deployed directly.
/// @param timelock The freshly-deployed timelock.
/// @param safe The chain's token-owner Safe.
/// @param deployer The address that broadcast the deploy.
function _assertPostState(address timelock, address safe, address deployer) internal view {
LibTimelockInvariants.assertTimelockState(timelock, safe);

IAccessControl acl = IAccessControl(timelock);
bytes32[4] memory roles = [
LibTimelockInvariants.TIMELOCK_PROPOSER_ROLE,
LibTimelockInvariants.TIMELOCK_CANCELLER_ROLE,
LibTimelockInvariants.TIMELOCK_EXECUTOR_ROLE,
LibTimelockInvariants.TIMELOCK_DEFAULT_ADMIN_ROLE
];
for (uint256 i = 0; i < roles.length; i++) {
if (acl.hasRole(roles[i], deployer)) {
revert DeployerHoldsTimelockRole(roles[i], deployer);
}
}
}
}
Loading
Loading