Skip to content

feat(timelock): migrate the upgrade beacons under the timelock too - #290

Merged
thedavidmeister merged 12 commits into
mainfrom
feat/20260729-timelock-beacon-ownership
Aug 12, 2026
Merged

feat(timelock): migrate the upgrade beacons under the timelock too#290
thedavidmeister merged 12 commits into
mainfrom
feat/20260729-timelock-beacon-ownership

Conversation

@hardyjosh

@hardyjosh hardyjosh commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Vault ownership and the authoriser _ADMIN roles are not the whole admin
surface. Every production token proxies through the chain's three in-use
upgrade beacons, and a beacon owner can upgradeTo a new implementation for
all of them in one transaction — a hostile implementation can re-take vault
ownership and rewrite the authoriser wiring outright. Timelocking
setAuthorizer and vault ownership while leaving the beacons Safe-owned
leaves the delay bypassable by design, so the beacons move in the same
atomic bundle.

  • The migration selects still-Safe-owned beacons from live state, pinning
    each beacon's codehash to the OZ UpgradeableBeacon bytecode BEFORE
    trusting its owner() read, and aborts on any owner outside
    {Safe, timelock}.
  • Bundle order becomes grants -> vault transfers -> beacon transfers ->
    renounces, so the Safe gives nothing up until everything has landed.
  • The post-state proves each beacon still serves the implementation it
    served before the bundle: this moves upgrade AUTHORITY, never performs an
    upgrade.
  • LibBeaconInvariants gains an owner-parametric assertion and a
    migration-window variant, so the 2026-10-01 forcing function red-lines on
    un-migrated beacons, not just un-migrated vaults.

run() and the happy-path test both crossed the legacy-codegen stack limit
once the beacon locals were added. via_ir stays off per CLAUDE.md, so the
selection collapses into a MigrationTargets struct and the post-state /
artifact blocks move into helpers. No behavioural change.

Summary by CodeRabbit

  • New Features

    • Governance migration now transfers upgrade beacon ownership to the timelock alongside vault ownership and administrative roles.
    • Added migration verification for ownership targets, implementation preservation, and artifact integrity.
    • Expanded migration support across Base, Ethereum, and HyperEVM.
    • Beacon upgrades and authorizer changes now follow the timelocked governance process.
  • Bug Fixes

    • Migration checks reject unexpected beacon owners, altered deployment artifacts, and implementation changes.
  • Documentation

    • Updated rollout steps, migration sequencing, and ownership invariants to include upgrade beacons.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The migration now transfers ownership of three production upgrade beacons with vault ownership and authorizer roles. The script validates beacon codehashes, preserves implementations, verifies artifacts, and checks post-migration state across three supported chains.

Changes

Governance migration

