Skip to content

feat(script): 20260729-migrate-governance-to-timelock Safe bundle - #286

Merged
thedavidmeister merged 19 commits into
mainfrom
feat/20260729-migrate-governance-to-timelock
Aug 11, 2026
Merged

feat(script): 20260729-migrate-governance-to-timelock Safe bundle#286
thedavidmeister merged 19 commits into
mainfrom
feat/20260729-migrate-governance-to-timelock

Conversation

@hardyjosh

Copy link
Copy Markdown
Contributor

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

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ffe2dcfd-b33f-4f13-af6e-0e5ad1bc6aa2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@hardyjosh
hardyjosh marked this pull request as ready for review July 29, 2026 07:11
@hardyjosh
hardyjosh force-pushed the feat/20260729-migrate-governance-to-timelock branch from fa417a8 to a4d3b1a Compare August 3, 2026 14:00
@hardyjosh
hardyjosh force-pushed the feat/20260729-migrate-governance-to-timelock branch from a4d3b1a to 6743df4 Compare August 10, 2026 09:04
@hardyjosh
hardyjosh force-pushed the feat/20260729-deploy-governance-timelock branch from e1c1ccd to 61806fa Compare August 10, 2026 09:04
@hardyjosh
hardyjosh force-pushed the feat/20260729-migrate-governance-to-timelock branch from 6743df4 to fa5842f Compare August 10, 2026 10:04
@hardyjosh
hardyjosh force-pushed the feat/20260729-deploy-governance-timelock branch 2 times, most recently from 009ce0a to d785f3b Compare August 10, 2026 10:11
@hardyjosh
hardyjosh force-pushed the feat/20260729-migrate-governance-to-timelock branch from fa5842f to 024b9c7 Compare August 10, 2026 10:11
@hardyjosh
hardyjosh force-pushed the feat/20260729-deploy-governance-timelock branch from d785f3b to b23db21 Compare August 10, 2026 11:28
@hardyjosh
hardyjosh force-pushed the feat/20260729-migrate-governance-to-timelock branch 3 times, most recently from beb665d to 0edf040 Compare August 10, 2026 12:02
Josh Hardy and others added 10 commits August 11, 2026 09:52
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.
Josh Hardy and others added 8 commits August 11, 2026 09:52
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.
@hardyjosh
hardyjosh force-pushed the feat/20260729-migrate-governance-to-timelock branch from c767a32 to 34e2037 Compare August 11, 2026 09:56
@hardyjosh
hardyjosh force-pushed the feat/20260729-deploy-governance-timelock branch from 92e25f4 to 263e26a Compare August 11, 2026 09:56
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.
@thedavidmeister
thedavidmeister changed the base branch from feat/20260729-deploy-governance-timelock to main August 11, 2026 15:17
@thedavidmeister

Copy link
Copy Markdown
Contributor

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.
Rulings-conformance: checked against every ruling stated for this work and the repo CLAUDE.md. (1) "zero is a checked case" — obeyed: the script refuses a zero pin with typed TimelockNotPinned before authoring anything. (2) "timelock code needs to be a constant" — obeyed: all timelock resolution goes through the frozen-pin lib now in main; assertTimelockState asserts the pinned literal codehash against the live instance in pre-flight. (3) no-brick assurances — the governance-loop proof in this layer is the authoring-time gate; the signer-side verify(string) rides the stack tip (#290) by explicit decision ("ok leave it"), since it must byte-match the beacon-inclusive final bundle. (4) merge ordering — retargeted to main on instruction; three-way merge is clean and main-side newer versions of the deploy-layer files prevail uncontested. (5) CLAUDE.md pragma/naming/single-profile rulings — obeyed; no deployed contract source changes, no pointer or CHANGELOG obligations.

@thedavidmeister
thedavidmeister merged commit e444afb into main Aug 11, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants