From 2aa9be4b5ba91f8a8722cb6d57267545968d52a5 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 29 Jul 2026 07:05:40 +0000 Subject: [PATCH 1/5] docs: governance timelock runbook docs/TIMELOCK.md: role model (Safe = proposer/canceller/executor, 48h min delay, timelock self-administered, deploy key never holds anything), deterministic per-chain addresses + pin constants, the four-step per-chain rollout (deploy broadcast -> pin PR -> CI-authored Safe bundle -> execute -> post-execution flip PR), how future governance actions work (schedule -> 48h -> execute, batching, cancellation), the invariant surface, the dedicated-canceller placeholder, and explicit out-of-scope follow-ups (beacon ownership, HyperEVM). CLAUDE.md: Governance section pointing script authors at LibTimelockInvariants.timelockForChainId and the schedule/execute pattern for any bundle touching onlyOwner/_ADMIN surfaces. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RquYKmoEVSfuVHy8kwn1yT --- CLAUDE.md | 15 +++++ docs/TIMELOCK.md | 152 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 docs/TIMELOCK.md diff --git a/CLAUDE.md b/CLAUDE.md index 51c90053..da9084e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,6 +151,21 @@ Source contracts should reference addresses and codehashes through the versioned `src/generated/*.pointers.sol`. The pointer files are consumed only by the deploy libraries. +## Governance + +Admin power over the production deployment is (post-migration) held by a +per-chain **governance timelock** — an unmodified OZ `TimelockController` that +owns every production receipt vault and holds the authoriser's seven `_ADMIN` +roles, with the token-owner Safe as sole proposer/canceller/ executor and a 48h +min delay. The Safe keeps the direct action roles +(`DEPOSIT`/`WITHDRAW`/`CERTIFY`); only admin surfaces are delay-gated. +Constants + invariants live in `src/lib/LibTimelockInvariants.sol`; +`timelockForChainId(block.chainid)` is the only sanctioned way for a script to +resolve the chain's timelock. Scripts authoring Safe bundles that touch +`onlyOwner`/`_ADMIN` surfaces must route them through `timelock.schedule(...)` → +(≥48h) → `timelock.execute(...)`. Full role model, rollout state, and runbook: +`docs/TIMELOCK.md`. + ## Dependencies Git submodules managed via Foundry. Key remappings in `foundry.toml`: diff --git a/docs/TIMELOCK.md b/docs/TIMELOCK.md new file mode 100644 index 00000000..0cd807f7 --- /dev/null +++ b/docs/TIMELOCK.md @@ -0,0 +1,152 @@ +# ST0x Governance Timelock + +## What it is + +An **unmodified, pre-audited OpenZeppelin `TimelockController`** (compiled from +the version-locked `@openzeppelin-contracts` 5.6.1 soldeer dependency) that sits +between the token-owner Safe and the privileged surfaces of the ST0x deployment. +After the migration executes, the timelock is: + +- the `owner()` of **every production receipt vault** — so `transferOwnership`, + `setAuthorizer`, and every `onlyOwner` surface (including owner freezes) is + delay-gated; and +- the **sole holder of the authoriser's seven `_ADMIN` roles** (`DEPOSIT_ADMIN`, + `WITHDRAW_ADMIN`, `CERTIFY_ADMIN`, `CONFISCATE_SHARES_ADMIN`, + `CONFISCATE_RECEIPT_ADMIN`, `SCHEDULE_CORPORATE_ACTION_ADMIN`, + `CANCEL_CORPORATE_ACTION_ADMIN`) — so adding or removing grants on the + authoriser is delay-gated. + +The Safe **keeps its three direct action roles** (`DEPOSIT`, `WITHDRAW`, +`CERTIFY`) and the service signer keeps its operational grants: day-to-day +operations are NOT timelocked. Only admin power is. + +No deployed ST0x contract changes for any of this — the timelock is purely a +deployment-config choice (the owner/admin principal moves from the Safe to the +timelock). That keeps the audited contract set untouched. + +## Role model + +| Role on the timelock | Holder | Meaning | +| -------------------- | ------------------------------------------------------ | --------------------------------------------------------------- | +| `PROPOSER_ROLE` | token-owner Safe | schedules operations | +| `CANCELLER_ROLE` | token-owner Safe (+ dedicated canceller, once decided) | vetoes a scheduled operation inside the window | +| `EXECUTOR_ROLE` | token-owner Safe | executes once the delay elapses | +| `DEFAULT_ADMIN_ROLE` | the timelock itself | role changes are themselves timelocked (OZ self-administration) | + +- **Min delay: 48 hours** (`LibTimelockInvariants.TIMELOCK_MIN_DELAY`). Changing + it is a timelocked `updateDelay` operation and must update the pin in the same + operational window. +- **The CI deploy key holds nothing, ever.** The deploy passes + `admin = address(0)`, so the constructor grants the deployer no role — there + is no configuration window and nothing to revoke. The deploy script's + post-state proves it. +- **No open roles.** OZ treats a zero-address grantee as "role open to + everyone"; `assertTimelockState` rejects that on every lifecycle role. +- **Canceller placeholder.** `LibTimelockInvariants.TIMELOCK_CANCELLER` is + `address(0)` until a dedicated canceller key/Safe is decided. Until then the + Safe cancels (the OZ constructor grants cancellership to proposers). + Provisioning the canceller later = schedule + `grantRole(CANCELLER_ROLE, canceller)` on the timelock + hydrate the constant + in the same window; `assertTimelockState` starts asserting the grant once the + pin is non-zero. + +## Addresses + +The timelock is deployed via the **Zoltu deterministic factory**: its address is +a pure function of its creation code (OZ creation bytecode + constructor args). +The chain's Safe is a constructor arg, so each chain gets a distinct, +precomputable address — +`LibTimelockInvariants.expectedTimelockAddress(chain's Safe)`. + +Constants (in `src/lib/LibTimelockInvariants.sol`) that future scripts and +invariants target: + +| Constant | Chain | Status | +| ----------------------------------- | -------- | ------------------------------------------------ | +| `STOX_GOVERNANCE_TIMELOCK` | Base | placeholder — hydrate after the deploy broadcast | +| `STOX_GOVERNANCE_TIMELOCK_ETHEREUM` | Ethereum | placeholder — hydrate after the deploy broadcast | +| `TIMELOCK_CANCELLER` | all | placeholder — dedicated canceller undecided | + +`testPinsMatchDerivedAddressesOnceHydrated` pins every hydrated address to the +derivation, so a pin PR cannot record a wrong address. + +## Rollout (per chain) + +1. **Deploy** — Actions → `manual-broadcast` → + `20260729-deploy-governance-timelock`, network `base` / `ethereum`. CI deploy + key broadcasts; the timelock lands at the derived address, fully configured + by its constructor. +2. **Pin PR** — hydrate that chain's + `LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK*` with the logged address. +3. **Author the migration bundle** — Actions → `run-script` → + `20260729-migrate-governance-to-timelock`, network `base` / `ethereum`. Emits + the Safe Tx Builder JSON + (`out/20260729-governance-timelock-migration-.json`) after a full + pre-flight, simulation, post-state assertion, and an end-to-end schedule → + 48h → execute proof on the fork. The logged MultiSend `SafeTxHash` is the + signer cross-check. +4. **Sign + execute** — import the CI-authored artifact into the Safe UI (never + a locally generated JSON), verify the hash, execute. The bundle is atomic: 7 + `_ADMIN` grants to the timelock → N vault `transferOwnership` → 7 Safe + renounces. +5. **Post-execution flip PR** — mark the migration script + `**EXECUTED YYYY-MM-DD.**`, repoint the strict uniform-ownership invariants + (`LibInvariants.assertAll`, `LibTokenInvariants` consumers, + `StoxProdV2`/`LibInvariants` fork tests, cross-chain parity) from the Safe to + the timelock, and retire the spent branch of the migration-window suite. + +The forcing function: `GovernanceTimelockMigration.t.sol` accepts +Safe-or-timelock per surface until **2026-10-01T00:00:00Z**, then demands the +timelock. An unfinished rollout red-lines cron past that date. + +## Operating under the timelock (future governance actions) + +Every admin action becomes two Safe transactions separated by ≥48h: + +1. **Schedule**: Safe → `timelock.schedule(target, 0, data, 0, salt, 172800)` + where `data` is the admin call (e.g. + `authoriser.grantRole(DEPOSIT, newSigner)`, + `vault.setAuthorizer(newAuthoriser)`, `vault.transferOwnership(newOwner)`). + Batch multiple calls with `scheduleBatch`. +2. **Wait** out the delay. Anyone can watch pending operations via + `CallScheduled` events; the Safe (or the dedicated canceller, once + provisioned) can `cancel(id)` during the window. +3. **Execute**: Safe → `timelock.execute(target, 0, data, 0, salt)` (or + `executeBatch`) with the identical arguments. + +Operational scripts that author such bundles follow the existing dated +`run-script` pattern — they should build the schedule/execute calldata against +`LibTimelockInvariants` constants, and each script's simulation should include +the same warp-and-execute loop proof the migration script uses. + +**Key invariant for script authors**: the Safe can no longer call `onlyOwner` / +`_ADMIN`-gated functions directly. Any script that authors a Safe bundle +targeting those surfaces must target the timelock instead, and +`LibTimelockInvariants.timelockForChainId(block.chainid)` is the only sanctioned +way to resolve it. + +## Invariants + +- `LibTimelockInvariants.assertTimelockState(timelock, safe)` — codehash + (derived from the compiled OZ dependency, so a dependency bump surfaces as + drift against the live deployment), 48h min delay, the full role model above, + no open roles, no root admin outside the timelock itself. +- `LibAuthoriserInvariants.assertExpectedGrants(authoriser, safe, + timelock)` — + the single master grant map, parameterised on the admin holder; post-migration + consumers pass the timelock. +- `LibTokenInvariants.assertUniformOwnershipMigration` / + `GovernanceTimelockMigration.t.sol` — the migration window + deadline (see + above). + +## Explicitly out of scope (follow-ups) + +- **Beacon ownership.** The upgrade beacons remain Safe-owned. Moving them under + the timelock is the same one-call-per-beacon `transferOwnership` pattern and + can reuse this machinery wholesale, but it gates contract UPGRADES (not token + admin) and deserves its own decision + rollout. +- **Dedicated canceller** — see the placeholder above. +- **HyperEVM** — the chain tables and Safe pins for HyperEVM land with the + multichain stack; extending the timelock there is: add the chain to + `timelockForChainId`, dispatch the deploy broadcast, pin, migrate — the + scripts are already chain-aware. From 68862ebf6cacd1b9a17e7df7fbb75548ea7a5e84 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 10 Aug 2026 08:55:28 +0000 Subject: [PATCH 2/5] docs: HyperEVM in the timelock runbook, with the CI RPC caveat --- docs/TIMELOCK.md | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/TIMELOCK.md b/docs/TIMELOCK.md index 0cd807f7..b66a8134 100644 --- a/docs/TIMELOCK.md +++ b/docs/TIMELOCK.md @@ -65,22 +65,23 @@ invariants target: | ----------------------------------- | -------- | ------------------------------------------------ | | `STOX_GOVERNANCE_TIMELOCK` | Base | placeholder — hydrate after the deploy broadcast | | `STOX_GOVERNANCE_TIMELOCK_ETHEREUM` | Ethereum | placeholder — hydrate after the deploy broadcast | +| `STOX_GOVERNANCE_TIMELOCK_HYPEREVM` | HyperEVM | placeholder — hydrate after the deploy broadcast | | `TIMELOCK_CANCELLER` | all | placeholder — dedicated canceller undecided | `testPinsMatchDerivedAddressesOnceHydrated` pins every hydrated address to the derivation, so a pin PR cannot record a wrong address. -## Rollout (per chain) +## Rollout (per chain: Base, Ethereum, HyperEVM) 1. **Deploy** — Actions → `manual-broadcast` → - `20260729-deploy-governance-timelock`, network `base` / `ethereum`. CI deploy - key broadcasts; the timelock lands at the derived address, fully configured - by its constructor. + `20260729-deploy-governance-timelock`, network `base` / `ethereum` / + `hyperevm`. CI deploy key broadcasts; the timelock lands at the derived + address, fully configured by its constructor. 2. **Pin PR** — hydrate that chain's `LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK*` with the logged address. 3. **Author the migration bundle** — Actions → `run-script` → - `20260729-migrate-governance-to-timelock`, network `base` / `ethereum`. Emits - the Safe Tx Builder JSON + `20260729-migrate-governance-to-timelock`, network `base` / `ethereum` / + `hyperevm`. Emits the Safe Tx Builder JSON (`out/20260729-governance-timelock-migration-.json`) after a full pre-flight, simulation, post-state assertion, and an end-to-end schedule → 48h → execute proof on the fork. The logged MultiSend `SafeTxHash` is the @@ -139,6 +140,25 @@ way to resolve it. `GovernanceTimelockMigration.t.sol` — the migration window + deadline (see above). +### HyperEVM caveat + +HyperEVM is in scope on the same terms as Base and Ethereum — 29 live production +tokens, same Safe / authoriser / beacon shape — but `HYPEREVM_RPC_URL` is **not +yet provisioned in CI** (rainix is adding the `RPC_URL_HYPEREVM_FORK` slot, +RAI-1511). Until it lands, HyperEVM's live-fork assertions +(`testHyperevmGovernanceInMigrationWindow`, the deploy fork test) soft-skip with +a `PENDING` log, matching the multichain stack's own HyperEVM suites — the +repo's static job bans `vm.skip`, so a logged early return is the sanctioned +form. + +What still holds HyperEVM unconditionally is the **fork-free** half: +`testEveryGovernedChainHasTimelockCoverage` (a chain with a pinned token-owner +Safe must resolve through `timelockForChainId`) and +`testEveryPinnedChainResolves`. Dropping HyperEVM's arm fails both regardless of +RPC availability. Before executing the HyperEVM bundle, dispatch the migration +authoring against a HyperEVM fork locally with `HYPEREVM_RPC_URL` set — CI +cannot yet prove that leg for you. + ## Explicitly out of scope (follow-ups) - **Beacon ownership.** The upgrade beacons remain Safe-owned. Moving them under @@ -146,7 +166,3 @@ way to resolve it. can reuse this machinery wholesale, but it gates contract UPGRADES (not token admin) and deserves its own decision + rollout. - **Dedicated canceller** — see the placeholder above. -- **HyperEVM** — the chain tables and Safe pins for HyperEVM land with the - multichain stack; extending the timelock there is: add the chain to - `timelockForChainId`, dispatch the deploy broadcast, pin, migrate — the - scripts are already chain-aware. From 025f7e681f03c019206b4c914dc5e77d29089adb Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 10 Aug 2026 10:27:00 +0000 Subject: [PATCH 3/5] docs: keep the timelock runbook to durable facts, not rollout progress --- docs/TIMELOCK.md | 56 ++++++++++++++++++------------------------------ 1 file changed, 21 insertions(+), 35 deletions(-) diff --git a/docs/TIMELOCK.md b/docs/TIMELOCK.md index b66a8134..fe4a3223 100644 --- a/docs/TIMELOCK.md +++ b/docs/TIMELOCK.md @@ -42,13 +42,14 @@ timelock). That keeps the audited contract set untouched. post-state proves it. - **No open roles.** OZ treats a zero-address grantee as "role open to everyone"; `assertTimelockState` rejects that on every lifecycle role. -- **Canceller placeholder.** `LibTimelockInvariants.TIMELOCK_CANCELLER` is - `address(0)` until a dedicated canceller key/Safe is decided. Until then the - Safe cancels (the OZ constructor grants cancellership to proposers). - Provisioning the canceller later = schedule - `grantRole(CANCELLER_ROLE, canceller)` on the timelock + hydrate the constant - in the same window; `assertTimelockState` starts asserting the grant once the - pin is non-zero. +- **Dedicated canceller.** The OZ constructor grants cancellership to proposers, + so the Safe can always cancel. A separate canceller principal is optional and + lives in `LibTimelockInvariants.TIMELOCK_CANCELLER`: while that pin is + `address(0)` no extra canceller is expected, and once it is non-zero + `assertTimelockState` asserts the grant. Provisioning one is itself a + timelocked operation — schedule `grantRole(CANCELLER_ROLE, canceller)` on the + timelock and hydrate the constant in the same operational window. It cannot be + set in the constructor, which takes no canceller argument. ## Addresses @@ -61,14 +62,18 @@ precomputable address — Constants (in `src/lib/LibTimelockInvariants.sol`) that future scripts and invariants target: -| Constant | Chain | Status | -| ----------------------------------- | -------- | ------------------------------------------------ | -| `STOX_GOVERNANCE_TIMELOCK` | Base | placeholder — hydrate after the deploy broadcast | -| `STOX_GOVERNANCE_TIMELOCK_ETHEREUM` | Ethereum | placeholder — hydrate after the deploy broadcast | -| `STOX_GOVERNANCE_TIMELOCK_HYPEREVM` | HyperEVM | placeholder — hydrate after the deploy broadcast | -| `TIMELOCK_CANCELLER` | all | placeholder — dedicated canceller undecided | - -`testPinsMatchDerivedAddressesOnceHydrated` pins every hydrated address to the +| Constant | Holds | +| ----------------------------------- | ----------------------------------------- | +| `STOX_GOVERNANCE_TIMELOCK` | the Base timelock | +| `STOX_GOVERNANCE_TIMELOCK_ETHEREUM` | the Ethereum timelock | +| `STOX_GOVERNANCE_TIMELOCK_HYPEREVM` | the HyperEVM timelock | +| `TIMELOCK_CANCELLER` | the dedicated canceller, once provisioned | + +Each pin is `address(0)` until that chain's deploy broadcast has run and the pin +PR has recorded the address. `timelockForChainId` returns the pin, so an +unhydrated chain reads as `address(0)` and every consumer that requires a +timelock refuses rather than proceeding against a wrong address. +`testPinsMatchDerivedAddressesOnceHydrated` ties every hydrated pin to the derivation, so a pin PR cannot record a wrong address. ## Rollout (per chain: Base, Ethereum, HyperEVM) @@ -140,29 +145,10 @@ way to resolve it. `GovernanceTimelockMigration.t.sol` — the migration window + deadline (see above). -### HyperEVM caveat - -HyperEVM is in scope on the same terms as Base and Ethereum — 29 live production -tokens, same Safe / authoriser / beacon shape — but `HYPEREVM_RPC_URL` is **not -yet provisioned in CI** (rainix is adding the `RPC_URL_HYPEREVM_FORK` slot, -RAI-1511). Until it lands, HyperEVM's live-fork assertions -(`testHyperevmGovernanceInMigrationWindow`, the deploy fork test) soft-skip with -a `PENDING` log, matching the multichain stack's own HyperEVM suites — the -repo's static job bans `vm.skip`, so a logged early return is the sanctioned -form. - -What still holds HyperEVM unconditionally is the **fork-free** half: -`testEveryGovernedChainHasTimelockCoverage` (a chain with a pinned token-owner -Safe must resolve through `timelockForChainId`) and -`testEveryPinnedChainResolves`. Dropping HyperEVM's arm fails both regardless of -RPC availability. Before executing the HyperEVM bundle, dispatch the migration -authoring against a HyperEVM fork locally with `HYPEREVM_RPC_URL` set — CI -cannot yet prove that leg for you. - ## Explicitly out of scope (follow-ups) - **Beacon ownership.** The upgrade beacons remain Safe-owned. Moving them under the timelock is the same one-call-per-beacon `transferOwnership` pattern and can reuse this machinery wholesale, but it gates contract UPGRADES (not token admin) and deserves its own decision + rollout. -- **Dedicated canceller** — see the placeholder above. +- **Dedicated canceller** — see the role model above. From d640d17f354242e8c51b4e48f62b55cc8cc4f3f2 Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 11 Aug 2026 11:38:46 +0000 Subject: [PATCH 4/5] docs: the timelock codehash expectation is a frozen pin, not a derivation Match the runbook to the lib: assertTimelockState asserts against the pinned TIMELOCK_RUNTIME_CODEHASH literal of the frozen TIMELOCK_CREATION_CODE generation, so compiler-settings or dependency changes cannot drift the expectation away from the live deployment. Co-Authored-By: Claude Fable 5 --- docs/TIMELOCK.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/TIMELOCK.md b/docs/TIMELOCK.md index fe4a3223..8fc971aa 100644 --- a/docs/TIMELOCK.md +++ b/docs/TIMELOCK.md @@ -134,9 +134,11 @@ way to resolve it. ## Invariants - `LibTimelockInvariants.assertTimelockState(timelock, safe)` — codehash - (derived from the compiled OZ dependency, so a dependency bump surfaces as - drift against the live deployment), 48h min delay, the full role model above, - no open roles, no root admin outside the timelock itself. + (against the pinned `TIMELOCK_RUNTIME_CODEHASH` literal of the frozen + `TIMELOCK_CREATION_CODE` generation, so a compiler-settings or dependency + change cannot drift the expectation away from the live deployment), 48h min + delay, the full role model above, no open roles, no root admin outside the + timelock itself. - `LibAuthoriserInvariants.assertExpectedGrants(authoriser, safe, timelock)` — the single master grant map, parameterised on the admin holder; post-migration From 229c61cfbb57f8c75a3b3390c22e024859e197dd Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 11 Aug 2026 16:27:44 +0000 Subject: [PATCH 5/5] docs: runbook matches the pin-with-arm doctrine and the multi-chain deploy Two sections still described the retired rollout mechanics: pins framed as address(0)-until-hydrated-by-a-post-deploy-PR (pins are now written with the chain arm, derived from the frozen bytecode before any deploy, zero refused by every consumer, the equality test unconditional and renamed), and the deploy framed as a per-network dispatch (it is one dispatch iterating networks() with skip-with-assert idempotency). Co-Authored-By: Claude Fable 5 --- docs/TIMELOCK.md | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/TIMELOCK.md b/docs/TIMELOCK.md index 8fc971aa..77d8edc3 100644 --- a/docs/TIMELOCK.md +++ b/docs/TIMELOCK.md @@ -69,21 +69,26 @@ invariants target: | `STOX_GOVERNANCE_TIMELOCK_HYPEREVM` | the HyperEVM timelock | | `TIMELOCK_CANCELLER` | the dedicated canceller, once provisioned | -Each pin is `address(0)` until that chain's deploy broadcast has run and the pin -PR has recorded the address. `timelockForChainId` returns the pin, so an -unhydrated chain reads as `address(0)` and every consumer that requires a -timelock refuses rather than proceeding against a wrong address. -`testPinsMatchDerivedAddressesOnceHydrated` ties every hydrated pin to the -derivation, so a pin PR cannot record a wrong address. +Every pin is written with its chain arm, derived from the frozen creation +bytecode and that chain's Safe pin before any deploy — +`testPinsMatchDerivedAddresses` asserts each equality unconditionally, so a +wrong or zeroed pin cannot survive CI. A zero pin is never a legitimate phase: +every consumer (the deploy pre-flight, the migration authoring, the +migration-window suite) refuses it as a reverted or never-hydrated arm rather +than proceeding against a wrong address. All three pins are hydrated and their +timelocks live. ## Rollout (per chain: Base, Ethereum, HyperEVM) -1. **Deploy** — Actions → `manual-broadcast` → - `20260729-deploy-governance-timelock`, network `base` / `ethereum` / - `hyperevm`. CI deploy key broadcasts; the timelock lands at the derived - address, fully configured by its constructor. -2. **Pin PR** — hydrate that chain's - `LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK*` with the logged address. +1. **Chain arm + pin** — a governed chain's `LibTimelockInvariants` arm is added + WITH its pin, derived from the frozen creation bytecode and the chain's Safe + pin (`expectedTimelockAddress`). The deploy refuses a zero pin, so the arm + always precedes the broadcast. +2. **Deploy** — Actions → `manual-broadcast` → + `20260729-deploy-governance-timelock`. One dispatch covers every governed + chain: the script iterates `networks()`, skips-with-assert any chain already + carrying its timelock, and the CI deploy key broadcasts the rest — each + landing at its derived address, fully configured by its constructor. 3. **Author the migration bundle** — Actions → `run-script` → `20260729-migrate-governance-to-timelock`, network `base` / `ethereum` / `hyperevm`. Emits the Safe Tx Builder JSON