Layer / File(s) Summary
Beacon ownership invariants
src/lib/LibBeaconInvariants.sol, test/src/lib/*, test/src/concrete/deploy/GovernanceTimelockMigration.t.sol
Beacon invariants validate deployment codehashes, expected owners, and migration-window ownership. Tests cover codehash drift and unexpected owners.
Migration targets and atomic bundle
script/20260729-migrate-governance-to-timelock.s.sol, docs/TIMELOCK.md
The script derives vault, beacon, grant, and renounce targets. The atomic bundle grants roles, transfers vault and beacon ownership, then renounces Safe roles.
Post-state and artifact verification
script/20260729-migrate-governance-to-timelock.s.sol, test/script/20260729-migrate-governance-to-timelock.t.sol, docs/TIMELOCK.md
The script adds verify(), artifact comparison, beacon implementation checks, and post-state validation. Tests cover artifact validation and tamper rejection.
Cross-chain and governance-flow tests
test/script/20260729-migrate-governance-to-timelock.t.sol, test/src/concrete/deploy/GovernanceTimelockMigration.t.sol
Tests cover Base, Ethereum, and HyperEVM, successor governance, delayed beacon upgrades, delayed authorizer changes, and Safe-mediated schedule/execute flows.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Safe
  participant MigrationScript
  participant Timelock
  participant VaultsAndBeacons
  Safe->>MigrationScript: provide live migration state
  MigrationScript->>VaultsAndBeacons: validate owners and implementations
  MigrationScript->>Timelock: build and verify migration bundle
  Safe->>Timelock: submit governance migration
  Timelock->>VaultsAndBeacons: transfer vault and beacon ownership
  Timelock->>Safe: complete role renouncements
  MigrationScript->>VaultsAndBeacons: verify post-migration state
Loading

Possibly related PRs

Suggested reviewers: thedavidmeister

Poem

A rabbit checked each beacon bright,
Then packed the roles for timelock night.
Vaults crossed over in ordered lines,
Safe roles fell at proper signs.
“No implementation moved,” said the hare.
“The artifact matches with care!”

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main change: migrating production upgrade beacons under timelock control.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/20260729-timelock-beacon-ownership

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 force-pushed the feat/20260729-timelock-beacon-ownership branch from 15e66b4 to f15612d Compare August 3, 2026 15:18
@hardyjosh
hardyjosh force-pushed the docs/20260729-timelock-governance branch from 2f457cd to 8ee276d Compare August 3, 2026 15:18
@hardyjosh
hardyjosh marked this pull request as ready for review August 4, 2026 10:10
@hardyjosh
hardyjosh force-pushed the docs/20260729-timelock-governance branch from 8ee276d to ff072b0 Compare August 10, 2026 09:04
@hardyjosh
hardyjosh force-pushed the feat/20260729-timelock-beacon-ownership branch 2 times, most recently from 53ae210 to ef6704b Compare August 10, 2026 10:04
@hardyjosh
hardyjosh force-pushed the docs/20260729-timelock-governance branch from ff072b0 to a1250e5 Compare August 10, 2026 10:04
@hardyjosh
hardyjosh force-pushed the feat/20260729-timelock-beacon-ownership branch from ef6704b to 3a583cd Compare August 10, 2026 10:11
@hardyjosh
hardyjosh force-pushed the docs/20260729-timelock-governance branch from a1250e5 to c416c40 Compare August 10, 2026 10:11
@hardyjosh
hardyjosh force-pushed the feat/20260729-timelock-beacon-ownership branch from 3a583cd to 686a1df Compare August 10, 2026 11:28
@hardyjosh
hardyjosh force-pushed the docs/20260729-timelock-governance branch from c416c40 to 6e69e39 Compare August 10, 2026 11:28
@hardyjosh
hardyjosh force-pushed the feat/20260729-timelock-beacon-ownership branch from 686a1df to bfe333b Compare August 10, 2026 11:50
@hardyjosh
hardyjosh force-pushed the docs/20260729-timelock-governance branch 2 times, most recently from ee70d26 to af13b89 Compare August 10, 2026 12:03
@hardyjosh
hardyjosh force-pushed the feat/20260729-timelock-beacon-ownership branch from bfe333b to 5f51624 Compare August 10, 2026 12:03
@hardyjosh
hardyjosh force-pushed the docs/20260729-timelock-governance branch from af13b89 to 2c016ca Compare August 11, 2026 09:56
@hardyjosh
hardyjosh force-pushed the feat/20260729-timelock-beacon-ownership branch 2 times, most recently from 18fd996 to 570799b Compare August 11, 2026 10:12
@hardyjosh
hardyjosh force-pushed the docs/20260729-timelock-governance branch from 2c016ca to 025f7e6 Compare August 11, 2026 10:12
@thedavidmeister
thedavidmeister changed the base branch from docs/20260729-timelock-governance to main August 11, 2026 16:38
hardyjosh and others added 2 commits August 12, 2026 13:38
Vault ownership and the authoriser _ADMIN roles are not the whole admin
surface. Every production token proxies through the chain's three in-use
upgrade beacons, and a beacon owner can upgradeTo a new implementation for
all of them in one transaction — a hostile implementation can re-take vault
ownership and rewrite the authoriser wiring outright. Timelocking
setAuthorizer and vault ownership while leaving the beacons Safe-owned
leaves the delay bypassable by design, so the beacons move in the same
atomic bundle.

- The migration selects still-Safe-owned beacons from live state, pinning
  each beacon's codehash to the OZ UpgradeableBeacon bytecode BEFORE
  trusting its owner() read, and aborts on any owner outside
  {Safe, timelock}.
- Bundle order becomes grants -> vault transfers -> beacon transfers ->
  renounces, so the Safe gives nothing up until everything has landed.
- The post-state proves each beacon still serves the implementation it
  served before the bundle: this moves upgrade AUTHORITY, never performs an
  upgrade.
- LibBeaconInvariants gains an owner-parametric assertion and a
  migration-window variant, so the 2026-10-01 forcing function red-lines on
  un-migrated beacons, not just un-migrated vaults.

run() and the happy-path test both crossed the legacy-codegen stack limit
once the beacon locals were added. via_ir stays off per CLAUDE.md, so the
selection collapses into a MigrationTargets struct and the post-state /
artifact blocks move into helpers. No behavioural change.
The chain-generic script was only ever exercised against Base, so HyperEVM
coverage rested on the chain map resolving rather than on the bundle being
correct. Parameterises the happy-path assertions by active chain (authoriser
clone, token table, artifact chain id) and adds the HyperEVM leg through the
same shared assertions, so neither chain is proven by a copy that can drift.

Verified locally against live HyperEVM: 29 vault transfers, 3 beacon
transfers, 7 grants, 7 renounces. Soft-skips in CI until HYPEREVM_RPC_URL is
provisioned (RAI-1511).
Josh Hardy and others added 6 commits August 12, 2026 13:38
UPGRADEABLE_BEACON_CODEHASH is documented as equal to
LibProdDeployV1.PROD_BEACON_BASE_RUNTIME_CODEHASH_V1 but the two copies
were tied only transitively — separate fork suites asserting each against
the same live beacons. The structural equality is now pinned directly, so
a drifted copy surfaces as the source defect it is rather than as an
on-chain codehash 'mismatch'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two coverage gaps on the governance loop:

- The full authoring was proven on Base and HyperEVM but never Ethereum,
  which shares HyperEVM's Safe and timelock yet has its own token table
  and authoriser clone. testRunAuthorsFullMigrationOnEthereum drives the
  shared assertion set on a live Ethereum fork, so all three governed
  chains carry the same proof.

- Nothing proved the timelock is not a one-way door.
  testGovernanceCanMigrateAwayToSuccessor executes the full migration on
  a Base fork, then hands every surface class onward to a successor
  principal through the real schedule -> 48h -> execute loop, each leg
  driven through the Safe's threshold-gated execTransaction (n+1 walk):
  a vault ownership transfer, a beacon ownership transfer, an authoriser
  _ADMIN handover with the timelock renouncing its own copy, and a role
  change on the timelock itself via self-administration. Each leg is
  proven pending-not-ready inside the window and Done after it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rough it

The upgrade path itself now has a literal proof: post-migration a direct
Safe upgradeTo reverts OwnableUnauthorizedAccount (the instant path is
closed), and the sanctioned path — schedule upgradeTo through the Safe's
threshold-gated exec, 48h, execute — completes with the beacon still
serving the same implementation (idempotent upgradeTo(currentImpl)).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gh it

The surface that rewires which contract gates every deposit, withdrawal
and transfer was asserted as STATE everywhere (authorizer() uniformity)
but the PATH had no proof. Post-migration a direct Safe setAuthorizer
reverts OwnableUnauthorizedAccount, and the sanctioned schedule -> 48h ->
execute path — each leg through the Safe's threshold-gated exec (n+1
walk) — completes, proven with an idempotent re-set of the current
authoriser and the wiring asserted unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing window

The last unguarded step in the migration was between CI authoring the
bundle and signers executing it: the SafeTxHash cross-check caught a
different bundle, but nothing re-checked the artifact against LIVE state
at signing time. verify(string) does, locally on the signer's machine:
re-runs run()'s full pre-flight, re-derives the expected bundle from
current chain state, asserts the downloaded artifact matches byte-exactly
(typed MigrationVerifyMismatch pinpoints the first divergent field), and
recomputes the canonical MultiSend SafeTxHash at the live nonce for the
Safe-UI cross-check. A nonce bump, a moved vault/beacon/role, or a
tampered or stale artifact all surface before anyone signs. Deliberately
not in the run-script dispatcher: it takes a local path and runs on the
signer's machine, per the established verify(string) convention.

The runbook's sign-and-execute step now instructs each signer to run it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@script/20260729-migrate-governance-to-timelock.s.sol`:
- Around line 428-430: Update the BUNDLE_NAME constant used by _emitArtifact and
LibSafeOps.emitTxBuilderJson to include the upgrade-beacon ownership transfer
alongside vault ownership and authoriser admin roles, so the Safe UI title
accurately describes all bundle transactions.

In `@src/lib/LibBeaconInvariants.sol`:
- Around line 266-277: Update assertProdBeaconsOwnershipMigration to validate
each beacon’s codehash against the existing UPGRADEABLE_BEACON_CODEHASH before
calling IOwnable(beacons[i]).owner(). Retain the deployment check and ensure
mismatched codehashes fail before any owner-based migration invariant is
evaluated.

In `@test/script/20260729-migrate-governance-to-timelock.t.sol`:
- Around line 276-291: Update the transfer assertions to iterate the full vault
and beacon tables while maintaining separate selected-transfer indices,
filtering entries by their post-run timelock ownership before comparing against
txs. Apply the same selection rule used by the migration script, incrementing
the expected transfer index only for selected entries, and remove the assumption
that the first expectedTransfers or expectedBeaconTransfers entries are all
migrated.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 881b944c-1167-485e-ac6d-c2a452743539

📥 Commits

Reviewing files that changed from the base of the PR and between 130d924 and e79c442.

📒 Files selected for processing (7)
  • docs/TIMELOCK.md
  • script/20260729-migrate-governance-to-timelock.s.sol
  • src/lib/LibBeaconInvariants.sol
  • test/script/20260729-migrate-governance-to-timelock.t.sol
  • test/src/concrete/deploy/GovernanceTimelockMigration.t.sol
  • test/src/lib/LibBeaconInvariants.t.sol
  • test/src/lib/LibBeaconInvariantsHarness.sol

Comment thread script/20260729-migrate-governance-to-timelock.s.sol
Comment thread src/lib/LibBeaconInvariants.sol
Comment thread test/script/20260729-migrate-governance-to-timelock.t.sol Outdated
@thedavidmeister

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

thedavidmeister and others added 4 commits August 12, 2026 14:19
The Tx Builder meta.name signers read in the Safe UI still described the
bundle as vault ownership + authoriser admin roles; the bundle also
transfers the three in-use upgrade beacons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The migration script's selection pins the OZ UpgradeableBeacon codehash
before trusting owner(); the ownership sweep and the migration-window
drift detector read owner() after only a code-presence check, so a
look-alike beacon shadowing the selector could pass either. Both loops
now share one deployment+codehash gate, and inverted fork tests prove a
look-alike reporting an accepted owner trips BeaconCodehashMismatch
before the owner read is consulted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The artifact-order assertions compared transfers against a prefix of the
vault/beacon tables, which only matches the script's skip-already-
timelocked selection rule when every table entry is Safe-owned. Capture
the Safe-owned addresses (not counts) before the run and assert the
emitted transfers against that selection, so the proof holds on any fork
state including a partial prior run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The post-execution flip list predates the beacon leg; the StoxProdV4 and
HyperEvmBeaconOwnership fork tests and the live deploy-script pre-flights
assert Safe-owned beacons and red-line after execution until repointed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/LibBeaconInvariants.sol (1)

228-245: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add REUSE metadata to docs/TIMELOCK.md and run nix develop -c rainix-sol-legal before merge. The four Solidity files already contain the required SPDX identifiers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/LibBeaconInvariants.sol` around lines 228 - 245, Update
docs/TIMELOCK.md (lines 12-28) with the required REUSE metadata, leaving the
Solidity sites in src/lib/LibBeaconInvariants.sol (lines 228-245),
test/src/lib/LibBeaconInvariants.t.sol (lines 200-259),
script/20260729-migrate-governance-to-timelock.s.sol (lines 299-346), and
test/script/20260729-migrate-governance-to-timelock.t.sol (lines 164-174)
unchanged because their SPDX identifiers are already correct; before merging,
run nix develop -c rainix-sol-legal.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/lib/LibBeaconInvariants.sol`:
- Around line 228-245: Update docs/TIMELOCK.md (lines 12-28) with the required
REUSE metadata, leaving the Solidity sites in src/lib/LibBeaconInvariants.sol
(lines 228-245), test/src/lib/LibBeaconInvariants.t.sol (lines 200-259),
script/20260729-migrate-governance-to-timelock.s.sol (lines 299-346), and
test/script/20260729-migrate-governance-to-timelock.t.sol (lines 164-174)
unchanged because their SPDX identifiers are already correct; before merging,
run nix develop -c rainix-sol-legal.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: eaf1f610-97b7-4cfc-8f9e-8da07ccf21ff

📥 Commits

Reviewing files that changed from the base of the PR and between e79c442 and c4692dd.

📒 Files selected for processing (5)
  • docs/TIMELOCK.md
  • script/20260729-migrate-governance-to-timelock.s.sol
  • src/lib/LibBeaconInvariants.sol
  • test/script/20260729-migrate-governance-to-timelock.t.sol
  • test/src/lib/LibBeaconInvariants.t.sol

@thedavidmeister

Copy link
Copy Markdown
Contributor

Reviewed c4692dd: KEEP — beacon leg codehash-pins before trusting owner() in both the script selection and the lib sweeps, bundle stays atomic with renounces last, implementation immutability proven across the bundle, verify(string) closes authoring-to-signing drift; all three chains' fork suites pass (HyperEVM verified locally against live RPC, RAI-1511 still gates it in CI); all CodeRabbit threads fixed and resolved.

@thedavidmeister

Copy link
Copy Markdown
Contributor

Reviewed c4692dd: KEEP — beacon leg codehash-pins before trusting owner() in both the script selection and the lib sweeps, bundle stays atomic with renounces last, implementation immutability proven across the bundle, verify(string) closes authoring-to-signing drift; all three chains' fork suites pass (HyperEVM verified locally against live RPC, RAI-1511 still gates it in CI); all CodeRabbit threads fixed and resolved.

Rulings-conformance: checked against the repo CLAUDE.md rulings and the rulings stated for this work —

  • via_ir stays OFF (Try --via-ir compilation to reduce StoxReceiptVault size #144): obeyed; stack-limit pressure from the beacon locals is solved by the MigrationTargets struct and split helpers, not by enabling IR.
  • Pragma convention (=0.8.25 contracts, ^0.8.25 libraries): obeyed; no pragma changes, new code sits in existing files that already conform.
  • Single-profile codehash pinning (no deploy/test profile split, bytecode_hash none): obeyed; foundry.toml untouched.
  • Versioning — source never references older-version constants: obeyed; LibBeaconInvariants re-declares the beacon codehash precisely to avoid reaching into LibProdDeployV1, and the equality is pinned structurally in a test (tests referencing V1 are the sanctioned audit-trail use). No deployed bytecode changes, so no new release snapshot or CHANGELOG entry is owed; git-clean CI confirms generated artifacts unchanged.
  • Governance routing (onlyOwner/_ADMIN via timelock.schedule → 48h → execute): this PR is the migration that establishes that state; post-migration paths are proven timelock-only by the upgradeTo/setAuthorizer/migrate-away fork tests.
  • Rulings stated for this work: all three CodeRabbit findings fixed, with the table-prefix test fix implemented via pre-run Safe-owned address capture (not CodeRabbit's post-run-owner filter, which false-fails on partial prior runs); merge is a true merge per the Graphite-stack ruling; branch retained.

@thedavidmeister
thedavidmeister merged commit a881d0b into main Aug 12, 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