[FEAT] Role-based access control across host-chain and registry-chain contracts - #92
[FEAT] Role-based access control across host-chain and registry-chain contracts#92liorbond wants to merge 11 commits into
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
roeezolantz
left a comment
There was a problem hiding this comment.
Reviewed the whole diff locally, plus the OZ 5.2.0 sources and live chain state. The role mapping itself is solid — every onlyOwner maps to exactly one onlyRole, nothing is left ungated, sequential storage is preserved, and the CREATE2 byte-identity fix is correct and necessary. Nice catch on that one.
Severity tags on each comment. Blocking items are the Critical on initializeV2 and the missing test for the migration path.
On initializeV2 (the Critical): the deploy scripts are correct — upgradeToAndCall really is one atomic tx, so that path is safe. The problem is that the safety is a property of one ternary in TypeScript, not of the contract. Anything that upgrades without going through it (a Safe, a manual cast send, upgradeProxy with no call:, or the three contracts that have no such script) reopens the window. Verified on Sepolia that the canonical TM proxy 0xeA30c4...48D9 currently has _initialized = 1 and a zero default-admin slot, so both preconditions are live today.
A few things that couldn't be anchored inline:
- High — no test for the migration. Every
upgradeToAndCallin the suite passes"0x", and the onlyinitializeV2test is the already-initialized case where OZ reverts for you. Worth one that deploys DeterministicTM, doesupgradeToAndCall(TMImpl, "0x"), then has a non-admin callinitializeV2and win — so the fix has something to prove. - Medium —
storage-layout-snapshot.jsonshould be regenerated in this PR. upgrades-core only walks namespaces present in the original layout, so the newAccessControl/AccessControlDefaultAdminRulesnamespaces aren't tracked yet and a later PR could drop them with CI green. Safe order: runstorage-layout:checkagainst the current baseline first (it passes, which proves compatibility), thengenerateand eyeball the diff — should be additions only. Not re-baselining blindly was the right call; re-baselining with that evidence is better than deferring. - Low —
deploy/deploy.ts:230,upgrades.upgradeProxy(...)with nocall:sends empty calldata. Marked not-currently-used, but it's generic over ACL/PT/TM and one use on a pre-roles proxy opens the window. Delete it or make it pass the migration data. - Low — registry-chain has no storage-layout check and no CI job at all (its 95 tests don't run either). Filed as PRO-528, not for this PR. Related: the
CHANGELOG.md:16claim that all four contracts retain the Ownable namespaces isn't accurate — worth fixing here.
Not flagging test/verifyInput/InputVerified.ts since I hear it's already being handled on another branch — just noting the suite is currently red on it (77 passing / 1 failing), so the numbers in the description are stale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Non-anchored items, all in fd6e0f7:
Suite is green now: host-chain 87 passing / 0 failing (the Not run: Slither. My only new assembly is an |
roeezolantz
left a comment
There was a problem hiding this comment.
Re-reviewed at fd6e0f7. The critical one is properly closed — legacy-owner gate on all four initializeV2s, slot constant re-derived and matching OZ 5.2.0, fail-closed on a zero legacy owner (which also kills the renounce-then-reclaim edge), and _pendingOwner is a different namespace so a pending 2-step owner can't sneak through. The Roles.ts migration test is the real thing too: real DeterministicTM, real "0x" gap, asserts the stranger is rejected with state unchanged. Ran both suites locally — 87 and 99 passing, storage-layout check OK on all three.
Two follow-ups below, both on the bootstrap migration path. Neither is an auth hole; they're about the DTM→TaskManager upgrade being layout-incompatible in a way that was previously hidden by the swallowed validation error.
Not blocking, noted for later: registry-chain still hardcodes DEFAULT_ADMIN_DELAY = 0 on every network while host-chain now refuses exactly that — worth aligning when the admin moves to a Safe, since that's when the cancellation window starts being worth anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All 12 threads addressed in Two notes worth surfacing rather than leaving in a thread:
I also considered fixing this structurally by making Suites: host-chain 91 passing, registry-chain 100 passing, storage-layout OK ×3, solhint 0 errors. One thing to watch: TaskManager is now 22567 bytes, 91.8% of the 24KB limit — past the 22KB warn line, ~2KB of headroom left. |
| // A proxy arriving from the pre-roles TaskManager already holds real signers in those | ||
| // slots and is left untouched: its `initialize` set both to address(1), so neither can | ||
| // legitimately be zero there. `isEnabled`, `acl` and `plaintextsStorage` are deliberately | ||
| // not touched - the first is already true on a live proxy (migrating must not pause it), | ||
| // and the latter two have no safe default and must be set via CONFIG_MANAGER_ROLE. |
There was a problem hiding this comment.
initialize never runs on a TaskManager proxy (it's initialized as DeterministicTM), and TaskManagerSetup allows VERIFIER_ADDRESS=0 on local networks — so the reasoning here doesn't hold, though the code does.
| // A proxy arriving from the pre-roles TaskManager already holds real signers in those | |
| // slots and is left untouched: its `initialize` set both to address(1), so neither can | |
| // legitimately be zero there. `isEnabled`, `acl` and `plaintextsStorage` are deliberately | |
| // not touched - the first is already true on a live proxy (migrating must not pause it), | |
| // and the latter two have no safe default and must be set via CONFIG_MANAGER_ROLE. | |
| // Zero is also a legitimate configured state - the debug bypass at L789/L861 - so a proxy | |
| // deliberately running with verification off is flipped fail-closed here and has to re-set | |
| // it after migrating. `isEnabled`, `acl` and `plaintextsStorage` are deliberately not | |
| // touched: the first is already true on a live proxy (migrating must not pause it), and the | |
| // latter two have no safe default and must be set via CONFIG_MANAGER_ROLE. |
Replaces
Ownable/Ownable2StepwithAccessControlDefaultAdminRulesonTaskManager,ACL,PlaintextsStorage, andCommitmentRegistry, binding each previouslyonlyOwnerentry point to a narrow role.Roles
TaskManagerUPGRADER,PAUSER,SECURITY_ZONE_MANAGER,AGGREGATOR_MANAGER,ACCESS_LIST_MANAGER,VERIFIER_SIGNER_MANAGER,DECRYPT_SIGNER_MANAGER,CONFIG_MANAGERACLUPGRADERPlaintextsStorageUPGRADERCommitmentRegistryUPGRADER,POSTER_MANAGER,VERSION_MANAGERNo admin entry point is left ungated.
Deployment: admin wallet receives every role
initializegrants onlyDEFAULT_ADMIN_ROLE, and_authorizeUpgraderequiresUPGRADER_ROLE— so without an explicit grant every proxy would have been permanently un-upgradeable from the moment it was deployed.Rather than maintain a hand-written list (which had also omitted
ACCESS_LIST_MANAGER_ROLE),grantAllRoles()discovers every*_ROLEconstant from the contract ABI and grants it to the admin wallet. A role added to a contract can no longer silently miss its grant.DEFAULT_ADMIN_ROLEis skipped —AccessControlDefaultAdminRulesreverts on granting it directly.Applied in
deploy/deploy.ts(TaskManager, ACL, PlaintextsStorage),tasks/upgradeTM.ts,registry-chain/scripts/deploy.ts, andestimateGasArbitrum.ts.Deploy script repairs
Three separate breakages on the localfhenix path:
deployDeterministicTM.tsthrew before sending a transaction — it encodedinitializefrom the TaskManager ABI (now 2-arg) while the implementation behind the bootstrap proxy isDeterministicTM.initialize(address). Now encoded from theDeterministicTMABI. This matters beyond the arity error: the proxy is deployed via CREATE2, so its init data feeds its address. The encoding is byte-identical to pre-PR (0xc4d66de8…), keeping the proxy at0xeA30c4…— the address compiled intoFHE.soland into ACL/PlaintextsStorage as a constant.upgradeTMcalleddefaultAdmin()on a proxy still running the Ownable implementation → revert, before the upgrade started."0x", never runninginitializeV2, leaving AccessControl storage empty; theincVersion()call two lines later then reverted for lack ofCONFIG_MANAGER_ROLE.Security:
initializeV2is unauthenticatedOn a migrating proxy,
defaultAdmin()is zero between the upgrade and the migration call — anyone could claimDEFAULT_ADMIN_ROLEin that window.initializeV2is now passed as thedataargument ofupgradeToAndCallso it executes atomically, in bothdeploy.tsand the standalonetask:upgradeTM. Fresh deploys were already safe: a second call reverts withAccessControlEnforcedDefaultAdminRules.registry-chain migration
CommitmentRegistry.solhad been migrated but nothing around it:scripts/deploy.tsandestimateGasArbitrum.tspassed the old 2-arg initializer and calledsetVersionStatuswithoutVERSION_MANAGER_ROLE, and all 95 tests failed at the fixture. Scripts, fixture, and behavior tests migrated (owner()→defaultAdmin(),transferOwnership/acceptOwnership→beginDefaultAdminTransfer/acceptDefaultAdminTransfer,OwnableUnauthorizedAccount→AccessControlUnauthorizedAccount).Storage layout
pnpm storage-layout:check(enforced by.github/workflows/checks.yml) rejected all three host-chain contracts: droppingOwnable/Ownable2Stepdeletes their ERC-7201 namespaces, leaving orphaned owner data a future upgrade could reuse. Applied the validator's own recommendation — retaining the namespace structs — rather than re-baseliningstorage-layout-snapshot.json, which would have silenced a real warning.Tests
New
test/roles/Roles.ts(13 tests) asserts the deploy invariant directly: the admin holds every declared role on all three contracts,UPGRADER_ROLEgates upgrades,DEFAULT_ADMIN_ROLEcannot stand in for an operational role, andinitializeV2cannot hijack an initialized proxy. Host-chain fixtures now use the same ABI-driven helper, so a future role added without a grant fails tests rather than a deployment.¹ one pre-existing unused-
Stringswarning, identical on master. ² the package has pre-existing typechain/Contracttyping errors throughout; none added.Breaking changes
initializesignatures:TaskManager/ACL/PlaintextsStoragetake(address initialAdmin, uint48 initialDelay);CommitmentRegistrytakes(address initialAdmin, uint48 initialDelay, address initialPoster).owner()→defaultAdmin();transferOwnership/acceptOwnership→beginDefaultAdminTransfer/acceptDefaultAdminTransfer.UPGRADER_ROLE.initializeV2viaupgradeToAndCall.Follow-ups (deliberately not in scope)
Deterministic*bootstrap contracts stay onOwnable— changing their bytecode would shift the canonical TaskManager address.initialDelayis0everywhere, matching current behavior. Worth revisiting for mainnet, where a non-zero default-admin handover delay is the point of usingAccessControlDefaultAdminRules.🤖 Generated with Claude Code