Skip to content

Give the timelock hand-off its own script, so a second pass can run it - #354

Merged
rbcp18 merged 3 commits into
developfrom
claude/relaxed-meitner-0h9pxi
Sep 22, 2026
Merged

rbcp18 merged 3 commits into
developfrom
claude/relaxed-meitner-0h9pxi

Conversation

@rbcp18

@rbcp18 rbcp18 commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Arc and Robinhood both launched with every proxy, both beacons and the group factory owned by one deployer EOA with no code — 0x8cfd836B4FCD05414f63523BC02f24bda992aa20 — while their 2-of-5 Safes and TimelockControllers sat deployed, correctly wired to each other, and owning nothing. Arc's ProxyAdmin has since been handed over (#353); the rest has not, and Robinhood is entirely untouched.

Mainnet is correct for contrast — its ProxyAdmin.owner() is the configured timelock. This is specific to the two chains launched after the bug existed, and both are live with real loans.

Why it happened

The transfers live inside the :deploy scripts that create those contracts, and on a two-pass chain launch that is the wrong place for them:

  1. Pass 1 runs with protocolTimelock unset → transfer skipped
  2. The script return trues anyway → hardhat-deploy records its migration id
  3. Pass 2 — the pass that has the timelock — finds the id recorded and never runs it

So the instruction those scripts print, "run deploy again after setting protocolTimelock", is not something anyone can actually follow.

Why a new script rather than clearing the records

Clearing the stale ids would unstick it, but those ids gate :deploy scripts — re-running them also re-runs deployBeacon against whatever contracts ref the run pins, shipping a new beacon implementation when all that was wanted was to move an owner.

So the transfer gets its own id, which no existing record blocks. It deploys nothing.

Every action is guarded on the current owner:

current owner action
already the timelock reported, left alone
the deployer transferred
anything else reported, left alone — never forced

A chain that is already correct is a no-op. A chain handed to something other than the deployer is never quietly overwritten. A contract a chain doesn't have is skipped rather than taking the run down. It covers CollateralEscrowBeacon, LenderCommitmentGroupBeaconV2, LenderCommitmentGroupFactory_V2 and the ProxyAdmin, and it skips entirely where protocolTimelock is unset — the one case where recording an id would be wrong.

Modelled on 38_transfer_apechain_timelock_ownership.ts, which does the same thing for one chain; this is the chain-agnostic version.

The two scripts that caused it

They keep their behaviour — they did deploy what they were asked to, and their ids are honestly recorded — but they no longer print an instruction that cannot be carried out. They point here instead.

To run

yarn hh deploy --network <chain> --tags protocol:transfer-timelock-ownership

with <CHAIN>_TIMELOCK_ADDRESS set. Arc's is 0x10b5089c2707e6d04750c10ee9D2Cc877761AE5C, Robinhood's is 0x81B014d0318361D646f7930D27C174D104724106.

Not verified

No node_modules in this checkout, so nothing was compiled or type-checked.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Gc9MzY447rkqkXopXT6vmP


Generated by Claude Code

Arc and Robinhood both launched with every proxy, both beacons and the group
factory owned by one deployer EOA with no code, while their 2-of-5 Safes and
TimelockControllers sat deployed, correctly wired to each other, and owning
nothing. Arc's ProxyAdmin has since been handed over; the rest has not, and
Robinhood is untouched.

The cause is structural rather than a typo. Those transfers live inside the
`:deploy` scripts that create the contracts, and on a two-pass chain launch
that is the wrong place for them: pass 1 runs with protocolTimelock unset, the
transfer is skipped, the script records its migration id anyway, and pass 2 -
the pass that has the timelock - never runs it again. The instruction those
scripts print, "run deploy again after setting protocolTimelock", is not
something anyone can actually follow.

Clearing the stale records would unstick it, but it also re-runs deployBeacon
against whatever contracts ref the run pins, shipping a new implementation
when all that was wanted was to move an owner. So the transfer gets its own
script and its own id, which no existing record blocks. It deploys nothing.

Every action is guarded on the current owner: already the timelock is reported
and left alone, the deployer is transferred, anything else is reported and
never forced. A chain that is already correct is a no-op, and a chain handed
to something other than the deployer is never quietly overwritten. A contract
a chain does not have is skipped rather than taking the run down.

The two scripts that caused this keep their own behaviour - they did deploy
what they were asked to, and their ids are honestly recorded - but they no
longer print an instruction that cannot be carried out. They point here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gc9MzY447rkqkXopXT6vmP
Written by scripts/deploy-chain.sh. Timelock: not yet deployed
Written by scripts/deploy-chain.sh. Timelock: not yet deployed
@rbcp18
rbcp18 merged commit 6b17ff6 into develop Sep 22, 2026
2 checks passed
@rbcp18
rbcp18 deleted the claude/relaxed-meitner-0h9pxi branch September 22, 2026 05:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants