feat(script): 20260729-migrate-governance-to-timelock Safe bundle - #286
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
fa417a8 to
a4d3b1a
Compare
a4d3b1a to
6743df4
Compare
e1c1ccd to
61806fa
Compare
6743df4 to
fa5842f
Compare
009ce0a to
d785f3b
Compare
fa5842f to
024b9c7
Compare
d785f3b to
b23db21
Compare
beb665d to
0edf040
Compare
Deploys the ST0x governance timelock (unmodified pre-audited OZ TimelockController) on the active chain via the Zoltu deterministic factory, dispatched from manual-broadcast.yaml as the CI deploy key. Constructor-configured, no post-deploy ceremony: minDelay = 48h, the chain's token-owner Safe is sole proposer (hence canceller) + executor, admin = address(0) so the timelock self-administers from birth — the deploy key is never granted any role (asserted in post-state, not just assumed). Deploying grants the timelock no power; it becomes governance admin only when the Safe executes the separate migration bundle. Pre-flight: live Safe policy assert, pin-not-hydrated guard, Zoltu factory codehash pin, derived-address-empty guard (deploy landed but pin PR outstanding surfaces by name). Post-state: full LibTimelockInvariants.assertTimelockState + deployer-holds-nothing sweep. Live-fork tests drive the full run() on Base + Ethereum heads: the deploy lands at the derived per-chain address with the pinned configuration, re-dispatch refuses, unsupported chains refuse, and the holds-nothing check is proven non-vacuous against the Safe itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RquYKmoEVSfuVHy8kwn1yT
…rness The Rain one-contract-per-file convention check fails the static job: the deploy-script test declared both the harness and the test contract. Move the harness into its own file, matching the existing DeployV4AuthoriserCloneHarness pattern.
Drives the deploy against a HyperEVM head fork alongside Base and Ethereum, and records the deliberate consequence of HyperEVM sharing Ethereum's token-owner Safe: the Zoltu-derived timelock address is identical on the two chains, so the equality is asserted rather than discovered at pin time.
…gured HYPEREVM_RPC_URL is not provisioned in CI yet (rainix is adding the RPC_URL_HYPEREVM_FORK slot, RAI-1511) and the static job bans vm.skip, so this matches the PENDING-log-and-return pattern the multichain stack's own HyperEVM suites already use. The assertion stays in the tree and starts running the moment the secret lands.
manual-broadcast.yaml ran forge script with --broadcast but no --verify and no explorer key, so nothing deployed through it has ever been verified — unlike the rainix suite deploys. Adds --verify and forwards the per-chain CI_DEPLOY_*_ETHERSCAN_API_KEY secrets that foundry.toml's [etherscan] block resolves by network alias. HyperEVM had no [etherscan] entry at all. It has no dedicated *scan deployment; Etherscan's multichain v2 API serves it (chain 999, explorer hyperevmscan.io), so the api url pins the chainid explicitly — the same reason the ethereum entry pins chain = 1. Multi-chain scripts broadcast to HyperEVM within a single run whatever network was dispatched, so they always take the legacy-tx path that HyperEVM's fee-history-less RPC requires.
forge script --verify cannot serve a multi-chain broadcast: it resolves one explorer from --rpc-url, then fails to locate the contracts that landed on the other chains — the Ethereum timelock was submitted to Base's explorer and the run failed with 'Unable to locate ContractCode'. run() now writes the per-chain addresses to out/, and a dedicated step verifies each against its own explorer. Reading a manifest rather than the broadcast artifact also keeps verification working on a re-dispatch, where every chain is skipped and no transactions are produced. --guess-constructor-args reads the creation tx from the explorer, so the per-chain Safe baked into the constructor is not duplicated in the workflow.
Etherscan's v2 multichain API rejects a free-tier key for anything beyond
mainnet ('Free API access is not supported for this chain'), and the
Ethereum and HyperEVM key secrets were never set at all. Sourcify covers all
three chains — including HyperEVM — with no API key, and matches on bytecode
so neither constructor args nor an RPC endpoint are needed.
Trade-off recorded: Sourcify is a separate index, so this does NOT surface as
verified on Basescan. Explorers that ingest Sourcify (Blockscout) show it;
Etherscan does not.
Etherscan does not ingest Sourcify, so Sourcify alone never surfaces on Basescan — which was the original gap. Run both: Sourcify unconditionally (free, and the only verifier covering HyperEVM), and Etherscan per chain only where a key is configured, so a missing or free-tier key skips that leg instead of failing the deploy. Setting a paid v2 key later turns the Etherscan leg on with no further change.
…ION_KEY
Every working deploy in this org verifies through rainix, which resolves
each chain's key as EXPLORER_VERIFICATION_KEY first and only then falls back
to the per-chain CI_DEPLOY_*_ETHERSCAN_API_KEY. That single unified key is an
Etherscan v2 multichain key, which is why verification has never been a
problem on the rainix path.
This workflow read the per-chain secrets directly instead — a stale free-tier
key on Base ('Free API access is not supported for this chain'), and nothing
at all for Ethereum and HyperEVM. Adopting the same resolution order makes it
use the key that already works.
Authors the Safe Tx Builder bundle that hands governance to the timelock
on the active chain: grant the authoriser's seven _ADMIN roles to the
timelock, transfer every production receipt vault's ownership Safe ->
timelock, then renounce the Safe's _ADMIN copies — grants first,
renounces last, one atomic MultiSend, so the Safe gives nothing up until
everything it hands over has landed. The Safe keeps its three direct
action roles: day-to-day operations stay un-timelocked, only admin power
moves.
Self-scoping from live state (per-vault owner + per-role holder state
machine): partial prior executions resume cleanly, full completion
refuses with NothingToMigrate, any owner/holder outside {Safe, timelock}
aborts with a typed error. The _ADMIN slice is read from
LibAuthoriserInvariants' master map, not hand-listed.
Post-state: uniform timelock ownership + unchanged authoriser wiring,
full grant map with the timelock as admin holder, Safe stripped of
_ADMIN, Safe identity/threshold unchanged, timelock config unchanged.
Then the governance loop is proven end-to-end on the fork: the Safe
schedules an idempotent admin op through its threshold-gated
execTransaction (n+1 walk, undersigned attempts rejected), the op is
pending-not-ready inside the 48h window, and executes to Done after it —
the exact path every future governance action takes.
run-script.yaml: register the script and add a network choice input
(base/ethereum) so chain-aware authoring scripts dispatch per network;
concurrency group + RPC env extended to match.
Live-fork tests stand up the real timelock at its derived address via the
deploy script, drive the full authoring, and re-derive the expected
bundle independently from pre-run state, asserting the emitted artifact
matches tx-for-tx (7 grants -> 29 transfers -> 7 renounces on Base).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RquYKmoEVSfuVHy8kwn1yT
…kHarness Same convention break as the deploy-script test one branch down: the migration test declared both the harness and the test contract. The static job stops at the first violation, so this one only surfaces once that is fixed.
HyperEVM carries 29 live production tokens behind the same Safe, authoriser and beacon shape as Base and Ethereum, so the migration authors there on identical terms rather than as a follow-up. Adds the authoriser-clone and token-table arms to the script's chain dispatch; everything downstream (vault selection, beacon selection, grant map, governance-loop proof) is already chain-generic and self-scopes from block.chainid. Also adds hyperevm to run-script.yaml's network options, which the multichain stack had not done — without it the dispatcher cannot target the chain the script now supports.
The multi-chain run() iterates every network and leaves the LAST one selected, so calling it from a Base-forked test silently moved the assertions onto HyperEVM — surfacing as 'no Safe-owned vaults'. Drive the per-chain step through the harness instead, which deploys on whichever fork the test selected and leaves it there.
c767a32 to
34e2037
Compare
92e25f4 to
263e26a
Compare
The hyperevm network option was added to the dispatcher without the matching RPC secret, so authoring the HyperEVM Safe bundle failed with 'environment variable HYPEREVM_RPC_URL not found' before reaching any pre-flight.
|
Reviewed 766f75f: ship — the migration Safe-bundle authoring script: simulation-only (never broadcasts; the CI key signs nothing), full live pre-flight with typed refusals (unpinned/zero timelock, codehash drift, missing operational grants, unknown vault owner), self-scoping selection from live chain state, atomic grants→transfers→renounces ordering, post-state asserting the Safe retains no _ADMIN, the end-to-end governance-loop proof through the Safe threshold gate, and the HyperEVM authoring wiring (dispatcher network + RPC). All repo CI green, no unresolved review threads. |

