diff --git a/.shared/INTEGRATION.md b/.shared/INTEGRATION.md index 18c3ae5..d97cc0d 100644 --- a/.shared/INTEGRATION.md +++ b/.shared/INTEGRATION.md @@ -124,12 +124,27 @@ node_modules/.bin/ts-node .shared/scripts/setup.ts --hooks node_modules/.bin/ts-node .shared/scripts/setup.ts --hooks --include-pre-commit-hook # opt into the pre-commit hook ``` -The pre-push hook executes the shared guardrail suite with `--fail-fast`, runs Prettier/ESLint/Solhint, and now -executes `npx hardhat test` by default. Set `SHARED_HARDHAT_PRE_PUSH_PRETTIER=0` or `SHARED_HARDHAT_PRE_PUSH_TEST=0` -to opt out temporarily, or override the test command with `SHARED_HARDHAT_PRE_PUSH_TEST_CMD="yarn test --runInBand"`. -Because the hook scripts live inside `.shared`, future updates to the tooling automatically flow into every -repository. Add `--include-pre-commit-hook` whenever you want the additional staged-file linting and compile guard that -the shared pre-commit hook provides. +The pre-push hook executes the shared guardrail suite with `--fail-fast`, runs Prettier/ESLint/Solhint, and +executes `npx hardhat test` by default. Because the hook scripts live inside `.shared`, future updates to the tooling +automatically flow into every repository. Add `--include-pre-commit-hook` whenever you want the additional +staged-file linting and compile guard that the shared pre-commit hook provides. + +> **Heads-up:** The default pre-push flow now blocks on Prettier. Please fix formatting before pushing instead of +> suppressing the guard via `SHARED_HARDHAT_PRE_PUSH_PRETTIER=0`. A quick way to sweep the tree is: +> +> ```bash +> node_modules/.bin/ts-node .shared/scripts/linting/prettier.ts --write +> ``` +> +> Only reach for the skip flag when you’re actively landing the formatter fix, and remember to re-enable it afterwards. + +**Yarn Berry note:** the shared package expects the `node-modules` linker (`.yarnrc.yml` with `nodeLinker: node-modules`) +so that Node ambient typings and shared binaries resolve consistently. Switch existing repos with: + +```bash +echo "nodeLinker: node-modules" > .yarnrc.yml +yarn install --mode=update-lockfile +``` > Prefer automation over manual flags? When you have access to this repository > locally, `bash path/to/scripts/subtree/add.sh --help` prints the non- @@ -218,6 +233,21 @@ node_modules/.bin/ts-node .shared/scripts/analysis/solhint.ts --quiet --max-warn 1. **Start clean** – abort if `git status --short` is non-empty. Fix compilation locally so guardrails have a stable baseline. 2. **Add the subtree** – prefer the wrapper: `bash scripts/subtree/add.sh --repo-url https://github.com/dtrinity/shared-hardhat-tools.git --branch main --prefix .shared`. Pass `--force-remove` only when replacing an existing directory after backing it up. 3. **Install the package** – `npm install file:./.shared` (or the equivalent `yarn/pnpm` command) so the bundled `ts-node` runtime is available. For Yarn Berry projects, follow immediately with `yarn install --mode=update-lockfile` to capture the new `file:` dependency so later `yarn install --immutable` checks succeed. + +### Keeping subtrees fresh + +1. Track the commit SHA applied to the repository (add it to the PR body or changelog). +2. Future updates become `git subtree pull --prefix=.shared https://github.com/dtrinity/shared-hardhat-tools.git --squash`. +3. Re-run `yarn install --mode=update-lockfile` (Yarn Berry) or the equivalent install command so lockfiles capture the new tree. +4. Re-run `node_modules/.bin/ts-node .shared/scripts/setup.ts --hooks --force` to make sure the latest hook wiring is present. +5. Run `node_modules/.bin/ts-node .shared/scripts/linting/prettier.ts --write` followed by `make lint`/`make test` to confirm the repo is still healthy. + +### Rollout learnings + +- **Prettier enforcement pays off.** Leaving `SHARED_HARDHAT_PRE_PUSH_PRETTIER` at its default forces teams to fix formatting once instead of repeatedly skipping it. Add the formatter sweep to your upgrade PR so reviewers only have to look at one big diff. +- **Ambient Hardhat/Node typings.** If you hit `TS2307` errors for built-in Node modules after updating the subtree, confirm that `types/ambient-hardhat.d.ts` and `types/node-globals.d.ts` are present and that your Yarn linker is set to `node-modules`. +- **Berry + subtrees.** Yarn 4 projects should keep `.yarnrc.yml` committed alongside the subtree to avoid accidental PnP installs, otherwise the shared scripts can’t locate ambient type definitions or the bundled `ts-node`. +- **Document the SHA.** Every shared update should call out the exact commit (`git subtree add/pull --prefix=.shared … `) in the PR description so future pulls can anchor to the same point. 4. **Run the setup preflight** – execute `node_modules/.bin/ts-node .shared/scripts/setup.ts --package-scripts` to add baseline npm scripts and surface missing prerequisites. Loop back with `--hooks`, `--configs`, or `--ci` once stakeholders sign off (teams sometimes stage those phases in their second pass, but do not forget them). 5. **Wire automation** – copy `.shared/ci/shared-guardrails.yml` into `.github/workflows/shared-guardrails.yml`, add a root `prettier.config.cjs` that re-exports `.shared/configs/prettier.config.cjs`, and stage both files with the rest of the integration diff so CI and local tooling stay in sync. 6. **Take a smoke-test lap** – run `npm run --prefix .shared lint:eslint -- --pattern 'hardhat.config.ts'`, `npm run --prefix .shared sanity:deploy-ids -- --quiet`, and either `npm run guardrails:check -- --skip-prettier --skip-solhint` (if the package script is wired up) or `npm run --prefix .shared guardrails:check -- --skip-prettier --skip-solhint` to confirm the shared tooling works in situ before committing. Drop the skips once formatting lands. diff --git a/.shared/Makefile b/.shared/Makefile index 123a971..be6211d 100644 --- a/.shared/Makefile +++ b/.shared/Makefile @@ -10,6 +10,7 @@ SHARED_ENABLE_SLITHER_TARGETS ?= 1 SHARED_ENABLE_ROLES_TARGETS ?= 1 ROLES_SCAN_ARGS ?= +ROLES_GRANT_ARGS ?= ROLES_TRANSFER_ARGS ?= ROLES_REVOKE_ARGS ?= @@ -148,7 +149,7 @@ roles.scan: ## Scan contracts for role assignments and ownership (make roles.sca fi @$(TS_NODE) $(SHARED_ROOT)/scripts/roles/scan-roles.ts --network "$(network)" --deployer "$(deployer)" --governance "$(governance)" $(if $(manifest),--manifest "$(manifest)",) $(ROLES_SCAN_ARGS) -roles.transfer: ## Transfer roles from deployer to governance (make roles.transfer network=network manifest=path [--yes]) +roles.grant: ## Grant DEFAULT_ADMIN_ROLE to governance (make roles.grant network=network manifest=path [--dry-run] [--yes]) @if [ "$(network)" = "" ]; then \ echo "Must provide 'network' argument."; \ exit 1; \ @@ -157,7 +158,18 @@ roles.transfer: ## Transfer roles from deployer to governance (make roles.transf echo "Must provide 'manifest' argument."; \ exit 1; \ fi - @$(TS_NODE) $(SHARED_ROOT)/scripts/roles/transfer-roles.ts --network "$(network)" --manifest "$(manifest)" $(if $(yes),--yes,) $(ROLES_TRANSFER_ARGS) + @$(TS_NODE) $(SHARED_ROOT)/scripts/roles/grant-default-admin.ts --network "$(network)" --manifest "$(manifest)" $(if $(yes),--yes,) $(ROLES_GRANT_ARGS) + +roles.transfer: ## Transfer Ownable ownership to governance (make roles.transfer network=network manifest=path [--dry-run] [--yes]) + @if [ "$(network)" = "" ]; then \ + echo "Must provide 'network' argument."; \ + exit 1; \ + fi + @if [ "$(manifest)" = "" ]; then \ + echo "Must provide 'manifest' argument."; \ + exit 1; \ + fi + @$(TS_NODE) $(SHARED_ROOT)/scripts/roles/transfer-ownership.ts --network "$(network)" --manifest "$(manifest)" $(if $(yes),--yes,) $(ROLES_TRANSFER_ARGS) roles.revoke: ## Revoke deployer roles via Safe batch (make roles.revoke network=network manifest=path) @if [ "$(network)" = "" ]; then \ @@ -181,4 +193,4 @@ endif analyze.shared guardrails shared.update shared.setup \ shared.sanity.deploy-ids shared.sanity.deploy-clean shared.sanity.deploy-addresses shared.sanity.oracle-addresses \ shared.metrics.nsloc \ - roles.scan roles.transfer roles.revoke + roles.scan roles.grant roles.transfer roles.revoke diff --git a/.shared/README.md b/.shared/README.md index 4aeb891..a3f57ce 100644 --- a/.shared/README.md +++ b/.shared/README.md @@ -204,9 +204,16 @@ import { runSlither } from '@dtrinity/shared-hardhat-tools/scripts/analysis/slit const success = runSlither({ network: 'mainnet', failOnHigh: true }); ``` -### Manifest-Driven Role Transfers +### Manifest-Driven Role Governance -The shared runner migrates `Ownable` ownership and `DEFAULT_ADMIN_ROLE` by reading a manifest instead of bespoke scripts. Version 2 manifests default to auto-including every contract the deployer still controls and let you opt out with targeted exclusions or overrides. A minimal example: +The shared tooling now splits role/ownership hardening into focused scripts driven by a single manifest: + +- `scan-roles` – read-only visibility into AccessControl and Ownable exposure. +- `grant-default-admin` – grants `DEFAULT_ADMIN_ROLE` to governance (direct execution). +- `revoke-roles` – prepares a Safe batch that revokes every deployer-held role. +- `transfer-ownership` – transfers `Ownable` contracts from the deployer to governance. + +Version 2 manifests still auto-include every contract the deployer controls and let you opt out with exclusions or overrides. The schema is simpler—there is no renounce list or removal strategy anymore. A minimal manifest: ```json { @@ -216,10 +223,7 @@ The shared runner migrates `Ownable` ownership and `DEFAULT_ADMIN_ROLE` by readi "autoInclude": { "ownable": true, "defaultAdmin": true }, "defaults": { "ownable": { "newOwner": "{{governance}}" }, - "defaultAdmin": { - "newAdmin": "{{governance}}", - "remove": { "strategy": "renounce", "execution": "direct", "address": "{{deployer}}" } - } + "defaultAdmin": { "newAdmin": "{{governance}}" } }, "safe": { "safeAddress": "0xSafe...", @@ -235,8 +239,7 @@ The shared runner migrates `Ownable` ownership and `DEFAULT_ADMIN_ROLE` by readi { "deployment": "SpecialContract", "defaultAdmin": { - "enabled": true, - "remove": { "strategy": "revoke", "execution": "safe", "address": "{{deployer}}" } + "enabled": true } } ] @@ -246,27 +249,31 @@ The shared runner migrates `Ownable` ownership and `DEFAULT_ADMIN_ROLE` by readi - `{{deployer}}` and `{{governance}}` placeholders resolve to the manifest addresses. - `autoInclude` determines the default sweep; exclusions and overrides explicitly change the plan. - `ownable.execution` must stay `direct`; Safe batches cannot call `transferOwnership`. -- Setting `remove.execution` to `safe` automatically switches to `revokeRole` and queues Safe transactions. +- The revoke script always generates `revokeRole` calls that the governance Safe executes offline. Before running the CLI, add `roles.deployer` and `roles.governance` to your Hardhat network config. The shared scripts fall back to these values when the CLI flags are omitted, and refuse to run if neither source is provided. Usage: ```bash -# Preview + execute direct ownership/admin transfers -ts-node .shared/scripts/roles/transfer-roles.ts --manifest manifests/roles.mainnet.json --network mainnet +# Scan for exposure and drift +ts-node .shared/scripts/roles/scan-roles.ts --manifest manifests/roles.mainnet.json --network mainnet --deployer 0xDeployer... --governance 0xGovernance... --drift-check + +# Grant DEFAULT_ADMIN_ROLE directly (prompted unless --yes) +ts-node .shared/scripts/roles/grant-default-admin.ts --manifest manifests/roles.mainnet.json --network mainnet -# Preview + queue Safe revokeRole transactions only +# Queue Safe revokeRole transactions for every deployer-held role ts-node .shared/scripts/roles/revoke-roles.ts --manifest manifests/roles.mainnet.json --network mainnet -# Dry-run without touching chain -ts-node .shared/scripts/roles/transfer-roles.ts --manifest manifests/roles.mainnet.json --network mainnet --dry-run-only +# Transfer Ownable contracts directly (prompted unless --yes) +ts-node .shared/scripts/roles/transfer-ownership.ts --manifest manifests/roles.mainnet.json --network mainnet -# Scan for new deployments and fail CI if coverage is missing -ts-node .shared/scripts/roles/scan-roles.ts --manifest manifests/roles.mainnet.json --network mainnet --deployer 0xDeployer... --governance 0xGovernance... --drift-check +# Add --dry-run to any script to print the plan without sending transactions ``` -Each command performs a guarded dry-run first, printing the planned changes and listing any remaining non-admin roles so governance can follow up manually. Supply `--json-output report.json` to persist an execution summary alongside console output. +`--json-output report.json` persists an execution summary alongside console output. The Safe batch creator never signs +or submits transactions—it only prepares a tx-builder payload and SafeTx hash offline so governors can review before +proposing. ### Setting Up Git Hooks diff --git a/.shared/RolesScriptRefactorPlan.md b/.shared/RolesScriptRefactorPlan.md new file mode 100644 index 0000000..d655b05 --- /dev/null +++ b/.shared/RolesScriptRefactorPlan.md @@ -0,0 +1,130 @@ +# Roles Script Refactor Plan + +## Goals +- Improve scanning performance by batching RPC reads more aggressively. +- Split responsibilities into dedicated scripts with narrow, low-risk scopes. +- Remove deployer-led role renounce flows; revocations happen via Safe transactions only. +- Provide clear operator feedback (progress logging, explicit manifest opt-outs, summaries). + +--- + +## Immediate Next Steps +- Baseline the current `roles:scan` experience against the Katana deployments to confirm pain points (RPC volume, slow contracts, missing progress logging). +- Annotate this document with findings and translate them into acceptance criteria for the multicall refactor. +- Lock in script CLI surfaces (flags/options) before coding so downstream projects can prepare integrations. + +--- + +## 1. Shared Enhancements + +### 1.1 Multicall Infrastructure +- Extend the multicall helper to support cross-contract batching (e.g., chunked `aggregate3` requests grouped by ABI/function signature). +- Add utilities that decode responses with context (contract name/function) and log partial failures gracefully before falling back to one-off calls. + +### 1.2 Scan Data Sources +- Build a contract discovery queue, grouping identical view calls across contracts: + - Batch constant role hash lookups (e.g., `DEFAULT_ADMIN_ROLE`, `_ROLE`) for all AccessControl contracts. + - Batch `hasRole` checks per holder (deployer vs governance) across contracts. + - Maintain a fallback path when Multicall3 is unavailable. +- Add progress logging (e.g., `Scanning contract X/Y`) and an RPC savings summary once complete. + +--- + +## 2. Script Breakdown + +### 2.1 `scan-roles.ts` +- Keep current reporting while using the new batching strategy. +- Print per-contract progress and highlight manifest opt-outs when encountered. +- Emit a final statistics block (contracts scanned, roles detected, multicall hit rate). + +### 2.2 `grant-default-admin.ts` (new “grant” script) +- Read manifest defaults/overrides but focus solely on granting `DEFAULT_ADMIN_ROLE` to the governance multisig. +- Behavior: + - Ensure the deployer signer is performing direct calls (no Safe integration). + - Skip removals entirely. + - Support dry-run planning with explicit logging (`Granting`, `Already granted`, `Skipped (opt-out)`). + - Confirm manifest opt-outs by name whenever they suppress a grant. + +### 2.3 `revoke-roles.ts` (revamp existing script) +- Purpose: build Safe batch transactions that revoke **all** AccessControl roles held by the deployer (including `DEFAULT_ADMIN_ROLE`). +- Requirements: + 1. Require a Safe configuration; no direct execution path. + 2. For each contract, queue `revokeRole` calls for every deployer-held role, unless opted out in the manifest (print these skips explicitly). + 3. Use batched scan data to seed the revocation list; no local cache between runs (re-scan each invocation). + 4. Provide JSON and console summaries, with counts for revocations queued vs skipped due to opt-outs or missing Safe config. + +### 2.4 `transfer-ownership.ts` (simplified transfer script) +- Scope: only `Ownable.transferOwnership` from deployer to governance multisig. +- Safeguards: + - Verify current owner matches the deployer signer before attempting the transfer. + - Abort if governance already owns the contract or if manifest requires an opt-out (log explicitly). + - Provide detailed prompts per contract (unless `--yes` flag) and track progress (`Transferring X/Y`). + - Print a concise summary (executed/skipped/failures). + +--- + +## 3. Manifest & Documentation + +### 3.1 Schema Adjustments +- Remove `roles.renounce` and related override structures. +- Retain opt-out capabilities via existing override flags; ensure scripts mention them when they suppress actions. +- Optionally add a manifest flag (e.g., `revocations.includeNonAdmin`) if future flexibility is required—default to `false` (current request: revoke deployer-held roles only). + +### 3.2 Docs & CLI Help +- Update README with the four-script workflow (`scan` → `grant` → `revoke` (Safe) → `transfer`). +- Document new CLI flags and expected prompts. +- Include safety notes emphasizing two-phase commit for AccessControl vs direct ownership transfer. + +--- + +## 4. Implementation Steps + +1. ✅ **Multicall & batching refactor**: extend helper, update scan logic, add progress logging/statistics. +2. ✅ **Manifest cleanup**: remove renounce policy fields, update types/validation, adjust planner output. +3. ✅ **Grant script**: create `grant-default-admin.ts`, wire into planner machinery with direct execution only. +4. ✅ **Revoke script**: overhaul to generate Safe revocation batches for all deployer-held roles, honoring opt-outs, with explicit logging. +5. ✅ **Transfer script**: strip role handling logic, reinforce ownership safeguards and progress reporting. +6. **Documentation & verification**: + - Update README/examples/CLI help. + - Run `scan` against sample deployments. + - Dry-run `grant`, `revoke`, `transfer` to validate logging and Safe batch output. + - Ensure Safe batch generation tested in a development environment. +7. **Dogfooding & acceptance**: + - Execute `roles:scan` end-to-end on the Katana repo and record before/after metrics. + - Share the diff in operator experience (progress logs, RPC calls, runtime) with stakeholders prior to release. + +--- + +## Open Questions / Confirmed Decisions +- ✅ Revocation targets only roles held by the deployer. +- ✅ Manifest opt-outs stay; each script must log when an opt-out suppresses an action. +- ✅ No inter-script cache; each run performs a fresh scan with multicall batching. + +--- + +## Dogfooding Log (Katana Mainnet scan – 2025-02-15) +- Command: `npx ts-node .shared/scripts/roles/scan-roles.ts --network katana_mainnet --manifest manifests/katana-mainnet-roles.json --deployer 0x0f5e3D9AEe7Ab5fDa909Af1ef147D98a7f4B3022 --governance 0xE83c188a7BE46B90715C757A06cF917175f30262` +- Runtime: ~29s via public RPC; ~400 log lines emitted without progress indicators. +- Pain points observed: + - No batching: one call per `hasRole`, resulting in repeated RPC churn across ~50 contracts. + - Logging is noisy and inconsistent (`Checking roles...` banners mixed with summary output). + - Missing progress counters make it unclear how long the run will take or how far along it is. + - Drift/manifests summary at the end is useful, but lack of aggregated statistics (RPC counts, failures, cache hits) hides performance characteristics. + - Startup prints missing mnemonic warnings even though scanning is read-only (should downgrade/skip when only reading). +- Acceptance criteria updates: + - New scan must show `Scanning X/Y` progress with elapsed time. + - Provide a final block containing contract count, unique role hashes fetched, and multicall savings (fallback counts). + - Compact the per-contract output (group by contract with deployer/governance role summaries) or add `--verbose` flag. + - Filter or reclassify non-blocking env warnings when running read-only tasks. +- Post-refactor validation: `npx ts-node scripts/roles/scan-roles.ts --network katana_mainnet --manifest ../katana-solidity-contracts/manifests/katana-mainnet-roles.json --deployer 0x0f5e3D9AEe7Ab5fDa909Af1ef147D98a7f4B3022 --governance 0xE83c188a7BE46B90715C757A06cF917175f30262 --deployments-dir ../katana-solidity-contracts/deployments/katana_mainnet --hardhat-config ../katana-solidity-contracts/hardhat.config.ts` + - Runtime dropped to ~6s (multicall supported; 5 aggregate batches covering 204 calls, zero fallbacks). + - Stage logging now outputs three progress markers (role hashes, hasRole checks, ownership), replacing 400+ lines with 60 lines. + - Summary block reports direct-call counts and multicall stats; exposure sections list only actionable items. + - Remaining gaps: environment warns about missing mnemonics; consider suppressing for read-only scans in future polish. +- Script dry-runs (Katana Mainnet, 2025-02-15): + - `npx ts-node .shared/scripts/roles/grant-default-admin.ts --network katana_mainnet --manifest manifests/katana-mainnet-roles.json --dry-run` + - Planned 20 grants (auto), 4 already satisfied, 2 blocked (implementations without deployer admin). + - `npx ts-node .shared/scripts/roles/revoke-roles.ts --network katana_mainnet --manifest manifests/katana-mainnet-roles.json --dry-run` + - Generated Safe batch preview with 48 `revokeRole` operations across 22 contracts, zero opt-outs. + - `npx ts-node .shared/scripts/roles/transfer-ownership.ts --network katana_mainnet --manifest manifests/katana-mainnet-roles.json --dry-run` + - Single Ownable transfer (DefaultProxyAdmin) flagged with irreversible transfer warning; no opt-outs. diff --git a/.shared/lib/roles/manifest.ts b/.shared/lib/roles/manifest.ts index 3807187..c367dbc 100644 --- a/.shared/lib/roles/manifest.ts +++ b/.shared/lib/roles/manifest.ts @@ -6,24 +6,15 @@ import { getAddress } from "@ethersproject/address"; import { SafeConfig } from "./types"; export type ExecutionMode = "direct" | "safe"; -export type DefaultAdminRemovalStrategy = "renounce" | "revoke"; export interface ManifestOwnableDefaults { readonly newOwner?: string; readonly execution?: ExecutionMode; } -export interface ManifestDefaultAdminRemoval { - readonly address?: string; - readonly strategy?: DefaultAdminRemovalStrategy; - readonly execution?: ExecutionMode; - readonly enabled?: boolean; -} - export interface ManifestDefaultAdminDefaults { readonly newAdmin?: string; readonly grantExecution?: ExecutionMode; - readonly remove?: ManifestDefaultAdminRemoval; } export interface ManifestDefaults { @@ -37,7 +28,6 @@ export interface ManifestOwnableOverrides extends ManifestOwnableDefaults { export interface ManifestDefaultAdminOverrides extends ManifestDefaultAdminDefaults { readonly enabled?: boolean; - readonly remove?: ManifestDefaultAdminRemoval; } export interface ManifestContractOverride { @@ -89,16 +79,9 @@ export interface ResolvedOwnableAction { readonly execution: ExecutionMode; } -export interface ResolvedDefaultAdminRemoval { - readonly address: string; - readonly strategy: DefaultAdminRemovalStrategy; - readonly execution: ExecutionMode; -} - export interface ResolvedDefaultAdminAction { readonly newAdmin: string; readonly grantExecution: ExecutionMode; - readonly removal?: ResolvedDefaultAdminRemoval; } export interface ResolvedOwnableOverride { @@ -198,18 +181,6 @@ function normalizeExecution(mode: ExecutionMode | undefined, fallback: Execution return mode; } -function normalizeStrategy(strategy: DefaultAdminRemovalStrategy | undefined): DefaultAdminRemovalStrategy { - if (!strategy) { - return "renounce"; - } - - if (strategy !== "renounce" && strategy !== "revoke") { - throw new ManifestValidationError(`Unsupported removal strategy: ${strategy}`); - } - - return strategy; -} - export function loadRoleManifest(manifestPath: string): RoleManifest { const absolutePath = path.isAbsolute(manifestPath) ? manifestPath : path.join(process.cwd(), manifestPath); @@ -332,15 +303,26 @@ function resolveManifestDefaults(defaults: ManifestDefaults | undefined, context const ownableDefaults = defaults?.ownable; const ownableExecution = normalizeExecution(ownableDefaults?.execution, "direct"); if (ownableExecution !== "direct") { - throw new ManifestValidationError(`defaults.ownable.execution must be 'direct'. Safe execution is not supported for Ownable transfers.`); + throw new ManifestValidationError( + `defaults.ownable.execution must be 'direct'. Safe execution is not supported for Ownable transfers.`, + ); } const resolvedOwnable: ResolvedOwnableAction = { - newOwner: resolveAddress(ownableDefaults?.newOwner ?? context.governance, context, "defaults.ownable.newOwner"), + newOwner: resolveAddress( + ownableDefaults?.newOwner ?? context.governance, + context, + "defaults.ownable.newOwner", + ), execution: ownableExecution, }; const defaultAdminDefaults = defaults?.defaultAdmin; + if (defaultAdminDefaults && Object.prototype.hasOwnProperty.call(defaultAdminDefaults as Record, "remove")) { + throw new ManifestValidationError( + "defaults.defaultAdmin.remove is no longer supported. Use the revoke script generated Safe batch to drop deployer roles.", + ); + } const grantExecution = normalizeExecution(defaultAdminDefaults?.grantExecution, "direct"); if (grantExecution === "safe") { throw new ManifestValidationError( @@ -348,38 +330,13 @@ function resolveManifestDefaults(defaults: ManifestDefaults | undefined, context ); } - const removalConfig = defaultAdminDefaults?.remove ?? {}; - const removalEnabled = removalConfig.enabled ?? true; - - let removal: ResolvedDefaultAdminRemoval | undefined; - if (removalEnabled) { - const strategy = normalizeStrategy(removalConfig.strategy); - const defaultRemovalExecution = strategy === "revoke" ? "safe" : "direct"; - const execution = normalizeExecution(removalConfig.execution, defaultRemovalExecution); - - if (execution === "safe" && strategy !== "revoke") { - throw new ManifestValidationError( - `defaults.defaultAdmin.remove.execution set to 'safe' requires the 'revoke' strategy.`, - ); - } - - const address = resolveAddress( - removalConfig.address ?? context.deployer, - context, - "defaults.defaultAdmin.remove.address", - ); - - removal = { - address, - strategy, - execution, - }; - } - const resolvedDefaultAdmin: ResolvedDefaultAdminAction = { - newAdmin: resolveAddress(defaultAdminDefaults?.newAdmin ?? context.governance, context, "defaults.defaultAdmin.newAdmin"), + newAdmin: resolveAddress( + defaultAdminDefaults?.newAdmin ?? context.governance, + context, + "defaults.defaultAdmin.newAdmin", + ), grantExecution, - removal, }; return { @@ -400,6 +357,12 @@ function resolveOwnableOverride( return { enabled }; } + if (Object.prototype.hasOwnProperty.call(override as Record, "remove")) { + throw new ManifestValidationError( + `${label}.defaultAdmin.remove is no longer supported. Use the revoke script generated Safe batch to drop deployer roles.`, + ); + } + const execution = normalizeExecution(override.execution, defaults.execution); if (execution !== "direct") { throw new ManifestValidationError( @@ -447,44 +410,11 @@ function resolveDefaultAdminOverride( `${label}.defaultAdmin.newAdmin`, ); - const removalConfig = override.remove ?? {}; - const defaultRemoval = defaults.removal; - const removalEnabled = removalConfig.enabled ?? (override.remove ? true : Boolean(defaultRemoval)); - - let removal: ResolvedDefaultAdminRemoval | undefined; - if (removalEnabled) { - const strategy = normalizeStrategy(removalConfig.strategy ?? defaultRemoval?.strategy); - const defaultRemovalExecution = strategy === "revoke" ? "safe" : "direct"; - const execution = normalizeExecution( - removalConfig.execution ?? defaultRemoval?.execution, - defaultRemoval?.execution ?? defaultRemovalExecution, - ); - - if (execution === "safe" && strategy !== "revoke") { - throw new ManifestValidationError( - `${label}.defaultAdmin.remove.execution set to 'safe' requires the 'revoke' strategy.`, - ); - } - - const address = resolveAddress( - removalConfig.address ?? defaultRemoval?.address ?? context.deployer, - context, - `${label}.defaultAdmin.remove.address`, - ); - - removal = { - address, - strategy, - execution, - }; - } - return { enabled, action: { newAdmin, grantExecution, - removal, }, }; } diff --git a/.shared/lib/roles/multicall.ts b/.shared/lib/roles/multicall.ts new file mode 100644 index 0000000..642bd12 --- /dev/null +++ b/.shared/lib/roles/multicall.ts @@ -0,0 +1,101 @@ +import { Interface } from "@ethersproject/abi"; + +export const DEFAULT_MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11"; + +const MULTICALL3_ABI = [ + "function aggregate3(tuple(address target,bool allowFailure,bytes callData)[] calls) public payable returns (tuple(bool success,bytes returnData)[] memory returnData)", +]; + +export interface MulticallRequest { + readonly target: string; + readonly callData: string; + readonly allowFailure?: boolean; +} + +export interface MulticallResult { + readonly success: boolean; + readonly returnData: string; +} + +interface MulticallOptions { + readonly address?: string; + readonly logger?: (message: string) => void; +} + +type HardhatRuntimeEnvironment = { + readonly ethers: { + getContractAt: (abi: any, address: string) => Promise<{ + readonly interface: Interface; + readonly getFunction: (name: string) => any; + }>; + }; +}; + +export async function executeMulticall( + hre: HardhatRuntimeEnvironment, + requests: MulticallRequest[], + options?: MulticallOptions, +): Promise { + if (requests.length === 0) { + return []; + } + + const address = options?.address ?? DEFAULT_MULTICALL3_ADDRESS; + + try { + const contract = await hre.ethers.getContractAt(MULTICALL3_ABI, address); + const formatted = requests.map((request) => ({ + target: request.target, + allowFailure: request.allowFailure ?? true, + callData: request.callData, + })); + const results = await contract.getFunction("aggregate3").staticCall(formatted); + return results.map((result: { success: boolean; returnData: string }) => ({ + success: Boolean(result.success), + returnData: result.returnData, + })); + } catch (error) { + options?.logger?.( + `Multicall unavailable at ${address}: ${error instanceof Error ? error.message : String(error)}. Falling back to individual calls.`, + ); + return null; + } +} + +export interface BatchedMulticallOptions extends MulticallOptions { + readonly chunkSize?: number; + readonly onBatchComplete?: (info: { readonly index: number; readonly total: number }) => void; +} + +export interface BatchedMulticallResult { + readonly results: MulticallResult[]; + readonly batchesExecuted: number; +} + +export async function executeMulticallBatches( + hre: HardhatRuntimeEnvironment, + requests: MulticallRequest[], + options?: BatchedMulticallOptions, +): Promise { + if (requests.length === 0) { + return { results: [], batchesExecuted: 0 }; + } + + const chunkSize = Math.max(1, options?.chunkSize ?? 50); + const aggregatedResults: MulticallResult[] = []; + let batchesExecuted = 0; + const totalBatches = Math.ceil(requests.length / chunkSize); + + for (let index = 0; index < requests.length; index += chunkSize) { + const chunk = requests.slice(index, index + chunkSize); + const chunkResult = await executeMulticall(hre, chunk, options); + if (chunkResult === null) { + return null; + } + batchesExecuted += 1; + options?.onBatchComplete?.({ index: batchesExecuted, total: totalBatches }); + aggregatedResults.push(...chunkResult); + } + + return { results: aggregatedResults, batchesExecuted }; +} diff --git a/.shared/lib/roles/planner.ts b/.shared/lib/roles/planner.ts index e4799cd..c9a0a57 100644 --- a/.shared/lib/roles/planner.ts +++ b/.shared/lib/roles/planner.ts @@ -216,12 +216,5 @@ export function cloneDefaultAdminAction(action: ResolvedDefaultAdminAction): Res return { newAdmin: action.newAdmin, grantExecution: action.grantExecution, - removal: action.removal - ? { - address: action.removal.address, - strategy: action.removal.strategy, - execution: action.removal.execution, - } - : undefined, }; } diff --git a/.shared/lib/roles/runner.ts b/.shared/lib/roles/runner.ts deleted file mode 100644 index f15fe04..0000000 --- a/.shared/lib/roles/runner.ts +++ /dev/null @@ -1,624 +0,0 @@ -import { Interface } from "@ethersproject/abi"; - -import { logger as sharedLogger } from "../logger"; - -import { SafeManager } from "./safe-manager"; -import { OwnableContractInfo, RolesContractInfo, scanRolesAndOwnership } from "./scan"; -import { SafeTransactionData } from "./types"; -import { - ExecutionMode, - ManifestValidationError, - ResolvedDefaultAdminAction, - ResolvedOwnableAction, - ResolvedRoleManifest, -} from "./manifest"; -import { ActionSource, PreparedContractPlan, prepareContractPlans } from "./planner"; - -type HardhatRuntimeEnvironment = any; - -type OperationType = - | "transferOwnership" - | "grantDefaultAdmin" - | "renounceDefaultAdmin" - | "revokeDefaultAdmin"; - -type OperationStatus = "executed" | "queued" | "skipped" | "failed" | "planned"; - -export interface OperationReport { - readonly type: OperationType; - readonly mode: ExecutionMode; - readonly status: OperationStatus; - readonly details?: string; - readonly txHash?: string; -} - -export interface RemainingRoleInfo { - readonly role: string; - readonly hash: string; - readonly deployerHasRole: boolean; - readonly governanceHasRole: boolean; -} - -export interface ContractReportMetadata { - readonly ownableSource?: ActionSource; - readonly defaultAdminSource?: ActionSource; -} - -export interface ContractReport { - readonly deployment: string; - readonly alias?: string; - readonly address?: string; - readonly operations: OperationReport[]; - readonly remainingRoles: RemainingRoleInfo[]; - readonly notes?: string; - readonly errors: string[]; - readonly metadata?: ContractReportMetadata; -} - -export interface SafeBatchSummary { - readonly description: string; - readonly transactionCount: number; - readonly safeTxHash?: string; - readonly success: boolean; - readonly error?: string; -} - -export interface RunnerStatistics { - readonly totalContracts: number; - readonly autoIncludedOwnable: number; - readonly autoIncludedDefaultAdmin: number; - readonly overrideOwnable: number; - readonly overrideDefaultAdmin: number; -} - -export interface RunnerResult { - readonly contracts: ContractReport[]; - readonly totalDirectOperations: number; - readonly totalSafeOperations: number; - readonly safeBatch?: SafeBatchSummary; - readonly statistics?: RunnerStatistics; -} - -export interface RunManifestOptions { - readonly hre: HardhatRuntimeEnvironment; - readonly manifest: ResolvedRoleManifest; - readonly logger?: (message: string) => void; - readonly jsonOutputPath?: string; - readonly dryRun?: boolean; -} - -export async function runRoleManifest(options: RunManifestOptions): Promise { - const { hre, manifest } = options; - const log = options.logger ?? ((message: string) => sharedLogger.info(message)); - - const { ethers, deployments } = hre; - - let deployerSigner; - try { - deployerSigner = await ethers.getSigner(manifest.deployer); - } catch (error) { - throw new ManifestValidationError( - `Unable to obtain signer for deployer ${manifest.deployer}. Error: ${error instanceof Error ? error.message : String(error)}`, - ); - } - - let signerAddress: string; - try { - signerAddress = await deployerSigner.getAddress(); - } catch { - signerAddress = manifest.deployer; - } - - const signerAddressLower = signerAddress.toLowerCase(); - const dryRun = options.dryRun ?? false; - - log(`Scanning deployments for network ${hre.network.name}...`); - const scan = await scanRolesAndOwnership({ - hre, - deployer: manifest.deployer, - governanceMultisig: manifest.governance, - }); - - const rolesByDeployment = new Map(scan.rolesContracts.map((info) => [info.deploymentName, info])); - const ownableByDeployment = new Map(scan.ownableContracts.map((info) => [info.deploymentName, info])); - - const plans = prepareContractPlans({ manifest, rolesByDeployment, ownableByDeployment }); - - const safeTransactions: SafeTransactionData[] = []; - const contractReports: ContractReport[] = []; - let totalDirectOperations = 0; - let autoIncludedOwnable = 0; - let autoIncludedDefaultAdmin = 0; - let overrideOwnable = 0; - let overrideDefaultAdmin = 0; - - for (const plan of plans) { - const reportOperations: OperationReport[] = []; - const reportErrors: string[] = []; - const remainingRoles: RemainingRoleInfo[] = []; - - const deployment = await deployments.getOrNull(plan.deployment); - - if (!deployment) { - reportErrors.push(`Deployment ${plan.deployment} not found.`); - contractReports.push({ - deployment: plan.deployment, - alias: plan.alias, - operations: reportOperations, - remainingRoles, - notes: plan.notes, - errors: reportErrors, - metadata: buildMetadata(plan), - }); - continue; - } - - const contractName = plan.alias || deployment.contractName || plan.deployment; - const address = deployment.address; - - log(`\nProcessing ${contractName} (${address})`); - - const rolesInfo = rolesByDeployment.get(plan.deployment); - const ownableInfo = ownableByDeployment.get(plan.deployment); - - if (plan.ownable) { - if (plan.ownableSource === "auto") { - autoIncludedOwnable += 1; - } else if (plan.ownableSource === "override") { - overrideOwnable += 1; - } - - const result = await handleOwnableAction({ - hre, - deployment, - action: plan.ownable, - ownableInfo, - deployerSigner, - signerAddress, - signerAddressLower, - contractName, - dryRun, - log, - }); - - reportOperations.push(result); - if (result.status === "executed") { - totalDirectOperations += 1; - } - } - - if (plan.defaultAdmin) { - if (plan.defaultAdminSource === "auto") { - autoIncludedDefaultAdmin += 1; - } else if (plan.defaultAdminSource === "override") { - overrideDefaultAdmin += 1; - } - - const { operations, errors } = await handleDefaultAdminAction({ - hre, - deployment, - action: plan.defaultAdmin, - deployerSigner, - signerAddressLower, - contractName, - rolesInfo, - safeTransactions, - dryRun, - log, - }); - - reportOperations.push(...operations); - - for (const op of operations) { - if (op.status === "executed") { - totalDirectOperations += 1; - } - } - - reportErrors.push(...errors); - } - - if (rolesInfo) { - for (const role of rolesInfo.roles) { - if (role.name === "DEFAULT_ADMIN_ROLE") continue; - - const deployerHasRole = rolesInfo.rolesHeldByDeployer.some((r) => r.hash === role.hash); - const governanceHasRole = rolesInfo.rolesHeldByGovernance.some((r) => r.hash === role.hash); - - remainingRoles.push({ - role: role.name, - hash: role.hash, - deployerHasRole, - governanceHasRole, - }); - } - } - - contractReports.push({ - deployment: plan.deployment, - alias: plan.alias, - address, - operations: reportOperations, - remainingRoles, - notes: plan.notes, - errors: reportErrors, - metadata: buildMetadata(plan), - }); - } - - let safeBatchSummary: SafeBatchSummary | undefined; - - if (!dryRun && safeTransactions.length > 0) { - if (!manifest.safe) { - throw new ManifestValidationError( - `Safe transactions were requested but manifest.safe is not configured.`, - ); - } - - const description = manifest.safe.description || `Role revocations (${safeTransactions.length} operations)`; - - const safeManager = new SafeManager(hre, deployerSigner, { - safeConfig: manifest.safe, - signingMode: "none", - }); - - await safeManager.initialize(); - - const result = await safeManager.createBatchTransaction({ - transactions: safeTransactions, - description, - }); - - safeBatchSummary = { - description, - transactionCount: safeTransactions.length, - safeTxHash: result.safeTxHash, - success: result.success, - error: result.error, - }; - } - - const runnerResult: RunnerResult = { - contracts: contractReports, - totalDirectOperations, - totalSafeOperations: safeTransactions.length, - safeBatch: safeBatchSummary, - statistics: { - totalContracts: plans.length, - autoIncludedOwnable, - autoIncludedDefaultAdmin, - overrideOwnable, - overrideDefaultAdmin, - }, - }; - - const outputPath = options.jsonOutputPath || manifest.output?.json; - - if (outputPath) { - const fs = require("fs"); - const path = require("path"); - const resolvedPath = path.isAbsolute(outputPath) ? outputPath : path.join(process.cwd(), outputPath); - fs.writeFileSync(resolvedPath, JSON.stringify(runnerResult, null, 2)); - log(`Saved JSON report to ${resolvedPath}`); - } - - return runnerResult; -} - -function buildMetadata(plan: PreparedContractPlan): ContractReportMetadata | undefined { - const metadata: ContractReportMetadata = { - ...(plan.ownableSource ? { ownableSource: plan.ownableSource } : {}), - ...(plan.defaultAdminSource ? { defaultAdminSource: plan.defaultAdminSource } : {}), - }; - - return Object.keys(metadata).length > 0 ? metadata : undefined; -} - -interface OwnableActionContext { - readonly hre: HardhatRuntimeEnvironment; - readonly deployment: any; - readonly action: ResolvedOwnableAction; - readonly ownableInfo?: OwnableContractInfo; - readonly deployerSigner: any; - readonly signerAddress: string; - readonly signerAddressLower: string; - readonly contractName: string; - readonly dryRun: boolean; - readonly log: (message: string) => void; -} - -async function handleOwnableAction(context: OwnableActionContext): Promise { - const { hre, deployment, action, ownableInfo, deployerSigner, signerAddress, signerAddressLower, contractName, dryRun, log } = context; - - const mode = action.execution; - - if (!ownableInfo) { - return { - type: "transferOwnership", - mode, - status: "skipped", - details: "Contract does not expose Ownable owner().", - }; - } - - if (mode !== "direct") { - return { - type: "transferOwnership", - mode, - status: "skipped", - details: "Ownable transfers must be executed directly by the current owner.", - }; - } - - const contract = await hre.ethers.getContractAt(deployment.abi as any, deployment.address, deployerSigner); - - try { - const currentOwner: string = await contract.owner(); - if (currentOwner.toLowerCase() === action.newOwner.toLowerCase()) { - log(` Ownership already transferred to ${action.newOwner}; skipping.`); - return { - type: "transferOwnership", - mode, - status: "skipped", - details: "Target already owns the contract.", - }; - } - - if (currentOwner.toLowerCase() !== signerAddressLower) { - log(` Current owner is ${currentOwner}; deployer signer ${signerAddress} is required.`); - return { - type: "transferOwnership", - mode, - status: "skipped", - details: `Current owner ${currentOwner} differs from signer ${signerAddress}.`, - }; - } - - if (dryRun) { - log(` [dry-run] Would transfer ownership of ${contractName} to ${action.newOwner}`); - return { - type: "transferOwnership", - mode, - status: "planned", - details: `Would call transferOwnership(${action.newOwner})`, - }; - } - - log(` Transferring ownership of ${contractName} to ${action.newOwner}`); - const tx = await contract.transferOwnership(action.newOwner); - const receipt = await tx.wait(); - return { - type: "transferOwnership", - mode, - status: "executed", - txHash: receipt?.transactionHash, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log(` Failed to transfer ownership: ${message}`); - return { - type: "transferOwnership", - mode, - status: "failed", - details: message, - }; - } -} - -interface DefaultAdminActionContext { - readonly hre: HardhatRuntimeEnvironment; - readonly deployment: any; - readonly action: ResolvedDefaultAdminAction; - readonly deployerSigner: any; - readonly signerAddressLower: string; - readonly contractName: string; - readonly rolesInfo: RolesContractInfo | undefined; - readonly safeTransactions: SafeTransactionData[]; - readonly dryRun: boolean; - readonly log: (message: string) => void; -} - -async function handleDefaultAdminAction(context: DefaultAdminActionContext): Promise<{ - operations: OperationReport[]; - errors: string[]; -}> { - const { hre, deployment, action, deployerSigner, signerAddressLower, contractName, rolesInfo, safeTransactions, dryRun, log } = context; - const operations: OperationReport[] = []; - const errors: string[] = []; - - if (!rolesInfo || !rolesInfo.defaultAdminRoleHash) { - errors.push(`No DEFAULT_ADMIN_ROLE detected for ${contractName}.`); - return { operations, errors }; - } - - const roleHash = rolesInfo.defaultAdminRoleHash; - const contract = await hre.ethers.getContractAt(deployment.abi as any, deployment.address, deployerSigner); - - if (action.grantExecution === "direct") { - try { - const hasRole = await contract.hasRole(roleHash, action.newAdmin); - if (!hasRole) { - if (dryRun) { - log(` [dry-run] Would grant DEFAULT_ADMIN_ROLE to ${action.newAdmin}`); - operations.push({ - type: "grantDefaultAdmin", - mode: "direct", - status: "planned", - details: `Would call grantRole(DEFAULT_ADMIN_ROLE, ${action.newAdmin})`, - }); - } else { - log(` Granting DEFAULT_ADMIN_ROLE to ${action.newAdmin}`); - const tx = await contract.grantRole(roleHash, action.newAdmin); - const receipt = await tx.wait(); - operations.push({ - type: "grantDefaultAdmin", - mode: "direct", - status: "executed", - txHash: receipt?.transactionHash, - }); - } - } else { - log(` ${action.newAdmin} already has DEFAULT_ADMIN_ROLE; skipping grant.`); - operations.push({ - type: "grantDefaultAdmin", - mode: "direct", - status: "skipped", - details: "New admin already holds the role.", - }); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log(` Failed to grant DEFAULT_ADMIN_ROLE: ${message}`); - operations.push({ - type: "grantDefaultAdmin", - mode: "direct", - status: "failed", - details: message, - }); - } - } - - const removal = action.removal; - if (!removal) { - log(" Removal step disabled; deployer will retain DEFAULT_ADMIN_ROLE until handled separately."); - return { operations, errors }; - } - - const iface = new Interface(deployment.abi as any); - const targetAddress = removal.address; - - if (removal.execution === "direct") { - try { - if (removal.strategy === "renounce") { - if (targetAddress.toLowerCase() !== signerAddressLower) { - const detail = `Renounce requires signer ${targetAddress}. Update manifest or use Safe revoke.`; - log(` ${detail}`); - operations.push({ - type: "renounceDefaultAdmin", - mode: "direct", - status: "skipped", - details: detail, - }); - } else { - const hasRole = await contract.hasRole(roleHash, targetAddress); - if (!hasRole) { - log(` ${targetAddress} does not hold DEFAULT_ADMIN_ROLE; skipping renounce.`); - operations.push({ - type: "renounceDefaultAdmin", - mode: "direct", - status: "skipped", - details: "Role already removed from deployer.", - }); - } else { - if (dryRun) { - log(` [dry-run] Would renounce DEFAULT_ADMIN_ROLE from ${targetAddress}`); - operations.push({ - type: "renounceDefaultAdmin", - mode: "direct", - status: "planned", - details: `Would call renounceRole(DEFAULT_ADMIN_ROLE, ${targetAddress})`, - }); - } else { - log(` Renouncing DEFAULT_ADMIN_ROLE from ${targetAddress}`); - const tx = await contract.renounceRole(roleHash, targetAddress); - const receipt = await tx.wait(); - operations.push({ - type: "renounceDefaultAdmin", - mode: "direct", - status: "executed", - txHash: receipt?.transactionHash, - }); - } - } - } - } else { - const hasRole = await contract.hasRole(roleHash, targetAddress); - if (!hasRole) { - log(` ${targetAddress} does not hold DEFAULT_ADMIN_ROLE; skipping revoke.`); - operations.push({ - type: "revokeDefaultAdmin", - mode: "direct", - status: "skipped", - details: "Role already removed.", - }); - } else { - if (dryRun) { - log(` [dry-run] Would revoke DEFAULT_ADMIN_ROLE from ${targetAddress}`); - operations.push({ - type: "revokeDefaultAdmin", - mode: "direct", - status: "planned", - details: `Would call revokeRole(DEFAULT_ADMIN_ROLE, ${targetAddress})`, - }); - } else { - log(` Revoking DEFAULT_ADMIN_ROLE from ${targetAddress}`); - const tx = await contract.revokeRole(roleHash, targetAddress); - const receipt = await tx.wait(); - operations.push({ - type: "revokeDefaultAdmin", - mode: "direct", - status: "executed", - txHash: receipt?.transactionHash, - }); - } - } - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log(` Failed to remove DEFAULT_ADMIN_ROLE: ${message}`); - operations.push({ - type: removal.strategy === "renounce" ? "renounceDefaultAdmin" : "revokeDefaultAdmin", - mode: "direct", - status: "failed", - details: message, - }); - } - } else { - if (removal.strategy !== "revoke") { - const detail = "Safe execution requires the revoke strategy."; - log(` ${detail}`); - operations.push({ - type: "revokeDefaultAdmin", - mode: "safe", - status: "skipped", - details: detail, - }); - } else { - const hasRole = await contract.hasRole(roleHash, targetAddress); - if (!hasRole) { - log(` ${targetAddress} does not hold DEFAULT_ADMIN_ROLE; skipping Safe revoke.`); - operations.push({ - type: "revokeDefaultAdmin", - mode: "safe", - status: "skipped", - details: "Role already removed.", - }); - } else { - if (dryRun) { - log(` [dry-run] Would queue Safe revokeRole for ${targetAddress}`); - operations.push({ - type: "revokeDefaultAdmin", - mode: "safe", - status: "planned", - details: `Would queue revokeRole for ${targetAddress}`, - }); - } else { - log(` Queueing Safe revokeRole for ${targetAddress}`); - safeTransactions.push({ - to: deployment.address, - value: "0", - data: iface.encodeFunctionData("revokeRole", [roleHash, targetAddress]), - }); - operations.push({ - type: "revokeDefaultAdmin", - mode: "safe", - status: "queued", - details: `Queued revokeRole for ${targetAddress}`, - }); - } - } - } - } - - return { operations, errors }; -} diff --git a/.shared/lib/roles/scan.ts b/.shared/lib/roles/scan.ts index 35e5b5d..9bb9cfb 100644 --- a/.shared/lib/roles/scan.ts +++ b/.shared/lib/roles/scan.ts @@ -1,60 +1,202 @@ import { HardhatRuntimeEnvironment } from "hardhat/types"; import { AbiItem } from "web3-utils"; +import { Interface } from "@ethersproject/abi"; import * as fs from "fs"; import * as path from "path"; -// Type guards for ABI fragments -function isAbiFunctionFragment( - item: AbiItem, -): item is AbiItem & { type: "function"; name: string; stateMutability?: string; inputs?: any[]; outputs?: any[] } { - return item.type === "function"; +import { + DEFAULT_MULTICALL3_ADDRESS, + executeMulticallBatches, + MulticallRequest, + MulticallResult, +} from "./multicall"; + +type FunctionAbiItem = AbiItem & { + readonly type: "function"; + readonly name: string; + readonly stateMutability?: string; + readonly inputs?: { readonly type: string }[]; + readonly outputs?: { readonly type: string }[]; +}; + +function isAbiFunctionFragment(item: AbiItem): item is FunctionAbiItem { + const fragment = item as { type?: string; name?: unknown }; + return fragment.type === "function" && typeof fragment.name === "string"; +} + +interface DeploymentSummary { + readonly deploymentName: string; + readonly contractName: string; + readonly address: string; + readonly abi: AbiItem[]; +} + +interface RoleContractContext extends DeploymentSummary { + readonly iface: Interface; + readonly hasRoleFragment: FunctionAbiItem; + readonly roleConstantFragments: FunctionAbiItem[]; + readonly roleHashes: Map; +} + +interface RoleConstantTask { + readonly context: RoleContractContext; + readonly functionName: string; + readonly request: MulticallRequest; +} + +interface HasRoleTask { + readonly context: RoleContractContext; + readonly role: RoleInfo; + readonly holder: "deployer" | "governance"; + readonly request: MulticallRequest; } export interface RoleInfo { - name: string; - hash: string; + readonly name: string; + readonly hash: string; } export interface RolesContractInfo { - deploymentName: string; - name: string; - address: string; - abi: AbiItem[]; - roles: RoleInfo[]; - rolesHeldByDeployer: RoleInfo[]; - rolesHeldByGovernance: RoleInfo[]; - defaultAdminRoleHash?: string; + readonly deploymentName: string; + readonly name: string; + readonly address: string; + readonly abi: AbiItem[]; + readonly roles: RoleInfo[]; + readonly rolesHeldByDeployer: RoleInfo[]; + readonly rolesHeldByGovernance: RoleInfo[]; + readonly defaultAdminRoleHash?: string; governanceHasDefaultAdmin: boolean; } export interface OwnableContractInfo { - deploymentName: string; - name: string; - address: string; - abi: AbiItem[]; - owner: string; - deployerIsOwner: boolean; - governanceIsOwner: boolean; + readonly deploymentName: string; + readonly name: string; + readonly address: string; + readonly abi: AbiItem[]; + readonly owner: string; + readonly deployerIsOwner: boolean; + readonly governanceIsOwner: boolean; +} + +export interface ScanTelemetry { + startedAt: number; + completedAt: number; + durationMs: number; + deploymentsEvaluated: number; + rolesContractsEvaluated: number; + ownableContractsEvaluated: number; + multicall: { + supported: boolean; + batchesExecuted: number; + requestsAttempted: number; + fallbacks: number; + }; + directCalls: { + roleConstants: number; + hasRole: number; + owner: number; + }; } export interface ScanResult { - rolesContracts: RolesContractInfo[]; - ownableContracts: OwnableContractInfo[]; + readonly rolesContracts: RolesContractInfo[]; + readonly ownableContracts: OwnableContractInfo[]; + readonly stats: ScanTelemetry; } export interface ScanOptions { - hre: HardhatRuntimeEnvironment; - deployer: string; - governanceMultisig: string; - deploymentsPath?: string; - logger?: (message: string) => void; + readonly hre: HardhatRuntimeEnvironment; + readonly deployer: string; + readonly governanceMultisig: string; + readonly deploymentsPath?: string; + readonly logger?: (message: string) => void; + readonly multicallAddress?: string; +} + +function detectHasRoleFragment(abi: AbiItem[]): FunctionAbiItem | undefined { + return abi.find( + (item): item is FunctionAbiItem => + isAbiFunctionFragment(item) && + item.name === "hasRole" && + (item.inputs?.length ?? 0) === 2 && + item.inputs?.[0].type === "bytes32" && + item.inputs?.[1].type === "address" && + (item.outputs?.length ?? 0) === 1 && + item.outputs?.[0].type === "bool", + ); +} + +function detectOwnableFragment(abi: AbiItem[]): FunctionAbiItem | undefined { + return abi.find( + (item): item is FunctionAbiItem => + isAbiFunctionFragment(item) && + item.name === "owner" && + (item.inputs?.length ?? 0) === 0 && + (item.outputs?.length ?? 0) === 1 && + item.outputs?.[0].type === "address", + ); +} + +function detectRoleConstantFragments(abi: AbiItem[]): FunctionAbiItem[] { + return abi + .filter(isAbiFunctionFragment) + .filter( + (item) => + (item.stateMutability === "view" || item.stateMutability === "pure") && + (item.name === "DEFAULT_ADMIN_ROLE" || item.name.endsWith("_ROLE")) && + (item.inputs?.length ?? 0) === 0 && + (item.outputs?.length ?? 0) === 1 && + item.outputs?.[0].type === "bytes32", + ) + .map((item) => item as FunctionAbiItem); +} + +async function decodeViaProvider( + hre: HardhatRuntimeEnvironment, + context: RoleContractContext, + functionName: string, + args: readonly unknown[], +): Promise { + const iface = context.iface; + const data = iface.encodeFunctionData(functionName, args); + try { + const returnData = await (hre as any).ethers.provider.call({ + to: context.address, + data, + }); + return { success: true, returnData }; + } catch { + return null; + } } export async function scanRolesAndOwnership(options: ScanOptions): Promise { const { hre, deployer, governanceMultisig, logger } = options; const ethers = (hre as any).ethers; const network = (hre as any).network; - const log = logger || (() => {}); + const log = logger ?? (() => {}); + const multicallAddress = options.multicallAddress ?? DEFAULT_MULTICALL3_ADDRESS; + + const startedAt = Date.now(); + const telemetry: ScanTelemetry = { + startedAt, + completedAt: startedAt, + durationMs: 0, + deploymentsEvaluated: 0, + rolesContractsEvaluated: 0, + ownableContractsEvaluated: 0, + multicall: { + supported: true, + batchesExecuted: 0, + requestsAttempted: 0, + fallbacks: 0, + }, + directCalls: { + roleConstants: 0, + hasRole: 0, + owner: 0, + }, + }; const deploymentsPath = options.deploymentsPath || path.join((hre as any).config.paths.deployments, network.name); if (!fs.existsSync(deploymentsPath)) { @@ -65,134 +207,351 @@ export async function scanRolesAndOwnership(options: ScanOptions): Promise f.endsWith(".json") && f !== ".migrations.json" && f !== "solcInputs"); - const rolesContracts: RolesContractInfo[] = []; - const ownableContracts: OwnableContractInfo[] = []; - + const deployments: DeploymentSummary[] = []; for (const filename of deploymentFiles) { try { const artifactPath = path.join(deploymentsPath, filename); const deployment = JSON.parse(fs.readFileSync(artifactPath, "utf-8")); - const abi: AbiItem[] = deployment.abi; - const contractAddress: string = deployment.address; - const deploymentName: string = filename.replace(".json", ""); - const contractName: string = deployment.contractName || deploymentName; - - // Detect AccessControl - const hasRoleFn = abi.find( - (item) => - isAbiFunctionFragment(item) && - item.name === "hasRole" && - item.inputs?.length === 2 && - item.inputs[0].type === "bytes32" && - item.inputs[1].type === "address" && - item.outputs?.length === 1 && - item.outputs[0].type === "bool", - ); + deployments.push({ + deploymentName: filename.replace(".json", ""), + contractName: deployment.contractName ?? filename.replace(".json", ""), + address: deployment.address, + abi: deployment.abi as AbiItem[], + }); + } catch { + // ignore malformed deployment files + } + } - if (hasRoleFn) { - log(` Contract ${contractName} has a hasRole function.`); - log(`\nChecking roles for contract: ${contractName} at ${contractAddress}`); - const roles: RoleInfo[] = []; - - // Collect role constants as view functions returning bytes32 - for (const item of abi) { - if ( - isAbiFunctionFragment(item) && - item.stateMutability === "view" && - ((item.name?.endsWith("_ROLE") as boolean) || item.name === "DEFAULT_ADMIN_ROLE") && - (item.inputs?.length ?? 0) === 0 && - item.outputs?.length === 1 && - item.outputs[0].type === "bytes32" - ) { - try { - const contract = await ethers.getContractAt(abi as any, contractAddress); - const roleHash: string = await (contract as any)[item.name](); - roles.push({ name: item.name!, hash: roleHash }); - log(` - Found role: ${item.name} with hash ${roleHash}`); - } catch { - // ignore role hash failures for this item - } - } + telemetry.deploymentsEvaluated = deployments.length; + + const roleContexts: RoleContractContext[] = []; + const ownableCandidates: { summary: DeploymentSummary; fragment: FunctionAbiItem }[] = []; + + for (const summary of deployments) { + const hasRoleFragment = detectHasRoleFragment(summary.abi); + const ownableFragment = detectOwnableFragment(summary.abi); + + if (hasRoleFragment) { + const roleConstantFragments = detectRoleConstantFragments(summary.abi); + roleContexts.push({ + ...summary, + iface: new Interface(summary.abi as any), + hasRoleFragment, + roleConstantFragments, + roleHashes: new Map(), + }); + } + + if (ownableFragment) { + ownableCandidates.push({ summary, fragment: ownableFragment }); + } + } + + telemetry.rolesContractsEvaluated = roleContexts.length; + telemetry.ownableContractsEvaluated = ownableCandidates.length; + + const constantTasks: RoleConstantTask[] = []; + for (const context of roleContexts) { + for (const fragment of context.roleConstantFragments) { + const functionName = fragment.name; + const request: MulticallRequest = { + target: context.address, + allowFailure: true, + callData: context.iface.encodeFunctionData(functionName, []), + }; + constantTasks.push({ context, functionName, request }); + } + } + + const recordRoleHash = (context: RoleContractContext, roleName: string, hash: string) => { + if (!context.roleHashes.has(roleName)) { + context.roleHashes.set(roleName, hash); + } + }; + + if (constantTasks.length > 0) { + log( + `Fetching ${constantTasks.length} role hash constants across ${roleContexts.length} AccessControl contracts via multicall.`, + ); + const constantBatch = await executeMulticallBatches(hre as any, constantTasks.map((task) => task.request), { + address: multicallAddress, + logger: log, + onBatchComplete: ({ index, total }) => { + log(` - role hash batch ${index}/${total} complete`); + }, + }); + + const fallbackTasks: RoleConstantTask[] = []; + + if (constantBatch === null) { + telemetry.multicall.supported = false; + telemetry.multicall.fallbacks += constantTasks.length; + fallbackTasks.push(...constantTasks); + } else { + telemetry.multicall.requestsAttempted += constantTasks.length; + telemetry.multicall.batchesExecuted += constantBatch.batchesExecuted; + + for (let index = 0; index < constantTasks.length; index += 1) { + const task = constantTasks[index]; + const result = constantBatch.results[index]; + + if (!result || !result.success) { + fallbackTasks.push(task); + continue; } - // Build role ownership information - const contract = await ethers.getContractAt(abi as any, contractAddress); - const rolesHeldByDeployer: RoleInfo[] = []; - const rolesHeldByGovernance: RoleInfo[] = []; + try { + const decoded = task.context.iface.decodeFunctionResult(task.functionName, result.returnData); + recordRoleHash(task.context, task.functionName, String(decoded[0])); + } catch { + fallbackTasks.push(task); + } + } + + telemetry.multicall.fallbacks += fallbackTasks.length; + } + + if (fallbackTasks.length > 0) { + telemetry.directCalls.roleConstants += fallbackTasks.length; + + await Promise.all( + fallbackTasks.map(async (task) => { + const individualResult = await decodeViaProvider(hre, task.context, task.functionName, []); + if (!individualResult || !individualResult.success) { + return; + } - for (const role of roles) { try { - if (await (contract as any).hasRole(role.hash, deployer)) { - rolesHeldByDeployer.push(role); - log(` Deployer HAS role ${role.name}`); - } - } catch {} + const decoded = task.context.iface.decodeFunctionResult(task.functionName, individualResult.returnData); + recordRoleHash(task.context, task.functionName, String(decoded[0])); + } catch { + // ignore failures in fallback decoding + } + }), + ); + } + } + + const roleContracts: RolesContractInfo[] = []; + + const hasRoleTasks: HasRoleTask[] = []; + for (const context of roleContexts) { + const roles: RoleInfo[] = Array.from(context.roleHashes.entries()).map(([name, hash]) => ({ + name, + hash, + })); + + const defaultAdmin = roles.find((role) => role.name === "DEFAULT_ADMIN_ROLE"); + const contractRecord: RolesContractInfo = { + deploymentName: context.deploymentName, + name: context.contractName, + address: context.address, + abi: context.abi, + roles, + rolesHeldByDeployer: [], + rolesHeldByGovernance: [], + defaultAdminRoleHash: defaultAdmin?.hash, + governanceHasDefaultAdmin: false, + }; + + roleContracts.push(contractRecord); + + for (const role of roles) { + hasRoleTasks.push({ + context, + role, + holder: "deployer", + request: { + target: context.address, + allowFailure: true, + callData: context.iface.encodeFunctionData("hasRole", [role.hash, deployer]), + }, + }); + + hasRoleTasks.push({ + context, + role, + holder: "governance", + request: { + target: context.address, + allowFailure: true, + callData: context.iface.encodeFunctionData("hasRole", [role.hash, governanceMultisig]), + }, + }); + } + } + + const rolesByContract = new Map(); + for (const contract of roleContracts) { + rolesByContract.set(contract.address.toLowerCase(), contract); + } + + const holderSet = { + deployer: new Map>(), + governance: new Map>(), + }; + + const addHeldRole = (contract: RolesContractInfo, holder: "deployer" | "governance", role: RoleInfo) => { + const registry = holderSet[holder]; + const key = contract.address.toLowerCase(); + const set = registry.get(key) ?? new Set(); + if (!registry.has(key)) { + registry.set(key, set); + } + if (set.has(role.hash)) { + return; + } + set.add(role.hash); + if (holder === "deployer") { + contract.rolesHeldByDeployer.push(role); + } else { + contract.rolesHeldByGovernance.push(role); + } + }; + + if (hasRoleTasks.length > 0) { + let useMulticall = telemetry.multicall.supported; + const fallbackTasks: HasRoleTask[] = []; + let attemptedWithMulticall = false; + + if (useMulticall) { + log(`Checking role holders via multicall (${hasRoleTasks.length} hasRole calls across ${roleContracts.length} contracts).`); + const hasRoleBatch = await executeMulticallBatches(hre as any, hasRoleTasks.map((task) => task.request), { + address: multicallAddress, + logger: log, + onBatchComplete: ({ index, total }) => { + log(` - hasRole batch ${index}/${total} complete`); + }, + }); + + if (hasRoleBatch === null) { + telemetry.multicall.supported = false; + useMulticall = false; + telemetry.multicall.fallbacks += hasRoleTasks.length; + fallbackTasks.push(...hasRoleTasks); + } else { + attemptedWithMulticall = true; + telemetry.multicall.requestsAttempted += hasRoleTasks.length; + telemetry.multicall.batchesExecuted += hasRoleBatch.batchesExecuted; + + for (let index = 0; index < hasRoleTasks.length; index += 1) { + const task = hasRoleTasks[index]; + const result = hasRoleBatch.results[index]; + + if (!result || !result.success) { + fallbackTasks.push(task); + continue; + } try { - if (await (contract as any).hasRole(role.hash, governanceMultisig)) { - rolesHeldByGovernance.push(role); - log(` Governance HAS role ${role.name}`); + const decoded = task.context.iface.decodeFunctionResult("hasRole", result.returnData); + if (Boolean(decoded[0])) { + const contract = rolesByContract.get(task.context.address.toLowerCase()); + if (contract) { + addHeldRole(contract, task.holder, task.role); + } } - } catch {} + } catch { + fallbackTasks.push(task); + } } - const defaultAdmin = roles.find((r) => r.name === "DEFAULT_ADMIN_ROLE"); - let governanceHasDefaultAdmin = false; - if (defaultAdmin) { - try { - governanceHasDefaultAdmin = await (contract as any).hasRole(defaultAdmin.hash, governanceMultisig); - log(` governanceHasDefaultAdmin: ${governanceHasDefaultAdmin}`); - } catch {} - } + telemetry.multicall.fallbacks += fallbackTasks.length; + } + } - rolesContracts.push({ - deploymentName, - name: contractName, - address: contractAddress, - abi, - roles, - rolesHeldByDeployer, - rolesHeldByGovernance, - defaultAdminRoleHash: defaultAdmin?.hash, - governanceHasDefaultAdmin, - }); + if (!useMulticall) { + fallbackTasks.splice(0, fallbackTasks.length, ...hasRoleTasks); + } + + if (fallbackTasks.length > 0) { + if (attemptedWithMulticall || !useMulticall) { + telemetry.directCalls.hasRole += fallbackTasks.length; } - // Detect Ownable (owner() view returns address) - const ownerFn = abi.find( - (item) => - isAbiFunctionFragment(item) && - item.name === "owner" && - (item.inputs?.length ?? 0) === 0 && - item.outputs?.length === 1 && - item.outputs[0].type === "address", + await Promise.all( + fallbackTasks.map(async (task) => { + const callResult = await decodeViaProvider( + hre, + task.context, + "hasRole", + [task.role.hash, task.holder === "deployer" ? deployer : governanceMultisig], + ); + + if (!callResult || !callResult.success) { + return; + } + + try { + const decoded = task.context.iface.decodeFunctionResult("hasRole", callResult.returnData); + if (Boolean(decoded[0])) { + const contract = rolesByContract.get(task.context.address.toLowerCase()); + if (contract) { + addHeldRole(contract, task.holder, task.role); + } + } + } catch { + // ignore decode errors in fallback path + } + }), ); + } + } - if (ownerFn) { - try { - const contract = await ethers.getContractAt(abi as any, contractAddress); - const owner: string = await (contract as any).owner(); - const ownerLower = owner.toLowerCase(); - const deployerLower = deployer?.toLowerCase?.(); - const governanceLower = governanceMultisig?.toLowerCase?.(); - log(` Contract ${contractName} appears to be Ownable. owner=${owner}`); - ownableContracts.push({ - deploymentName, - name: contractName, - address: contractAddress, - abi, - owner, - deployerIsOwner: deployerLower ? ownerLower === deployerLower : false, - governanceIsOwner: governanceLower ? ownerLower === governanceLower : false, - }); - } catch (error) { - log(` Failed to resolve owner for ${contractName}: ${error}`); - } - } + for (const contract of roleContracts) { + const defaultAdminHash = contract.defaultAdminRoleHash; + if (!defaultAdminHash) { + continue; + } + + const governanceHasDefaultAdmin = contract.rolesHeldByGovernance.some( + (role) => role.hash.toLowerCase() === defaultAdminHash.toLowerCase(), + ); + contract.governanceHasDefaultAdmin = governanceHasDefaultAdmin; + } + + const ownableContracts: OwnableContractInfo[] = []; + + if (ownableCandidates.length > 0) { + log(`Resolving owners for ${ownableCandidates.length} Ownable contracts.`); + } + + for (const candidate of ownableCandidates) { + const iface = new Interface(candidate.summary.abi as any); + const callData = iface.encodeFunctionData("owner", []); + try { + const returnData = await ethers.provider.call({ + to: candidate.summary.address, + data: callData, + }); + telemetry.directCalls.owner += 1; + const decoded = iface.decodeFunctionResult("owner", returnData); + const owner = String(decoded[0]); + const ownerLower = owner.toLowerCase(); + const deployerLower = deployer.toLowerCase(); + const governanceLower = governanceMultisig.toLowerCase(); + ownableContracts.push({ + deploymentName: candidate.summary.deploymentName, + name: candidate.summary.contractName, + address: candidate.summary.address, + abi: candidate.summary.abi, + owner, + deployerIsOwner: ownerLower === deployerLower, + governanceIsOwner: ownerLower === governanceLower, + }); } catch { - // ignore malformed artifact + // ignore failures to read owner } } - return { rolesContracts, ownableContracts }; + const completedAt = Date.now(); + telemetry.completedAt = completedAt; + telemetry.durationMs = completedAt - startedAt; + + return { + rolesContracts: roleContracts, + ownableContracts, + stats: telemetry, + }; } diff --git a/.shared/package.json b/.shared/package.json index 453600a..5411d21 100644 --- a/.shared/package.json +++ b/.shared/package.json @@ -28,7 +28,8 @@ "setup": "ts-node scripts/setup.ts", "test": "tsc --noEmit && ts-node scripts/tests/run.ts", "roles:scan": "ts-node scripts/roles/scan-roles.ts", - "roles:transfer": "ts-node scripts/roles/transfer-roles.ts", + "roles:grant": "ts-node scripts/roles/grant-default-admin.ts", + "roles:transfer": "ts-node scripts/roles/transfer-ownership.ts", "roles:revoke": "ts-node scripts/roles/revoke-roles.ts" }, "keywords": [ diff --git a/.shared/scripts/roles/grant-default-admin.ts b/.shared/scripts/roles/grant-default-admin.ts new file mode 100644 index 0000000..1a91a95 --- /dev/null +++ b/.shared/scripts/roles/grant-default-admin.ts @@ -0,0 +1,366 @@ +#!/usr/bin/env ts-node + +import { Command } from "commander"; +import * as readline from "readline"; + +import { logger } from "../../lib/logger"; +import { scanRolesAndOwnership } from "../../lib/roles/scan"; +import { loadRoleManifest, resolveRoleManifest } from "../../lib/roles/manifest"; +import { prepareContractPlans, isDeploymentExcluded } from "../../lib/roles/planner"; + +type ScanResult = Awaited>; + +type ManifestSource = "auto" | "override"; + +interface GrantTarget { + readonly deployment: string; + readonly contractName: string; + readonly address: string; + readonly manifestSource: ManifestSource; + readonly defaultAdminRoleHash: string; + readonly rolesInfo: ScanResult["rolesContracts"][number]; +} + +interface ContractRef { + readonly deployment: string; + readonly contractName: string; + readonly address: string; + readonly manifestSource: ManifestSource; +} + +interface Summary { + readonly granted: GrantTarget[]; + readonly skippedExisting: ContractRef[]; + readonly skippedNoPermission: ContractRef[]; + readonly skippedMissingRole: ContractRef[]; + readonly manifestOptOuts: { + readonly deployment: string; + readonly contractName: string; + readonly address: string; + readonly reason: string; + }[]; + readonly failures: { + readonly target: GrantTarget; + readonly error: string; + }[]; +} + +async function promptYesNo(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer: string = await new Promise((resolve) => rl.question(question, resolve)); + rl.close(); + return ["y", "yes"].includes(answer.trim().toLowerCase()); +} + +async function main(): Promise { + const program = new Command(); + + program + .description("Grant DEFAULT_ADMIN_ROLE from the deployer to the governance multisig based on the shared manifest.") + .requiredOption("-m, --manifest ", "Path to the role manifest JSON") + .requiredOption("-n, --network ", "Hardhat network to target") + .option("--deployments-dir ", "Path to deployments directory (defaults to hardhat configured path)") + .option("--hardhat-config ", "Path to hardhat.config.ts (defaults to ./hardhat.config.ts)") + .option("--dry-run", "Simulate the grants without sending transactions") + .option("--yes", "Skip confirmation prompt") + .option("--json-output ", "Write summary report JSON to path (or stdout when set to '-')"); + + program.parse(process.argv); + const options = program.opts(); + + process.env.HARDHAT_NETWORK = options.network; + + if (options.hardhatConfig) { + process.env.HARDHAT_CONFIG = options.hardhatConfig; + process.env.HARDHAT_USER_CONFIG = options.hardhatConfig; + } + + try { + const hre = require("hardhat"); + const manifest = resolveRoleManifest(loadRoleManifest(options.manifest)); + const dryRun = Boolean(options.dryRun); + + const rolesResult = await scanRolesAndOwnership({ + hre, + deployer: manifest.deployer, + governanceMultisig: manifest.governance, + deploymentsPath: options.deploymentsDir, + logger: (message: string) => logger.info(message), + }); + + const rolesByDeployment = new Map(rolesResult.rolesContracts.map((info) => [info.deploymentName, info])); + const plans = prepareContractPlans({ + manifest, + rolesByDeployment, + ownableByDeployment: new Map(), + }); + + const actionable: GrantTarget[] = []; + const skippedExisting: ContractRef[] = []; + const skippedNoPermission: ContractRef[] = []; + const skippedMissingRole: ContractRef[] = []; + const manifestOptOuts: Summary["manifestOptOuts"] = []; + const failures: Summary["failures"] = []; + + const planByDeployment = new Map(plans.map((plan) => [plan.deployment, plan])); + + for (const plan of plans) { + if (!plan.defaultAdmin) { + continue; + } + + const rolesInfo = rolesByDeployment.get(plan.deployment); + const manifestSource: ManifestSource = (plan.defaultAdminSource ?? "auto") as ManifestSource; + + if (!rolesInfo || !rolesInfo.defaultAdminRoleHash) { + skippedMissingRole.push({ + deployment: plan.deployment, + contractName: plan.alias ?? rolesInfo?.name ?? plan.deployment, + address: rolesInfo?.address ?? "unknown", + manifestSource, + }); + continue; + } + + const contractName = rolesInfo.name; + const address = rolesInfo.address; + const defaultAdminRoleHash = rolesInfo.defaultAdminRoleHash; + + const deployerHasAdmin = rolesInfo.rolesHeldByDeployer.some( + (role) => role.hash.toLowerCase() === defaultAdminRoleHash.toLowerCase(), + ); + const governanceHasAdmin = rolesInfo.governanceHasDefaultAdmin; + + const target: GrantTarget = { + deployment: plan.deployment, + contractName, + address, + manifestSource: (plan.defaultAdminSource ?? "auto") as ManifestSource, + defaultAdminRoleHash, + rolesInfo, + }; + + if (governanceHasAdmin) { + skippedExisting.push({ + deployment: target.deployment, + contractName, + address, + manifestSource, + }); + continue; + } + + if (!deployerHasAdmin) { + skippedNoPermission.push({ + deployment: target.deployment, + contractName, + address, + manifestSource, + }); + continue; + } + + actionable.push(target); + } + + // Identify manifest opt-outs for awareness. + for (const rolesInfo of rolesResult.rolesContracts) { + const defaultAdminHeldByDeployer = rolesInfo.rolesHeldByDeployer.some( + (role) => role.name === "DEFAULT_ADMIN_ROLE", + ); + if (!defaultAdminHeldByDeployer) { + continue; + } + + const plan = planByDeployment.get(rolesInfo.deploymentName); + if (plan?.defaultAdmin) { + continue; + } + + if ( + isDeploymentExcluded(manifest, rolesInfo.deploymentName, "defaultAdmin") || + manifest.overrides.some( + (override) => override.deployment === rolesInfo.deploymentName && override.defaultAdmin?.enabled === false, + ) + ) { + manifestOptOuts.push({ + deployment: rolesInfo.deploymentName, + contractName: rolesInfo.name, + address: rolesInfo.address, + reason: "Manifest opt-out (exclusion or disabled override)", + }); + } + } + + logger.info("\n=== Grant Plan ==="); + logger.info(`Actionable grants: ${actionable.length}`); + logger.info(`Already satisfied: ${skippedExisting.length}`); + logger.info(`Missing deployer permission: ${skippedNoPermission.length}`); + logger.info(`Missing DEFAULT_ADMIN_ROLE ABI/scan data: ${skippedMissingRole.length}`); + logger.info(`Manifest opt-outs: ${manifestOptOuts.length}`); + + if (actionable.length > 0) { + logger.info(`\nGranting DEFAULT_ADMIN_ROLE to governance multisig ${manifest.governance}:`); + actionable.forEach((target, index) => { + logger.info( + `- [${index + 1}/${actionable.length}] ${target.contractName} (${target.address}) :: grant from deployer -> ${manifest.governance} (${target.manifestSource})`, + ); + }); + } + + if (skippedExisting.length > 0) { + logger.info("\nAlready satisfied (governance already holds DEFAULT_ADMIN_ROLE):"); + skippedExisting.forEach((entry) => { + logger.info(`- ${entry.contractName} (${entry.address}) [${entry.manifestSource}]`); + }); + } + + if (skippedNoPermission.length > 0) { + logger.warn("\nMissing deployer permission (deployer does not hold DEFAULT_ADMIN_ROLE):"); + skippedNoPermission.forEach((entry) => { + logger.warn(`- ${entry.contractName} (${entry.address}) [${entry.manifestSource}]`); + }); + } + + if (skippedMissingRole.length > 0) { + logger.warn("\nMissing DEFAULT_ADMIN_ROLE ABI or scan data (manual investigation required):"); + skippedMissingRole.forEach((entry) => { + logger.warn(`- ${entry.contractName} (${entry.address}) [${entry.manifestSource}]`); + }); + } + + if (manifestOptOuts.length > 0) { + logger.info("\nManifest opt-outs (not processed by design):"); + manifestOptOuts.forEach((opt) => { + logger.info(`- ${opt.contractName} (${opt.address}) :: ${opt.reason}`); + }); + } + + if (actionable.length === 0) { + logger.success("\nNo grants required. Governance already holds DEFAULT_ADMIN_ROLE (or manifest opts out)."); + await maybeEmitJson(options.jsonOutput, { + status: "no-action", + granted: [], + skippedExisting, + skippedNoPermission, + skippedMissingRole, + manifestOptOuts, + failures, + }); + return; + } + + if (!dryRun && !options.yes) { + const confirmed = await promptYesNo("\nProceed with granting DEFAULT_ADMIN_ROLE? (yes/no): "); + if (!confirmed) { + logger.info("Aborted by user."); + return; + } + } + + const signer = await hre.ethers.getSigner(manifest.deployer); + const resultsGranted: GrantTarget[] = []; + + for (let index = 0; index < actionable.length; index += 1) { + const target = actionable[index]; + logger.info( + `\n[${index + 1}/${actionable.length}] Granting DEFAULT_ADMIN_ROLE on ${target.contractName} (${target.address})`, + ); + + try { + const contract = await hre.ethers.getContractAt(target.rolesInfo.abi as any, target.address, signer); + + if (dryRun) { + logger.info(" [dry-run] Would call grantRole(DEFAULT_ADMIN_ROLE, governance)"); + resultsGranted.push(target); + continue; + } + + const tx = await contract.grantRole(target.defaultAdminRoleHash, manifest.governance); + const receipt = await tx.wait(); + const txHash = receipt?.hash ?? tx.hash ?? "unknown"; + logger.info(` ✅ Transaction hash: ${txHash}`); + resultsGranted.push(target); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(` ❌ Failed to grant DEFAULT_ADMIN_ROLE: ${message}`); + failures.push({ target, error: message }); + } + } + + logger.info("\n=== Summary ==="); + logger.info(`Grants executed: ${resultsGranted.length}`); + logger.info(`Already satisfied: ${skippedExisting.length}`); + logger.info(`Skipped (missing permission): ${skippedNoPermission.length}`); + logger.info(`Skipped (no role hash): ${skippedMissingRole.length}`); + logger.info(`Manifest opt-outs: ${manifestOptOuts.length}`); + logger.info(`Failures: ${failures.length}`); + + if (manifestOptOuts.length > 0) { + logger.info("\nManifest opt-outs:"); + for (const opt of manifestOptOuts) { + logger.info(`- ${opt.contractName} (${opt.address}) :: ${opt.reason}`); + } + } + + if (skippedNoPermission.length > 0) { + logger.warn("\nContracts skipped due to missing deployer permission:"); + for (const item of skippedNoPermission) { + logger.warn(`- ${item.contractName} (${item.address})`); + } + } + + if (failures.length > 0) { + logger.error("\nFailures:"); + for (const failure of failures) { + logger.error(`- ${failure.target.contractName} (${failure.target.address}) :: ${failure.error}`); + } + } + + await maybeEmitJson(options.jsonOutput, { + status: dryRun ? "dry-run" : "executed", + granted: resultsGranted, + skippedExisting, + skippedNoPermission, + skippedMissingRole, + manifestOptOuts, + failures, + }); + } catch (error) { + logger.error("Failed to grant DEFAULT_ADMIN_ROLE."); + logger.error(String(error instanceof Error ? error.message : error)); + process.exitCode = 1; + } +} + +async function maybeEmitJson( + outputPath: string | undefined, + summary: { + status: "executed" | "dry-run" | "no-action"; + granted: GrantTarget[]; + skippedExisting: ContractRef[]; + skippedNoPermission: ContractRef[]; + skippedMissingRole: ContractRef[]; + manifestOptOuts: Summary["manifestOptOuts"]; + failures: Summary["failures"]; + }, +): Promise { + if (!outputPath) { + return; + } + + const payload = JSON.stringify(summary, null, 2); + if (outputPath === "-") { + // eslint-disable-next-line no-console + console.log(payload); + return; + } + + const fs = require("fs"); + const path = require("path"); + const resolved = path.isAbsolute(outputPath) ? outputPath : path.join(process.cwd(), outputPath); + fs.writeFileSync(resolved, payload); + logger.info(`\nSaved JSON report to ${resolved}`); +} + +void main(); diff --git a/.shared/scripts/roles/revoke-roles.ts b/.shared/scripts/roles/revoke-roles.ts index 852812d..e2aee92 100644 --- a/.shared/scripts/roles/revoke-roles.ts +++ b/.shared/scripts/roles/revoke-roles.ts @@ -1,11 +1,35 @@ #!/usr/bin/env ts-node +import { Interface } from "@ethersproject/abi"; import { Command } from "commander"; import * as readline from "readline"; import { logger } from "../../lib/logger"; +import { scanRolesAndOwnership } from "../../lib/roles/scan"; import { loadRoleManifest, resolveRoleManifest } from "../../lib/roles/manifest"; -import { OperationReport, RunnerResult, runRoleManifest } from "../../lib/roles/runner"; +import { isDeploymentExcluded } from "../../lib/roles/planner"; +import { SafeManager } from "../../lib/roles/safe-manager"; +import { SafeTransactionData } from "../../lib/roles/types"; + +type ManifestSource = "auto" | "override"; + +interface RevocationPlanItem { + readonly deployment: string; + readonly contractName: string; + readonly address: string; + readonly manifestSource: ManifestSource; + readonly roles: { + readonly name: string; + readonly hash: string; + }[]; +} + +interface OptOutItem { + readonly deployment: string; + readonly contractName: string; + readonly address: string; + readonly reason: string; +} async function promptYesNo(question: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -14,110 +38,23 @@ async function promptYesNo(question: string): Promise { return ["y", "yes"].includes(answer.trim().toLowerCase()); } -function describeOperation(op: OperationReport): string { - const base = `${op.type} [${op.mode}]`; - if (op.status === "planned") return `${base} (planned)`; - if (op.status === "executed") return `${base} (tx: ${op.txHash ?? "unknown"})`; - if (op.status === "queued") return `${base} (queued)`; - if (op.status === "skipped") return `${base} (skipped${op.details ? `: ${op.details}` : ""})`; - return `${base} (failed${op.details ? `: ${op.details}` : ""})`; -} - -function printPlannedSafeOperations(result: RunnerResult): number { - let planned = 0; - - logger.info("\n--- Planned Safe Revocations ---"); - for (const contract of result.contracts) { - const plannedOps = contract.operations.filter((op) => op.status === "planned" && op.mode === "safe"); - if (plannedOps.length === 0) continue; - - logger.info(`- ${contract.alias ?? contract.deployment}${contract.address ? ` (${contract.address})` : ""}`); - for (const op of plannedOps) { - logger.info(` • ${describeOperation(op)}`); - } - planned += plannedOps.length; - } - - if (planned === 0) { - logger.info("No Safe revocations required."); - } - - return planned; -} - -function printRemainingRoles(result: RunnerResult): void { - logger.info("\n--- Remaining Roles (non-default admin) ---"); - let reported = false; - for (const contract of result.contracts) { - if (contract.remainingRoles.length === 0) continue; - reported = true; - logger.info(`- ${contract.alias ?? contract.deployment}${contract.address ? ` (${contract.address})` : ""}`); - for (const role of contract.remainingRoles) { - const deployerFlag = role.deployerHasRole ? "deployer" : ""; - const governanceFlag = role.governanceHasRole ? "governance" : ""; - const holders = [deployerFlag, governanceFlag].filter(Boolean).join(", ") || "other"; - logger.info(` • ${role.role} (${holders}) hash=${role.hash}`); - } - } - - if (!reported) { - logger.info("No additional AccessControl roles detected."); - } -} - -function summarizeResult(result: RunnerResult): void { - logger.info("\n--- Safe Batch Summary ---"); - - let queued = 0; - for (const contract of result.contracts) { - for (const op of contract.operations) { - if (op.status === "queued" && op.mode === "safe") { - queued += 1; - } - } - } - - logger.info(`Safe operations queued: ${queued}`); - if (result.safeBatch) { - logger.info(`Batch description: ${result.safeBatch.description}`); - if (result.safeBatch.safeTxHash) { - logger.info(`SafeTxHash: ${result.safeBatch.safeTxHash}`); - } - if (!result.safeBatch.success && result.safeBatch.error) { - logger.error(`Safe batch error: ${result.safeBatch.error}`); - } - } -} - -function logStatistics(result: RunnerResult, phase: string): void { - if (!result.statistics) return; - - logger.info(`\n--- ${phase} Breakdown ---`); - logger.info(`Contracts considered: ${result.statistics.totalContracts}`); - logger.info(`Auto-included Ownable actions: ${result.statistics.autoIncludedOwnable}`); - logger.info(`Auto-included DEFAULT_ADMIN_ROLE actions: ${result.statistics.autoIncludedDefaultAdmin}`); - logger.info(`Override Ownable actions: ${result.statistics.overrideOwnable}`); - logger.info(`Override DEFAULT_ADMIN_ROLE actions: ${result.statistics.overrideDefaultAdmin}`); -} - async function main(): Promise { const program = new Command(); program - .description("Prepare Safe revocations for DEFAULT_ADMIN_ROLE using the shared manifest.") + .description("Generate Safe batch transactions to revoke all deployer-held AccessControl roles.") .requiredOption("-m, --manifest ", "Path to the role manifest JSON") - .option("-n, --network ", "Hardhat network to target") - .option("--json-output ", "Write execution report JSON to path (overrides manifest output)") - .option("--dry-run-only", "Run planning step without queueing Safe transactions") + .requiredOption("-n, --network ", "Hardhat network to target") + .option("--deployments-dir ", "Path to deployments directory (defaults to hardhat configured path)") .option("--hardhat-config ", "Path to hardhat.config.ts (defaults to ./hardhat.config.ts)") - .option("--yes", "Skip confirmation prompt"); + .option("--dry-run", "Simulate the batch without creating Safe transactions") + .option("--yes", "Skip confirmation prompt") + .option("--json-output ", "Write summary report JSON to path (or stdout when set to '-')"); program.parse(process.argv); const options = program.opts(); - if (options.network) { - process.env.HARDHAT_NETWORK = options.network; - } + process.env.HARDHAT_NETWORK = options.network; if (options.hardhatConfig) { process.env.HARDHAT_CONFIG = options.hardhatConfig; process.env.HARDHAT_USER_CONFIG = options.hardhatConfig; @@ -126,54 +63,149 @@ async function main(): Promise { try { const hre = require("hardhat"); const manifest = resolveRoleManifest(loadRoleManifest(options.manifest)); + const dryRun = Boolean(options.dryRun); if (!manifest.safe) { - throw new Error("Manifest must include a Safe configuration for revoke operations."); + throw new Error("Manifest must include a Safe configuration to prepare revocation batches."); } - const safeOverrides = manifest.overrides - .filter((override) => override.defaultAdmin?.action?.removal?.execution === "safe") - .map((override) => ({ - deployment: override.deployment, - alias: override.alias, - notes: override.notes, - defaultAdmin: override.defaultAdmin, - })); - - const revokeManifest = { - ...manifest, - autoInclude: { - ownable: false, - defaultAdmin: false, - }, - overrides: safeOverrides, - } as typeof manifest; - - logger.info(`Loaded manifest for Safe ${manifest.safe.safeAddress} (threshold ${manifest.safe.threshold})`); - logger.info(`Safe-ready default admin overrides: ${safeOverrides.length}`); - - const planResult = await runRoleManifest({ + const rolesScan = await scanRolesAndOwnership({ hre, - manifest: revokeManifest, - dryRun: true, + deployer: manifest.deployer, + governanceMultisig: manifest.governance, + deploymentsPath: options.deploymentsDir, logger: (msg: string) => logger.info(msg), }); - const plannedSafe = printPlannedSafeOperations(planResult); - printRemainingRoles(planResult); - logStatistics(planResult, "Planning"); + const overridesByDeployment = new Map(manifest.overrides.map((override) => [override.deployment, override])); + + const planItems: RevocationPlanItem[] = []; + const optOuts: OptOutItem[] = []; + + for (const contractInfo of rolesScan.rolesContracts) { + if (contractInfo.rolesHeldByDeployer.length === 0) { + continue; + } + + const override = overridesByDeployment.get(contractInfo.deploymentName); - if (plannedSafe === 0) { - logger.success("\nNo Safe revocations required."); + if (isDeploymentExcluded(manifest, contractInfo.deploymentName, "defaultAdmin")) { + optOuts.push({ + deployment: contractInfo.deploymentName, + contractName: contractInfo.name, + address: contractInfo.address, + reason: "Manifest exclusion", + }); + continue; + } + + if (override?.defaultAdmin && override.defaultAdmin.enabled === false) { + optOuts.push({ + deployment: contractInfo.deploymentName, + contractName: contractInfo.name, + address: contractInfo.address, + reason: "Override disabled default admin actions", + }); + continue; + } + + let include = false; + let manifestSource: ManifestSource = "auto"; + + if (override?.defaultAdmin && override.defaultAdmin.enabled !== false) { + include = true; + manifestSource = "override"; + } else if (manifest.autoInclude.defaultAdmin) { + include = true; + manifestSource = "auto"; + } + + if (!include) { + optOuts.push({ + deployment: contractInfo.deploymentName, + contractName: contractInfo.name, + address: contractInfo.address, + reason: "Auto-include disabled and no override present", + }); + continue; + } + + planItems.push({ + deployment: contractInfo.deploymentName, + contractName: contractInfo.name, + address: contractInfo.address, + manifestSource, + roles: contractInfo.rolesHeldByDeployer.map((role) => ({ + name: role.name, + hash: role.hash, + })), + }); + } + + if (planItems.length === 0) { + logger.success("\nNo roles require Safe revocation. Deployer holds no AccessControl roles or all were opted out."); + await emitJson(options.jsonOutput, { + status: "no-action", + safeBatch: null, + plan: [], + optOuts, + }); return; } - if (options.dryRunOnly) { - logger.info("\nDry-run only flag supplied; exiting without Safe batch creation."); + logger.info("\n=== Revocation Plan ==="); + let totalRoles = 0; + planItems.forEach((item, index) => { + totalRoles += item.roles.length; + logger.info( + `- [${index + 1}/${planItems.length}] ${item.contractName} (${item.address}) :: ${item.roles.length} roles (${item.manifestSource})`, + ); + for (const role of item.roles) { + logger.info(` • ${role.name} (${role.hash})`); + } + }); + + if (optOuts.length > 0) { + logger.info("\nManifest opt-outs:"); + for (const entry of optOuts) { + logger.info(`- ${entry.contractName} (${entry.address}) :: ${entry.reason}`); + } + } + + const safeTransactions: SafeTransactionData[] = []; + + for (const item of planItems) { + const contractInfo = rolesScan.rolesContracts.find((c) => c.deploymentName === item.deployment); + if (!contractInfo) { + continue; + } + const iface = new Interface(contractInfo.abi as any); + + for (const role of item.roles) { + const data = iface.encodeFunctionData("revokeRole", [role.hash, manifest.deployer]); + safeTransactions.push({ + to: contractInfo.address, + value: "0", + data, + }); + } + } + + logger.info(`\nTotal roles to revoke: ${totalRoles}`); + logger.info(`Safe operations to queue: ${safeTransactions.length}`); + + if (safeTransactions.length === 0) { + logger.success("\nNo Safe transactions generated after filtering. Nothing to do."); + await emitJson(options.jsonOutput, { + status: "no-action", + safeBatch: null, + plan: planItems, + optOuts, + }); return; } - if (!options.yes) { + if (!dryRun && !options.yes) { const confirmed = await promptYesNo("\nQueue Safe revoke transactions now? (yes/no): "); if (!confirmed) { logger.info("Aborted by user."); @@ -181,23 +213,74 @@ async function main(): Promise { } } - const executionResult = await runRoleManifest({ - hre, - manifest: revokeManifest, - logger: (msg: string) => logger.info(msg), - jsonOutputPath: options.jsonOutput, - dryRun: false, - }); + let safeBatchResult: Awaited> | null = null; + + if (!dryRun) { + const signer = await hre.ethers.getSigner(manifest.deployer); + const safeManager = new SafeManager(hre, signer, { + safeConfig: manifest.safe, + }); + + await safeManager.initialize(); - summarizeResult(executionResult); - printRemainingRoles(executionResult); - logStatistics(executionResult, "Execution"); - logger.success("\nSafe revocation batch prepared."); + safeBatchResult = await safeManager.createBatchTransaction({ + transactions: safeTransactions, + description: `Role revocations (${safeTransactions.length} operations)`, + }); + + if (safeBatchResult.success) { + logger.success(`\nSafe batch prepared. SafeTxHash: ${safeBatchResult.safeTxHash ?? "unknown"}`); + } else { + logger.error(`\nFailed to prepare Safe batch: ${safeBatchResult.error ?? "unknown error"}`); + } + } else { + logger.info("\nDry-run mode: Safe batch not created."); + } + + await emitJson(options.jsonOutput, { + status: dryRun ? "dry-run" : "executed", + safeBatch: safeTransactions.length === 0 ? null : safeBatchResult, + plan: planItems, + optOuts, + }); } catch (error) { - logger.error("Failed to prepare Safe revocations."); + logger.error("Failed to prepare Safe revocation batch."); logger.error(String(error instanceof Error ? error.message : error)); process.exitCode = 1; } } +async function emitJson( + outputPath: string | undefined, + payload: { + status: "executed" | "dry-run" | "no-action"; + safeBatch: + | ({ + success: boolean; + safeTxHash?: string; + error?: string; + requiresAdditionalSignatures?: boolean; + } | null); + plan: RevocationPlanItem[]; + optOuts: OptOutItem[]; + }, +): Promise { + if (!outputPath) { + return; + } + + const serialized = JSON.stringify(payload, null, 2); + if (outputPath === "-") { + // eslint-disable-next-line no-console + console.log(serialized); + return; + } + + const fs = require("fs"); + const path = require("path"); + const resolved = path.isAbsolute(outputPath) ? outputPath : path.join(process.cwd(), outputPath); + fs.writeFileSync(resolved, serialized); + logger.info(`\nSaved JSON report to ${resolved}`); +} + void main(); diff --git a/.shared/scripts/roles/scan-roles.ts b/.shared/scripts/roles/scan-roles.ts index 7710498..533b555 100644 --- a/.shared/scripts/roles/scan-roles.ts +++ b/.shared/scripts/roles/scan-roles.ts @@ -63,58 +63,64 @@ async function main(): Promise { logger: (m: string) => logger.info(m), }); - logger.info(`\nRoles contracts: ${result.rolesContracts.length}`); - for (const c of result.rolesContracts) { - logger.info(`- ${c.name} (${c.address})`); - if (c.rolesHeldByDeployer.length > 0) { - logger.info(` deployer roles: ${c.rolesHeldByDeployer.map((r) => r.name).join(', ')}`); - } - if (c.rolesHeldByGovernance.length > 0) { - logger.info(` governance roles: ${c.rolesHeldByGovernance.map((r) => r.name).join(', ')}`); - } - logger.info(` governanceHasDefaultAdmin: ${c.governanceHasDefaultAdmin}`); - } - - logger.info(`\nOwnable contracts: ${result.ownableContracts.length}`); - for (const c of result.ownableContracts) { - logger.info( - `- ${c.name} (${c.address}) owner=${c.owner} deployerIsOwner=${c.deployerIsOwner} governanceIsOwner=${c.governanceIsOwner}`, - ); - } + const { stats } = result; + const durationSeconds = (stats.durationMs / 1000).toFixed(2); + logger.info( + `\nCompleted scan in ${durationSeconds}s :: deployments=${stats.deploymentsEvaluated}, rolesContracts=${stats.rolesContractsEvaluated}, ownableContracts=${stats.ownableContractsEvaluated}`, + ); + logger.info( + `Multicall batches=${stats.multicall.batchesExecuted}, requests=${stats.multicall.requestsAttempted}, fallbacks=${stats.multicall.fallbacks}, supported=${stats.multicall.supported}`, + ); + logger.info( + `Direct calls roleHashes=${stats.directCalls.roleConstants}, hasRole=${stats.directCalls.hasRole}, owner=${stats.directCalls.owner}`, + ); const exposureRoles = result.rolesContracts.filter((c) => c.rolesHeldByDeployer.length > 0); + const governanceMissingAdmin = result.rolesContracts.filter( + (c) => c.defaultAdminRoleHash && !c.governanceHasDefaultAdmin, + ); const exposureOwnable = result.ownableContracts.filter((c) => c.deployerIsOwner); const governanceOwnableMismatches = result.ownableContracts.filter((c) => !c.governanceIsOwner); - logger.info('\n--- Deployer Exposure Summary ---'); - if (exposureRoles.length > 0) { - logger.info(`Contracts with roles held by deployer: ${exposureRoles.length}`); - for (const c of exposureRoles) { - logger.info(`- ${c.name} (${c.address})`); - for (const role of c.rolesHeldByDeployer) { - logger.info(` - ${role.name} (hash: ${role.hash})`); - } - } + logger.info('\nAccessControl exposures (deployer-held roles):'); + if (exposureRoles.length === 0) { + logger.success('- None'); } else { - logger.success('Deployer holds no AccessControl roles.'); + exposureRoles.forEach((contract, index) => { + const deployerRoles = contract.rolesHeldByDeployer.map((role) => role.name).join(', '); + logger.info( + `- [${index + 1}/${exposureRoles.length}] ${contract.name} (${contract.address}) deployerRoles=${deployerRoles}`, + ); + }); } - if (exposureOwnable.length > 0) { - logger.info(`\nOwnable contracts owned by deployer: ${exposureOwnable.length}`); - for (const c of exposureOwnable) { - logger.info(`- ${c.name} (${c.address})`); - } + logger.info('\nGovernance default admin coverage:'); + if (governanceMissingAdmin.length === 0) { + logger.success('- Governance already holds DEFAULT_ADMIN_ROLE everywhere.'); } else { - logger.success('\nDeployer owns no Ownable contracts.'); + governanceMissingAdmin.forEach((contract, index) => { + logger.warn(`- [${index + 1}/${governanceMissingAdmin.length}] ${contract.name} (${contract.address})`); + }); } - if (governanceOwnableMismatches.length > 0) { - logger.warn(`\nOwnable contracts NOT owned by governance multisig: ${governanceOwnableMismatches.length}`); - for (const c of governanceOwnableMismatches) { - logger.warn(`- ${c.name} (${c.address}) owner=${c.owner}`); - } + logger.info('\nOwnable exposures:'); + if (exposureOwnable.length === 0) { + logger.success('- Deployer does not own any Ownable contracts.'); + } else { + exposureOwnable.forEach((contract, index) => { + logger.info(`- [${index + 1}/${exposureOwnable.length}] ${contract.name} (${contract.address})`); + }); + } + + logger.info('\nOwnable contracts not yet under governance:'); + if (governanceOwnableMismatches.length === 0) { + logger.success('- All Ownable contracts are governed by the multisig.'); } else { - logger.success('\nAll Ownable contracts are governed by the multisig.'); + governanceOwnableMismatches.forEach((contract, index) => { + logger.warn( + `- [${index + 1}/${governanceOwnableMismatches.length}] ${contract.name} (${contract.address}) owner=${contract.owner}`, + ); + }); } let driftIssues: DriftIssue[] = []; @@ -161,6 +167,7 @@ async function main(): Promise { rolesContracts: result.rolesContracts.length, ownableContracts: result.ownableContracts.length, }, + stats, exposures: { ownable: exposureOwnable.map((c) => ({ deployment: c.deploymentName, @@ -254,11 +261,7 @@ function findDefaultAdminDrift({ const plannedAction = plannedDefaultAdmin.get(exposure.deploymentName); if (plannedAction) { - if (!plannedAction.removal) { - // Manifest explicitly keeps deployer as admin; no drift. - continue; - } - // Removal planned – coverage exists. + // Manifest plans to grant governance default admin; coverage exists. continue; } diff --git a/.shared/scripts/roles/transfer-ownership.ts b/.shared/scripts/roles/transfer-ownership.ts new file mode 100644 index 0000000..a5c7b36 --- /dev/null +++ b/.shared/scripts/roles/transfer-ownership.ts @@ -0,0 +1,313 @@ +#!/usr/bin/env ts-node + +import { Command } from "commander"; +import * as readline from "readline"; + +import { logger } from "../../lib/logger"; +import { scanRolesAndOwnership } from "../../lib/roles/scan"; +import { loadRoleManifest, resolveRoleManifest } from "../../lib/roles/manifest"; +import { prepareContractPlans, isDeploymentExcluded } from "../../lib/roles/planner"; + +type ScanResult = Awaited>; + +type ManifestSource = "auto" | "override"; + +interface TransferTarget { + readonly deployment: string; + readonly contractName: string; + readonly address: string; + readonly currentOwner: string; + readonly newOwner: string; + readonly manifestSource: ManifestSource; + readonly abi: ScanResult["ownableContracts"][number]["abi"]; +} + +interface ContractRef { + readonly deployment: string; + readonly contractName: string; + readonly address: string; + readonly manifestSource: ManifestSource; +} + +interface OptOutRef { + readonly deployment: string; + readonly contractName: string; + readonly address: string; + readonly reason: string; +} + +async function promptYesNo(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer: string = await new Promise((resolve) => rl.question(question, resolve)); + rl.close(); + return ["y", "yes"].includes(answer.trim().toLowerCase()); +} + +async function main(): Promise { + const program = new Command(); + + program + .description("Transfer Ownable contracts from the deployer to governance as defined in the manifest.") + .requiredOption("-m, --manifest ", "Path to the role manifest JSON") + .requiredOption("-n, --network ", "Hardhat network to target") + .option("--deployments-dir ", "Path to deployments directory (defaults to hardhat configured path)") + .option("--hardhat-config ", "Path to hardhat.config.ts (defaults to ./hardhat.config.ts)") + .option("--dry-run", "Simulate transfers without sending transactions") + .option("--yes", "Skip confirmation prompt") + .option("--json-output ", "Write summary report JSON to path (or stdout when set to '-')"); + + program.parse(process.argv); + const options = program.opts(); + + process.env.HARDHAT_NETWORK = options.network; + if (options.hardhatConfig) { + process.env.HARDHAT_CONFIG = options.hardhatConfig; + process.env.HARDHAT_USER_CONFIG = options.hardhatConfig; + } + + try { + const hre = require("hardhat"); + const manifest = resolveRoleManifest(loadRoleManifest(options.manifest)); + const dryRun = Boolean(options.dryRun); + + const scan = await scanRolesAndOwnership({ + hre, + deployer: manifest.deployer, + governanceMultisig: manifest.governance, + deploymentsPath: options.deploymentsDir, + logger: (msg: string) => logger.info(msg), + }); + + const rolesByDeployment = new Map(scan.rolesContracts.map((info) => [info.deploymentName, info])); + const ownableByDeployment = new Map(scan.ownableContracts.map((info) => [info.deploymentName, info])); + const plans = prepareContractPlans({ manifest, rolesByDeployment, ownableByDeployment }); + + const actionable: TransferTarget[] = []; + const skippedAlreadyOwned: ContractRef[] = []; + const skippedNotOwner: ContractRef[] = []; + const missingOwnable: ContractRef[] = []; + const manifestOptOuts: OptOutRef[] = []; + + for (const plan of plans) { + if (!plan.ownable) { + continue; + } + + const ownableInfo = ownableByDeployment.get(plan.deployment); + const manifestSource: ManifestSource = (plan.ownableSource ?? "auto") as ManifestSource; + + if (!ownableInfo) { + missingOwnable.push({ + deployment: plan.deployment, + contractName: plan.alias ?? plan.deployment, + address: "unknown", + manifestSource, + }); + continue; + } + + if (ownableInfo.governanceIsOwner) { + skippedAlreadyOwned.push({ + deployment: plan.deployment, + contractName: ownableInfo.name, + address: ownableInfo.address, + manifestSource, + }); + continue; + } + + if (!ownableInfo.deployerIsOwner) { + skippedNotOwner.push({ + deployment: plan.deployment, + contractName: ownableInfo.name, + address: ownableInfo.address, + manifestSource, + }); + continue; + } + + actionable.push({ + deployment: plan.deployment, + contractName: ownableInfo.name, + address: ownableInfo.address, + currentOwner: ownableInfo.owner, + newOwner: plan.ownable.newOwner, + manifestSource, + abi: ownableInfo.abi, + }); + } + + for (const ownableInfo of scan.ownableContracts) { + if (!ownableInfo.deployerIsOwner) { + continue; + } + + const plan = plans.find((p) => p.deployment === ownableInfo.deploymentName); + if (plan?.ownable) { + continue; + } + + if (isDeploymentExcluded(manifest, ownableInfo.deploymentName, "ownable")) { + manifestOptOuts.push({ + deployment: ownableInfo.deploymentName, + contractName: ownableInfo.name, + address: ownableInfo.address, + reason: "Manifest exclusion", + }); + continue; + } + + const override = manifest.overrides.find((o) => o.deployment === ownableInfo.deploymentName); + if (override?.ownable?.enabled === false) { + manifestOptOuts.push({ + deployment: ownableInfo.deploymentName, + contractName: ownableInfo.name, + address: ownableInfo.address, + reason: "Override disabled ownable actions", + }); + continue; + } + + if (!manifest.autoInclude.ownable) { + manifestOptOuts.push({ + deployment: ownableInfo.deploymentName, + contractName: ownableInfo.name, + address: ownableInfo.address, + reason: "Auto-include disabled and no override present", + }); + } + } + + logger.info("\n=== Ownership Transfer Plan ==="); + logger.info(`Pending transfers: ${actionable.length}`); + logger.info(`Already owned by governance: ${skippedAlreadyOwned.length}`); + logger.info(`Skipped (deployer not owner): ${skippedNotOwner.length}`); + logger.info(`Missing Ownable metadata: ${missingOwnable.length}`); + logger.info(`Manifest opt-outs: ${manifestOptOuts.length}`); + + if (actionable.length === 0) { + logger.success("\nNo ownership transfers required."); + await emitJson(options.jsonOutput, { + status: "no-action", + executed: [], + skippedAlreadyOwned, + skippedNotOwner, + missingOwnable, + manifestOptOuts, + failures: [], + }); + return; + } + + logger.warn("\n⚠️ Ownership transfers are irreversible. Verify each target carefully before proceeding."); + actionable.forEach((item, index) => { + logger.info( + `- [${index + 1}/${actionable.length}] ${item.contractName} (${item.address}) :: owner=${item.currentOwner} -> ${item.newOwner} (${item.manifestSource})`, + ); + }); + + if (!dryRun && !options.yes) { + const confirmed = await promptYesNo("\nProceed with ownership transfers? (yes/no): "); + if (!confirmed) { + logger.info("Aborted by user."); + return; + } + } + + const signer = await hre.ethers.getSigner(manifest.deployer); + const executed: TransferTarget[] = []; + const failures: { target: TransferTarget; error: string }[] = []; + + for (let index = 0; index < actionable.length; index += 1) { + const target = actionable[index]; + logger.info(`\n[${index + 1}/${actionable.length}] Transferring ownership of ${target.contractName} (${target.address})`); + + try { + const contract = await hre.ethers.getContractAt(target.abi as any, target.address, signer); + + if (dryRun) { + logger.info(" [dry-run] Would call transferOwnership(newOwner)"); + executed.push(target); + continue; + } + + const tx = await contract.transferOwnership(target.newOwner); + const receipt = await tx.wait(); + const txHash = receipt?.hash ?? tx.hash ?? "unknown"; + logger.info(` ✅ Transaction hash: ${txHash}`); + executed.push(target); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(` ❌ Failed to transfer ownership: ${message}`); + failures.push({ target, error: message }); + } + } + + logger.info("\n=== Summary ==="); + logger.info(`Transfers executed: ${executed.length}`); + logger.info(`Already owned by governance: ${skippedAlreadyOwned.length}`); + logger.info(`Skipped (deployer not owner): ${skippedNotOwner.length}`); + logger.info(`Manifest opt-outs: ${manifestOptOuts.length}`); + logger.info(`Failures: ${failures.length}`); + + if (manifestOptOuts.length > 0) { + logger.info("\nManifest opt-outs:"); + for (const opt of manifestOptOuts) { + logger.info(`- ${opt.contractName} (${opt.address}) :: ${opt.reason}`); + } + } + + if (failures.length > 0) { + logger.error("\nFailures:"); + for (const failure of failures) { + logger.error(`- ${failure.target.contractName} (${failure.target.address}) :: ${failure.error}`); + } + } + + await emitJson(options.jsonOutput, { + status: dryRun ? "dry-run" : "executed", + executed, + skippedAlreadyOwned, + skippedNotOwner, + missingOwnable, + manifestOptOuts, + failures, + }); + } catch (error) { + logger.error("Failed to transfer ownership."); + logger.error(String(error instanceof Error ? error.message : error)); + process.exitCode = 1; + } +} + +async function emitJson( + outputPath: string | undefined, + payload: { + status: "executed" | "dry-run" | "no-action"; + executed: TransferTarget[]; + skippedAlreadyOwned: ContractRef[]; + skippedNotOwner: ContractRef[]; + missingOwnable: ContractRef[]; + manifestOptOuts: OptOutRef[]; + failures: { target: TransferTarget; error: string }[]; + }, +): Promise { + if (!outputPath) { + return; + } + + const serialized = JSON.stringify(payload, null, 2); + if (outputPath === "-") { + // eslint-disable-next-line no-console + console.log(serialized); + return; + } + + const fs = require("fs"); + const path = require("path"); + const resolved = path.isAbsolute(outputPath) ? outputPath : path.join(process.cwd(), outputPath); + fs.writeFileSync(resolved, serialized); + logger.info(`\nSaved JSON report to ${resolved}`); +} + +void main(); diff --git a/.shared/scripts/roles/transfer-roles.ts b/.shared/scripts/roles/transfer-roles.ts deleted file mode 100644 index 1aafaad..0000000 --- a/.shared/scripts/roles/transfer-roles.ts +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env ts-node - -import { Command } from "commander"; -import * as readline from "readline"; - -import { logger } from "../../lib/logger"; -import { loadRoleManifest, resolveRoleManifest } from "../../lib/roles/manifest"; -import { OperationReport, RunnerResult, runRoleManifest } from "../../lib/roles/runner"; - -async function promptYesNo(question: string): Promise { - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const answer: string = await new Promise((resolve) => rl.question(question, resolve)); - rl.close(); - return ["y", "yes"].includes(answer.trim().toLowerCase()); -} - -function printPlannedOperations(result: RunnerResult): number { - let planned = 0; - - logger.info("\n--- Planned Operations ---"); - for (const contract of result.contracts) { - const plannedOps = contract.operations.filter((op) => op.status === "planned"); - if (plannedOps.length === 0) continue; - - logger.info(`- ${contract.alias ?? contract.deployment}${contract.address ? ` (${contract.address})` : ""}`); - for (const op of plannedOps) { - logger.info(` • ${describeOperation(op)}`); - } - planned += plannedOps.length; - } - - if (planned === 0) { - logger.info("No operations required."); - } - - return planned; -} - -function printRemainingRoles(result: RunnerResult): void { - logger.info("\n--- Remaining Roles (non-default admin) ---"); - let reported = false; - for (const contract of result.contracts) { - if (contract.remainingRoles.length === 0) continue; - reported = true; - logger.info(`- ${contract.alias ?? contract.deployment}${contract.address ? ` (${contract.address})` : ""}`); - for (const role of contract.remainingRoles) { - const deployerFlag = role.deployerHasRole ? "deployer" : ""; - const governanceFlag = role.governanceHasRole ? "governance" : ""; - const holders = [deployerFlag, governanceFlag].filter(Boolean).join(", ") || "other"; - logger.info(` • ${role.role} (${holders}) hash=${role.hash}`); - } - } - - if (!reported) { - logger.info("No additional AccessControl roles detected."); - } -} - -function describeOperation(op: OperationReport): string { - const base = `${op.type} [${op.mode}]`; - if (op.status === "planned") return `${base} (planned)`; - if (op.status === "executed") return `${base} (tx: ${op.txHash ?? "unknown"})`; - if (op.status === "queued") return `${base} (queued)`; - if (op.status === "skipped") return `${base} (skipped${op.details ? `: ${op.details}` : ""})`; - return `${base} (failed${op.details ? `: ${op.details}` : ""})`; -} - -function summarizeResult(result: RunnerResult): void { - logger.info("\n--- Execution Summary ---"); - let executed = 0; - let queued = 0; - - for (const contract of result.contracts) { - for (const op of contract.operations) { - if (op.status === "executed") executed += 1; - if (op.status === "queued") queued += 1; - } - } - - logger.info(`Direct operations executed: ${executed}`); - logger.info(`Safe operations queued: ${queued}`); - - if (result.safeBatch) { - logger.info(`Safe batch description: ${result.safeBatch.description}`); - if (result.safeBatch.safeTxHash) { - logger.info(`SafeTxHash: ${result.safeBatch.safeTxHash}`); - } - if (!result.safeBatch.success && result.safeBatch.error) { - logger.error(`Safe batch error: ${result.safeBatch.error}`); - } - } -} - -function logStatistics(result: RunnerResult, phase: string): void { - if (!result.statistics) return; - - logger.info(`\n--- ${phase} Breakdown ---`); - logger.info(`Contracts considered: ${result.statistics.totalContracts}`); - logger.info(`Auto-included Ownable actions: ${result.statistics.autoIncludedOwnable}`); - logger.info(`Auto-included DEFAULT_ADMIN_ROLE actions: ${result.statistics.autoIncludedDefaultAdmin}`); - logger.info(`Override Ownable actions: ${result.statistics.overrideOwnable}`); - logger.info(`Override DEFAULT_ADMIN_ROLE actions: ${result.statistics.overrideDefaultAdmin}`); -} - -async function main(): Promise { - const program = new Command(); - - program - .description("Transfer ownership and DEFAULT_ADMIN_ROLE using a manifest-driven workflow.") - .requiredOption("-m, --manifest ", "Path to the role manifest JSON") - .option("-n, --network ", "Hardhat network to target") - .option("--json-output ", "Write execution report JSON to path (overrides manifest output)") - .option("--dry-run-only", "Run planning step without executing on-chain actions") - .option("--hardhat-config ", "Path to hardhat.config.ts (defaults to ./hardhat.config.ts)") - .option("--yes", "Skip confirmation prompt"); - - program.parse(process.argv); - const options = program.opts(); - - if (options.network) { - process.env.HARDHAT_NETWORK = options.network; - } - if (options.hardhatConfig) { - process.env.HARDHAT_CONFIG = options.hardhatConfig; - process.env.HARDHAT_USER_CONFIG = options.hardhatConfig; - } - - try { - const hre = require("hardhat"); - const manifest = resolveRoleManifest(loadRoleManifest(options.manifest)); - - logger.info(`Loaded manifest for deployer ${manifest.deployer} → governance ${manifest.governance}`); - logger.info(`Auto-included Ownable transfers: ${manifest.autoInclude.ownable ? "enabled" : "disabled"}`); - logger.info(`Auto-included DEFAULT_ADMIN_ROLE transfers: ${manifest.autoInclude.defaultAdmin ? "enabled" : "disabled"}`); - logger.info(`Overrides declared: ${manifest.overrides.length}`); - - const planResult = await runRoleManifest({ - hre, - manifest, - dryRun: true, - logger: (msg: string) => logger.info(msg), - }); - - const plannedCount = printPlannedOperations(planResult); - printRemainingRoles(planResult); - logStatistics(planResult, "Planning"); - - if (plannedCount === 0) { - logger.success("\nNothing to execute. Governance and ownership already aligned."); - return; - } - - if (options.dryRunOnly) { - logger.info("\nDry-run only flag supplied; exiting without execution."); - return; - } - - if (!options.yes) { - const confirmed = await promptYesNo("\nProceed to execute these operations on-chain? (yes/no): "); - if (!confirmed) { - logger.info("Aborted by user."); - return; - } - } - - const executionResult = await runRoleManifest({ - hre, - manifest, - logger: (msg: string) => logger.info(msg), - jsonOutputPath: options.jsonOutput, - dryRun: false, - }); - - summarizeResult(executionResult); - printRemainingRoles(executionResult); - logStatistics(executionResult, "Execution"); - logger.success("\nRole migration completed."); - } catch (error) { - logger.error("Failed to execute role migration."); - logger.error(String(error instanceof Error ? error.message : error)); - process.exitCode = 1; - } -} - -void main(); diff --git a/manifests/ronin-mainnet-roles.json b/manifests/ronin-mainnet-roles.json index 4b3d92f..7a8652e 100644 --- a/manifests/ronin-mainnet-roles.json +++ b/manifests/ronin-mainnet-roles.json @@ -10,13 +10,7 @@ }, "defaultAdmin": { "newAdmin": "{{governance}}", - "grantExecution": "direct", - "remove": { - "address": "{{deployer}}", - "strategy": "renounce", - "execution": "direct", - "enabled": true - } + "grantExecution": "direct" } }, "autoInclude": { diff --git a/manifests/ronin-testnet-roles.json b/manifests/ronin-testnet-roles.json index d33e2e6..0ef01d0 100644 --- a/manifests/ronin-testnet-roles.json +++ b/manifests/ronin-testnet-roles.json @@ -10,13 +10,7 @@ }, "defaultAdmin": { "newAdmin": "{{governance}}", - "grantExecution": "direct", - "remove": { - "address": "{{deployer}}", - "strategy": "renounce", - "execution": "direct", - "enabled": true - } + "grantExecution": "direct" } }, "autoInclude": {