Skip to content

Commit 544daa8

Browse files
authored
feat(prepareMigration): add post-pre-migration role-swap script (#271)
1 parent 9b80b8d commit 544daa8

10 files changed

Lines changed: 689 additions & 36 deletions

contracts/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,11 @@ Modified ERC1155 allowing only one token per ID:
314314
- `LockedMigrationController`: Handles ENSv1 → ENSv2 migration for locked names
315315
- `UnlockedMigrationController`: Handles ENSv1 → ENSv2 migration for unlocked names
316316

317+
Scripts for running the migration end-to-end:
318+
319+
- [Pre-migration](docs/premigration.md) — seed v1 registrations into the v2 registry as *reserved* entries, via `BatchRegistrar`.
320+
- [Prepare migration](docs/prepareMigration.md) — swap registry roles from `BatchRegistrar` to `ETHRegistrar` and the two migration controllers once pre-migration is complete.
321+
317322
### Resolution
318323

319324
#### `UniversalResolverV2` - One-Stop Resolution

contracts/deploy/03_ETHRegistrar.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { artifacts, execute } from "@rocketh";
2-
import { ROLES } from "../script/deploy-constants.js";
2+
import { DEPLOYMENT_ROLES } from "../script/deploy-constants.js";
33

44
export default execute(
55
async ({
@@ -37,10 +37,7 @@ export default execute(
3737

3838
await write(ethRegistry, {
3939
functionName: "grantRootRoles",
40-
args: [
41-
ROLES.REGISTRY.REGISTRAR | ROLES.REGISTRY.RENEW,
42-
ethRegistrar.address,
43-
],
40+
args: [DEPLOYMENT_ROLES.ETH_REGISTRAR_ROOT, ethRegistrar.address],
4441
account: deployer,
4542
});
4643
},

contracts/deploy/04_BatchRegistrar.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { artifacts, execute } from "@rocketh";
2-
import { ROLES } from "../script/deploy-constants.js";
2+
import { DEPLOYMENT_ROLES } from "../script/deploy-constants.js";
33

44
export default execute(
55
async ({ deploy, execute: write, get, namedAccounts: { deployer } }) => {
@@ -15,10 +15,7 @@ export default execute(
1515
await write(ethRegistry, {
1616
account: deployer,
1717
functionName: "grantRootRoles",
18-
args: [
19-
ROLES.REGISTRY.REGISTRAR | ROLES.REGISTRY.RENEW,
20-
batchRegistrar.address,
21-
],
18+
args: [DEPLOYMENT_ROLES.ETH_REGISTRAR_ROOT, batchRegistrar.address],
2219
});
2320
},
2421
{

contracts/docs/prepareMigration.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# ENS Prepare-Migration Script
2+
3+
## Overview
4+
5+
The prepare-migration script (`contracts/script/prepareMigration.ts`) rewires role grants on the `.eth` `PermissionedRegistry` to flip the registry from its **seeding** configuration (only `BatchRegistrar` can register names) to its **live** configuration (`ETHRegistrar` handles new registrations and renewals; `UnlockedMigrationController` and `LockedMigrationController` promote reserved names to registered as ENSv1 owners migrate in). `BatchRegistrar` retains no roles after this script runs — it is fully decommissioned on hand-off.
6+
7+
Run this once, after all pre-migration seeding via [`preMigration.ts`](./premigration.md) has completed and before opening registration traffic to users. The script is idempotent at the role level — re-running it against a registry that is already in the live configuration will show every planned op as already satisfied and simply broadcast the same grants/revokes again.
8+
9+
## Role changes
10+
11+
The script performs exactly four root-level role operations on the target registry:
12+
13+
| Target | Op | Roles | Expected prior state |
14+
|---|---|---|---|
15+
| `BatchRegistrar` | **REVOKE** | `ROLE_REGISTRAR` · `ROLE_REGISTRAR_ADMIN` · `ROLE_REGISTER_RESERVED` · `ROLE_REGISTER_RESERVED_ADMIN` · `ROLE_RENEW` · `ROLE_RENEW_ADMIN` | Holds `ROLE_REGISTRAR \| ROLE_RENEW` on a canonically-deployed registry. The four admin bits and `ROLE_REGISTER_RESERVED` are revoked defensively and are no-ops on a canonical deploy — they exist in the bitmap to guarantee the post-state is unambiguously "no roles" regardless of what the registry looked like going in. |
16+
| `ETHRegistrar` | **GRANT** | `ROLE_REGISTRAR` · `ROLE_RENEW` | None of the granted bits. |
17+
| `UnlockedMigrationController` | **GRANT** | `ROLE_REGISTER_RESERVED` | None of the granted bit. |
18+
| `LockedMigrationController` | **GRANT** | `ROLE_REGISTER_RESERVED` | None of the granted bit. |
19+
20+
> **Note for devnet users.** The canonical devnet deploy scripts (`deploy/03_ETHRegistrar.ts`, `deploy/02_UnlockedMigrationController.ts`, `deploy/04_LockedMigrationController.ts`) *already* pre-grant the roles this script would otherwise grant, as a convenience for local dev. That means running this script against a fresh devnet will show every GRANT op as already satisfied and only the `BatchRegistrar` revoke will produce observable state change. The test fixture `revertPrePrepareMigrationRoles` in `test/utils/mockPrepareMigration.ts` undoes those pre-grants so the grant paths can be exercised end-to-end in the e2e tests.
21+
22+
For background on these roles and the EAC admin/base pairing used by registry contracts, see the [EAC section of the contracts README](../README.md#access-control) and [`RegistryRolesLib.sol`](../src/registry/libraries/RegistryRolesLib.sol).
23+
24+
### Why these specific roles
25+
26+
- `ROLE_REGISTRAR` is checked by `PermissionedRegistry.register()` when the entry is expired or never existed. `BatchRegistrar` seeds names via this path (`owner = address(0)`, entering the expired branch), so it holds the role during pre-migration. After hand-off, `ETHRegistrar` holds it to handle live new registrations.
27+
- `ROLE_REGISTER_RESERVED` is checked by the same `register()` entry point when the entry is currently **reserved** (owner zero, not expired) and an actual owner is being set. This is the promotion path the migration controllers use to flip a pre-seeded reserved name into a registered name owned by its ENSv1 claimant — hence both controllers receive it here.
28+
- `ROLE_RENEW` gates `PermissionedRegistry.renew()`. During pre-migration `BatchRegistrar` uses it to bump expiries on reserved names; afterwards the live renewal path runs through `ETHRegistrar.renew()` (see `src/registrar/ETHRegistrar.sol`), so the role moves from `BatchRegistrar` to `ETHRegistrar`.
29+
30+
## Prerequisites
31+
32+
- **Bun** runtime installed
33+
- **Forge artifacts** compiled (`forge build` in `contracts/`) — the script loads the `PermissionedRegistry` ABI from `contracts/out/`
34+
- **Deployed contracts:**
35+
- `PermissionedRegistry` (the `.eth` registry)
36+
- `BatchRegistrar` — currently holding the seeding roles
37+
- `ETHRegistrar` — will receive `ROLE_REGISTRAR`
38+
- `UnlockedMigrationController` — will receive `ROLE_REGISTER_RESERVED`
39+
- `LockedMigrationController` — will receive `ROLE_REGISTER_RESERVED`
40+
- **Signer** holding the admin-role counterparts for every role being moved. In practice this means holding `ROLE_REGISTRAR_ADMIN`, `ROLE_REGISTER_RESERVED_ADMIN`, and `ROLE_RENEW_ADMIN` at the registry root. The script runs a pre-flight check against the signer's root roles and aborts with a clear error if any required admin bits are missing — no transactions are broadcast.
41+
- **RPC endpoint** for the chain the registry is deployed on. The chain ID is auto-detected from the RPC.
42+
43+
`--execute` additionally requires `--private-key`; without it the script stays in dry-run mode.
44+
45+
## CLI Reference
46+
47+
Run from the `contracts/` directory:
48+
49+
```bash
50+
bun run script/prepareMigration.ts [options]
51+
```
52+
53+
### Required Options
54+
55+
| Option | Description |
56+
|---|---|
57+
| `--rpc-url <url>` | JSON-RPC endpoint for the target chain |
58+
| `--registry <address>` | `.eth` `PermissionedRegistry` address |
59+
| `--batch-registrar <address>` | `BatchRegistrar` address (roles revoked from this target) |
60+
| `--eth-registrar <address>` | `ETHRegistrar` address (receives `ROLE_REGISTRAR`) |
61+
| `--unlocked-migration-controller <address>` | `UnlockedMigrationController` address (receives `ROLE_REGISTER_RESERVED`) |
62+
| `--locked-migration-controller <address>` | `LockedMigrationController` address (receives `ROLE_REGISTER_RESERVED`) |
63+
64+
### Optional
65+
66+
| Option | Default | Description |
67+
|---|---|---|
68+
| `--private-key <hex>` || Signer private key. Required when `--execute` is passed; enables the admin-role pre-flight check when running dry. |
69+
| `--execute` | `false` | Broadcast transactions. Without this flag the script performs a dry run and never sends anything on-chain. |
70+
71+
## How It Works
72+
73+
1. **Parse CLI options** and build viem clients via `createV2Clients` in `scriptUtils.ts`. When no private key is supplied the script runs with a read-only public client.
74+
2. **Load the `PermissionedRegistry` ABI** from the forge artifact under `contracts/out/`.
75+
3. **Build the op list** — the fixed four-entry sequence shown in the [Role changes](#role-changes) table.
76+
4. **Preview each op.** For every target the script reads the current root-role bitmap from the registry and prints it next to the planned change, so the diff is visible before anything is broadcast.
77+
5. **Signer admin-role pre-flight** (when a signer is configured). The script computes the admin bits required for each planned grant/revoke and checks the signer's root roles on the registry. Any missing admin bit aborts the run with a description of which op needs which missing admin role.
78+
6. **Dry-run exit.** If `--execute` is not set (or no wallet client is available) the script stops here after printing a "Dry run complete" summary.
79+
7. **Execute.** If `--execute` is set, the script submits `grantRootRoles` / `revokeRootRoles` transactions sequentially, waiting for each receipt before moving on. After the last op it re-reads the role bitmap for every target and prints the final state.
80+
81+
## Dry Run vs. Execute
82+
83+
**Dry run is the default.** Running without `--execute` always produces the full preview — planned operations, the current on-chain state for every target, and (if a signer is supplied) the admin pre-flight result. No transactions are broadcast.
84+
85+
Passing `--execute` along with `--private-key` broadcasts the role changes. Transactions run **sequentially, one per op**, so an interruption part-way through leaves the registry in a partially-applied state. Re-running the script with `--execute` is safe: ops that have already been applied simply re-issue the same grant/revoke, and the preview will show the current state matching the desired state before each re-broadcast.
86+
87+
## Examples
88+
89+
### Dry run without a signer
90+
91+
Prints planned ops and current on-chain state for each target. No admin pre-flight (nothing to check against).
92+
93+
```bash
94+
bun run script/prepareMigration.ts \
95+
--rpc-url https://v2-rpc.example.com \
96+
--registry 0x1234...abcd \
97+
--batch-registrar 0x5678...ef01 \
98+
--eth-registrar 0xaaaa...1111 \
99+
--unlocked-migration-controller 0xbbbb...2222 \
100+
--locked-migration-controller 0xcccc...3333
101+
```
102+
103+
### Dry run with a signer (admin pre-flight)
104+
105+
Same preview, plus the pre-flight check that the signer holds every admin bit the execute phase would need.
106+
107+
```bash
108+
bun run script/prepareMigration.ts \
109+
--rpc-url https://v2-rpc.example.com \
110+
--registry 0x1234...abcd \
111+
--batch-registrar 0x5678...ef01 \
112+
--eth-registrar 0xaaaa...1111 \
113+
--unlocked-migration-controller 0xbbbb...2222 \
114+
--locked-migration-controller 0xcccc...3333 \
115+
--private-key 0xabc...def
116+
```
117+
118+
### Execute
119+
120+
Broadcasts the full role swap. Prints the final on-chain role state for every target on completion.
121+
122+
```bash
123+
bun run script/prepareMigration.ts \
124+
--rpc-url https://v2-rpc.example.com \
125+
--registry 0x1234...abcd \
126+
--batch-registrar 0x5678...ef01 \
127+
--eth-registrar 0xaaaa...1111 \
128+
--unlocked-migration-controller 0xbbbb...2222 \
129+
--locked-migration-controller 0xcccc...3333 \
130+
--private-key 0xabc...def \
131+
--execute
132+
```

contracts/script/deploy-constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ export const DEPLOYMENT_ROLES = {
8484
ROLES.REGISTRY.SET_PARENT |
8585
ROLES.ADMIN.REGISTRY.SET_PARENT |
8686
ROLES.ADMIN.REGISTRY.RENEW,
87+
// ETHRegistrar and BatchRegistrar are granted REGISTRAR and RENEW at the
88+
// ETHRegistry root at static deploy.
89+
ETH_REGISTRAR_ROOT: ROLES.REGISTRY.REGISTRAR | ROLES.REGISTRY.RENEW,
8790
// UnlockedMigrationController and LockedMigrationController
8891
// only need to register() pre-migrated reservations on ETHRegistry (see: "ENSv2 Migration Case Study")
8992
MIGRATION_CONTROLLER_ROOT: ROLES.REGISTRY.REGISTER_RESERVED,

contracts/script/preMigration.ts

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,16 @@ import {
77
readFileSync,
88
writeFileSync,
99
} from "node:fs";
10-
import { join } from "node:path";
1110
import {
1211
createPublicClient,
1312
createWalletClient,
14-
defineChain,
1513
getContract,
1614
http,
1715
keccak256,
1816
publicActions,
1917
toHex,
2018
zeroAddress,
2119
type Address,
22-
type Chain,
2320
} from "viem";
2421
import { privateKeyToAccount } from "viem/accounts";
2522
import { mainnet } from "viem/chains";
@@ -36,15 +33,7 @@ import {
3633
yellow,
3734
} from "./logger.js";
3835

39-
// Load ABI from forge compilation artifacts
40-
function loadArtifact(contractName: string): { abi: any[] } {
41-
const artifactPath = join(
42-
import.meta.dirname,
43-
`../out/${contractName}.sol/${contractName}.json`,
44-
);
45-
const artifact = JSON.parse(readFileSync(artifactPath, "utf-8"));
46-
return { abi: artifact.abi };
47-
}
36+
import { loadArtifact, resolveChain } from "./scriptUtils.js";
4837

4938
// ABI fragments for v1 BaseRegistrar
5039
const BASE_REGISTRAR_ABI = [
@@ -460,20 +449,7 @@ interface MigrationClients {
460449
async function createMigrationClients(
461450
config: PreMigrationConfig,
462451
): Promise<MigrationClients> {
463-
const tempClient = createPublicClient({
464-
transport: http(config.rpcUrl, { retryCount: 0, timeout: RPC_TIMEOUT_MS }),
465-
});
466-
const chainId = await tempClient.getChainId();
467-
468-
const v2Chain: Chain =
469-
chainId === 1
470-
? mainnet
471-
: defineChain({
472-
id: chainId,
473-
name: "Custom",
474-
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
475-
rpcUrls: { default: { http: [config.rpcUrl] } },
476-
});
452+
const v2Chain = await resolveChain(config.rpcUrl, RPC_TIMEOUT_MS);
477453

478454
const client = createWalletClient({
479455
account: privateKeyToAccount(config.privateKey),

0 commit comments

Comments
 (0)