Authors the Safe Tx Builder bundle that hands governance to the timelock
on the active chain: grant the authoriser's seven _ADMIN roles to the
timelock, transfer every production receipt vault's ownership Safe ->
timelock, then renounce the Safe's _ADMIN copies — grants first,
renounces last, one atomic MultiSend, so the Safe gives nothing up until
everything it hands over has landed. The Safe keeps its three direct
action roles: day-to-day operations stay un-timelocked, only admin power
moves.
Self-scoping from live state (per-vault owner + per-role holder state
machine): partial prior executions resume cleanly, full completion
refuses with NothingToMigrate, any owner/holder outside {Safe, timelock}
aborts with a typed error. The _ADMIN slice is read from
LibAuthoriserInvariants' master map, not hand-listed.
Post-state: uniform timelock ownership + unchanged authoriser wiring,
full grant map with the timelock as admin holder, Safe stripped of
_ADMIN, Safe identity/threshold unchanged, timelock config unchanged.
Then the governance loop is proven end-to-end on the fork: the Safe
schedules an idempotent admin op through its threshold-gated
execTransaction (n+1 walk, undersigned attempts rejected), the op is
pending-not-ready inside the 48h window, and executes to Done after it —
the exact path every future governance action takes.
run-script.yaml: register the script and add a network choice input
(base/ethereum) so chain-aware authoring scripts dispatch per network;
concurrency group + RPC env extended to match.
Live-fork tests stand up the real timelock at its derived address via the
deploy script, drive the full authoring, and re-derive the expected
bundle independently from pre-run state, asserting the emitted artifact
matches tx-for-tx (7 grants -> 29 transfers -> 7 renounces on Base).
Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01RquYKmoEVSfuVHy8kwn1yT