Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,11 @@ Modified ERC1155 allowing only one token per ID:
- `LockedMigrationController`: Handles ENSv1 → ENSv2 migration for locked names
- `UnlockedMigrationController`: Handles ENSv1 → ENSv2 migration for unlocked names

Scripts for running the migration end-to-end:

- [Pre-migration](docs/premigration.md) — seed v1 registrations into the v2 registry as *reserved* entries, via `BatchRegistrar`.
- [Prepare migration](docs/prepareMigration.md) — swap registry roles from `BatchRegistrar` to `ETHRegistrar` and the two migration controllers once pre-migration is complete.

### Resolution

#### `UniversalResolverV2` - One-Stop Resolution
Expand Down
7 changes: 2 additions & 5 deletions contracts/deploy/03_ETHRegistrar.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { artifacts, execute } from "@rocketh";
import { ROLES } from "../script/deploy-constants.js";
import { DEPLOYMENT_ROLES } from "../script/deploy-constants.js";

export default execute(
async ({
Expand Down Expand Up @@ -37,10 +37,7 @@ export default execute(

await write(ethRegistry, {
functionName: "grantRootRoles",
args: [
ROLES.REGISTRY.REGISTRAR | ROLES.REGISTRY.RENEW,
ethRegistrar.address,
],
args: [DEPLOYMENT_ROLES.ETH_REGISTRAR_ROOT, ethRegistrar.address],
account: deployer,
});
},
Expand Down
7 changes: 2 additions & 5 deletions contracts/deploy/04_BatchRegistrar.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { artifacts, execute } from "@rocketh";
import { ROLES } from "../script/deploy-constants.js";
import { DEPLOYMENT_ROLES } from "../script/deploy-constants.js";

export default execute(
async ({ deploy, execute: write, get, namedAccounts: { deployer } }) => {
Expand All @@ -15,10 +15,7 @@ export default execute(
await write(ethRegistry, {
account: deployer,
functionName: "grantRootRoles",
args: [
ROLES.REGISTRY.REGISTRAR | ROLES.REGISTRY.RENEW,
batchRegistrar.address,
],
args: [DEPLOYMENT_ROLES.ETH_REGISTRAR_ROOT, batchRegistrar.address],
});
},
{
Expand Down
132 changes: 132 additions & 0 deletions contracts/docs/prepareMigration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# ENS Prepare-Migration Script

## Overview

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.

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.

## Role changes

The script performs exactly four root-level role operations on the target registry:

| Target | Op | Roles | Expected prior state |
|---|---|---|---|
| `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. |
| `ETHRegistrar` | **GRANT** | `ROLE_REGISTRAR` · `ROLE_RENEW` | None of the granted bits. |
| `UnlockedMigrationController` | **GRANT** | `ROLE_REGISTER_RESERVED` | None of the granted bit. |
| `LockedMigrationController` | **GRANT** | `ROLE_REGISTER_RESERVED` | None of the granted bit. |

> **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.

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).

### Why these specific roles

- `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.
- `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.
- `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`.

## Prerequisites

- **Bun** runtime installed
- **Forge artifacts** compiled (`forge build` in `contracts/`) — the script loads the `PermissionedRegistry` ABI from `contracts/out/`
- **Deployed contracts:**
- `PermissionedRegistry` (the `.eth` registry)
- `BatchRegistrar` — currently holding the seeding roles
- `ETHRegistrar` — will receive `ROLE_REGISTRAR`
- `UnlockedMigrationController` — will receive `ROLE_REGISTER_RESERVED`
- `LockedMigrationController` — will receive `ROLE_REGISTER_RESERVED`
- **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.
- **RPC endpoint** for the chain the registry is deployed on. The chain ID is auto-detected from the RPC.

`--execute` additionally requires `--private-key`; without it the script stays in dry-run mode.

## CLI Reference

Run from the `contracts/` directory:

```bash
bun run script/prepareMigration.ts [options]
```

### Required Options

| Option | Description |
|---|---|
| `--rpc-url <url>` | JSON-RPC endpoint for the target chain |
| `--registry <address>` | `.eth` `PermissionedRegistry` address |
| `--batch-registrar <address>` | `BatchRegistrar` address (roles revoked from this target) |
| `--eth-registrar <address>` | `ETHRegistrar` address (receives `ROLE_REGISTRAR`) |
| `--unlocked-migration-controller <address>` | `UnlockedMigrationController` address (receives `ROLE_REGISTER_RESERVED`) |
| `--locked-migration-controller <address>` | `LockedMigrationController` address (receives `ROLE_REGISTER_RESERVED`) |

### Optional

| Option | Default | Description |
|---|---|---|
| `--private-key <hex>` | — | Signer private key. Required when `--execute` is passed; enables the admin-role pre-flight check when running dry. |
| `--execute` | `false` | Broadcast transactions. Without this flag the script performs a dry run and never sends anything on-chain. |

## How It Works

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.
2. **Load the `PermissionedRegistry` ABI** from the forge artifact under `contracts/out/`.
3. **Build the op list** — the fixed four-entry sequence shown in the [Role changes](#role-changes) table.
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.
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.
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.
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.

## Dry Run vs. Execute

**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.

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.

## Examples

### Dry run without a signer

Prints planned ops and current on-chain state for each target. No admin pre-flight (nothing to check against).

```bash
bun run script/prepareMigration.ts \
--rpc-url https://v2-rpc.example.com \
--registry 0x1234...abcd \
--batch-registrar 0x5678...ef01 \
--eth-registrar 0xaaaa...1111 \
--unlocked-migration-controller 0xbbbb...2222 \
--locked-migration-controller 0xcccc...3333
```

### Dry run with a signer (admin pre-flight)

Same preview, plus the pre-flight check that the signer holds every admin bit the execute phase would need.

```bash
bun run script/prepareMigration.ts \
--rpc-url https://v2-rpc.example.com \
--registry 0x1234...abcd \
--batch-registrar 0x5678...ef01 \
--eth-registrar 0xaaaa...1111 \
--unlocked-migration-controller 0xbbbb...2222 \
--locked-migration-controller 0xcccc...3333 \
--private-key 0xabc...def
```

### Execute

Broadcasts the full role swap. Prints the final on-chain role state for every target on completion.

```bash
bun run script/prepareMigration.ts \
--rpc-url https://v2-rpc.example.com \
--registry 0x1234...abcd \
--batch-registrar 0x5678...ef01 \
--eth-registrar 0xaaaa...1111 \
--unlocked-migration-controller 0xbbbb...2222 \
--locked-migration-controller 0xcccc...3333 \
--private-key 0xabc...def \
--execute
```
3 changes: 3 additions & 0 deletions contracts/script/deploy-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ export const DEPLOYMENT_ROLES = {
ROLES.REGISTRY.SET_PARENT |
ROLES.ADMIN.REGISTRY.SET_PARENT |
ROLES.ADMIN.REGISTRY.RENEW,
// ETHRegistrar and BatchRegistrar are granted REGISTRAR and RENEW at the
// ETHRegistry root at static deploy.
ETH_REGISTRAR_ROOT: ROLES.REGISTRY.REGISTRAR | ROLES.REGISTRY.RENEW,
// UnlockedMigrationController and LockedMigrationController
// only need to register() pre-migrated reservations on ETHRegistry (see: "ENSv2 Migration Case Study")
MIGRATION_CONTROLLER_ROOT: ROLES.REGISTRY.REGISTER_RESERVED,
Expand Down
28 changes: 2 additions & 26 deletions contracts/script/preMigration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,16 @@ import {
readFileSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import {
createPublicClient,
createWalletClient,
defineChain,
getContract,
http,
keccak256,
publicActions,
toHex,
zeroAddress,
type Address,
type Chain,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet } from "viem/chains";
Expand All @@ -36,15 +33,7 @@ import {
yellow,
} from "./logger.js";

// Load ABI from forge compilation artifacts
function loadArtifact(contractName: string): { abi: any[] } {
const artifactPath = join(
import.meta.dirname,
`../out/${contractName}.sol/${contractName}.json`,
);
const artifact = JSON.parse(readFileSync(artifactPath, "utf-8"));
return { abi: artifact.abi };
}
import { loadArtifact, resolveChain } from "./scriptUtils.js";

// ABI fragments for v1 BaseRegistrar
const BASE_REGISTRAR_ABI = [
Expand Down Expand Up @@ -460,20 +449,7 @@ interface MigrationClients {
async function createMigrationClients(
config: PreMigrationConfig,
): Promise<MigrationClients> {
const tempClient = createPublicClient({
transport: http(config.rpcUrl, { retryCount: 0, timeout: RPC_TIMEOUT_MS }),
});
const chainId = await tempClient.getChainId();

const v2Chain: Chain =
chainId === 1
? mainnet
: defineChain({
id: chainId,
name: "Custom",
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
rpcUrls: { default: { http: [config.rpcUrl] } },
});
const v2Chain = await resolveChain(config.rpcUrl, RPC_TIMEOUT_MS);

const client = createWalletClient({
account: privateKeyToAccount(config.privateKey),
Expand Down
Loading
Loading