Skip to content
Merged
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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Transaction must be sent from the name owner (or an approved operator).

### Resolver deployment (ENSv2)

ENSv2 names use per-account `PermissionedResolver` proxies. The v1 Public Resolver can't be reused because its authorisation is gated by the v1 registry, which knows nothing about v2-registered names. Each owner can deploy their own resolver through the v2 `VerifiableFactory`; ENSv2 registration and subname creation commands use that resolver by default when it is already deployed.
ENSv2 names use an OwnedResolver, a per-account `PermissionedResolver` proxy. The v1 Public Resolver can't be reused for updates because its authorisation is gated by the v1 registry, which knows nothing about v2-registered names. Each owner can deploy their own resolver through the v2 `VerifiableFactory`; ENSv2 registration, migration, and subname creation commands use that resolver by default when it is already deployed.

```sh
# Predicts the CREATE2 address and emits deployProxy calldata.
Expand Down Expand Up @@ -139,6 +139,30 @@ ens price myname.eth
ens renew myname.eth --value 2307947853431408 --json
```

### Migrating from ENSv1 to ENSv2

Generate the one-way migration transaction for a pre-migrated (reserved) `.eth` 2LD. The command reads the v1 owner, resolver, wrapping state, and fuses, then targets the unlocked or locked migration controller as appropriate.

```sh
ens migrate myname.eth --chain sepolia --json
# Returns: { to, data, value, kind, currentOwner, owner, controller, ... }

# Optionally change v2 configuration during migration
ens migrate myname.eth \
--chain sepolia \
--owner 0xNewOwner \
--resolver 0xResolver \
--subregistry 0xSubregistry
```

The transaction must be sent by the current ENSv1 token owner or an approved operator. Unwrapped names transfer the Base Registrar ERC-721 to the unlocked controller. Wrapped names transfer the NameWrapper ERC-1155 to either the unlocked or locked controller based on `CANNOT_UNWRAP`. Locked migration deploys its own `WrapperRegistry`, so `--subregistry` is ignored in that case.

Unless `--resolver` is provided, migration uses the new owner's canonical OwnedResolver when it is already deployed. Otherwise it preserves the current ENSv1 resolver for record continuity and returns a flag recommending that an OwnedResolver be deployed before regenerating the migration. Locked names with `CANNOT_SET_RESOLVER` are excluded because their resolver payload is ignored.

Wrapped-name flags also describe how the command inferred the migration path, the sender authorization it assumes, and any fuse-dependent behavior to review before broadcasting. For example, `CANNOT_APPROVE` with a frozen token approval is expected to revert.

Migration only works after the name has been reserved in ENSv2 and cannot be reversed. Names in the ENSv1 grace period must be renewed through `ETHRenewerV1` before migration. Test on Sepolia before migrating valuable names.

### Subnames

Generate calldata to create a subname under a parent you own. On ENSv2, the command walks the registry hierarchy and targets the parent's subregistry. If the parent has no subregistry, deploy one with `ens subregistry deploy` and set it with `ens subregistry set` first. On ENSv1, the command reads the parent's onchain owner; if the parent is wrapped in the NameWrapper, the calldata targets the NameWrapper instead of the registry.
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { setCommands } from './commands/set.ts'
import { subnameCommands } from './commands/subname.ts'
import { subregistryCommands } from './commands/subregistry.ts'
import { whoisCommand } from './commands/whois.ts'
import { migrateCommand } from './commands/migrate.ts'

export const cli = Cli.create('ens', {
version: packageJson.version,
Expand All @@ -31,3 +32,4 @@ export const cli = Cli.create('ens', {
.command(setCommands)
.command(subnameCommands)
.command(subregistryCommands)
.command(migrateCommand)
314 changes: 314 additions & 0 deletions src/commands/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
import { Cli, z } from 'incur'
import { zeroAddress } from 'viem'
import { encodeAbiParameters, encodeFunctionData, getAddress, isAddressEqual } from 'viem/utils'
import { labelhash, namehash } from 'viem/ens'
import {
addresses,
baseRegistrarAbi,
ensRegistryAbi,
nameWrapperAbi,
v2RegistryAbi,
} from '../lib/contracts.ts'
import { activeV2Deployment, clientFromContext, globalEnv, globalOptions } from '../lib/context.ts'
import { eth2ldLabel, validateName } from '../lib/utils.ts'
import { resolveDeployedOwnedResolver } from '../lib/v2.ts'

// ENSv1 NameWrapper fuse bitmap values mirrored from INameWrapper.sol.
// Source: https://github.com/ensdomains/ens-contracts/blob/3b1cc225ccdf64581d5fdc81db574f51ba5c8c09/contracts/wrapper/INameWrapper.sol#L10-L16
const CANNOT_UNWRAP = 1 // bit 0
const CANNOT_TRANSFER = 4 // bit 2
const CANNOT_SET_RESOLVER = 8 // bit 3
const CANNOT_APPROVE = 64 // bit 6

// Solidity assigns zero-based ordinals to IPermissionedRegistry.Status:
// AVAILABLE = 0, RESERVED = 1, REGISTERED = 2.
// Source: https://github.com/ensdomains/contracts-v2/blob/48b3e2d39513b9dd32ef1850877a29009bc807b9/contracts/src/registry/interfaces/IPermissionedRegistry.sol#L16-L20
const V2_STATUS_RESERVED = 1
const V2_STATUS_REGISTERED = 2

export const migrateCommand = Cli.create('migrate', {
description: 'Migrate a .eth name from ENSv1 to ENSv2.',
hint: 'Generates unsigned calldata only. The name must be a pre-migrated RESERVED .eth 2LD. The command detects unwrapped, wrapped-unlocked, and wrapped-locked names and selects the matching controller. Review all returned flags before broadcasting. The transaction is one-way and must be sent by the current ENSv1 owner or an approved operator.',
args: z.object({
name: z.string().describe('ENSv1 .eth name to migrate (e.g. myname.eth)'),
}),
options: globalOptions.merge(
z.object({
owner: z
.string()
.optional()
.describe('ENSv2 owner after migration (default: current ENSv1 token owner)'),
resolver: z
.string()
.optional()
.describe(
'ENSv2 resolver after migration (default: deployed OwnedResolver for the v2 owner, otherwise current ENSv1 resolver)',
),
subregistry: z
.string()
.optional()
.describe(
'ENSv2 subregistry for an unlocked name (default: zero address; ignored for locked names, whose WrapperRegistry is deployed automatically)',
),
lockedController: z
.string()
.optional()
.describe('Override the configured LockedMigrationController address'),
unlockedController: z
.string()
.optional()
.describe('Override the configured UnlockedMigrationController address'),
}),
),
examples: [
{
description: 'Migrate a reserved Sepolia name with its current owner and default resolver',
args: { name: 'myname.eth' },
options: { chain: 'sepolia' },
},
{
description: 'Choose the ENSv2 owner, resolver, and unlocked-name subregistry',
args: { name: 'myname.eth' },
options: {
chain: 'sepolia',
owner: '0x0000000000000000000000000000000000000001',
resolver: '0x0000000000000000000000000000000000000002',
subregistry: '0x0000000000000000000000000000000000000003',
},
},
],
env: globalEnv,
async run(c) {
const { client, chain } = clientFromContext(c)
const name = validateName(c.args.name)
const label = eth2ldLabel(name)
if (label == null) {
throw new Error(`ENSv1 to v2 migration currently only supports .eth 2LD names. Got: ${name}`)
}

const v2Deployment = await activeV2Deployment(c)
if (!v2Deployment) {
throw new Error(`ENSv2 migration is not active or configured for chain "${chain}"`)
}

const registry = addresses[chain].registry
const baseRegistrar = addresses[chain].baseRegistrar
const nameWrapper = addresses[chain].nameWrapper
const node = namehash(name)
const nodeId = BigInt(node)
const labelId = BigInt(labelhash(label))

const [{ status }, registryOwner, currentResolver, v1Expiry, latestBlock] = await Promise.all([
client.readContract({
address: v2Deployment.registry,
abi: v2RegistryAbi,
functionName: 'getState',
args: [labelId],
}),
client.readContract({
address: registry,
abi: ensRegistryAbi,
functionName: 'owner',
args: [node],
}),
client.readContract({
address: registry,
abi: ensRegistryAbi,
functionName: 'resolver',
args: [node],
}),
client.readContract({
address: baseRegistrar,
abi: baseRegistrarAbi,
functionName: 'nameExpires',
args: [labelId],
}),
client.getBlock(),
])

if (status === V2_STATUS_REGISTERED) {
throw new Error(`"${name}" is already registered in ENSv2`)
}
if (status !== V2_STATUS_RESERVED) {
throw new Error(
`"${name}" is not reserved for ENSv1 migration in ENSv2 (status=${status}). Only pre-migrated RESERVED names can be migrated.`,
)
}
if (v1Expiry <= latestBlock.timestamp) {
throw new Error(
`"${name}" is expired in ENSv1 and cannot be migrated during the v1 grace period. Renew it through ETHRenewerV1, then retry. (v1Expiry=${v1Expiry}, blockTimestamp=${latestBlock.timestamp})`,
)
}

const wrapped = isAddressEqual(registryOwner, nameWrapper)
let currentOwner: `0x${string}`
let fuses = 0

if (wrapped) {
const wrapperData = await client.readContract({
address: nameWrapper,
abi: nameWrapperAbi,
functionName: 'getData',
args: [nodeId],
})
currentOwner = wrapperData[0]
fuses = wrapperData[1]
} else {
currentOwner = await client.readContract({
address: baseRegistrar,
abi: baseRegistrarAbi,
functionName: 'ownerOf',
args: [labelId],
})
}

if (isAddressEqual(currentOwner, zeroAddress)) {
throw new Error(`"${name}" has no current ENSv1 token owner and cannot be migrated`)
}

const kind = !wrapped
? 'unwrapped'
: (fuses & CANNOT_UNWRAP) !== 0
? 'wrapped-locked'
: 'wrapped-unlocked'
if (kind === 'wrapped-locked' && (fuses & CANNOT_TRANSFER) !== 0) {
throw new Error(
`"${name}" has the CANNOT_TRANSFER fuse burned. ENSv1 NameWrapper blocks the transfer required for migration.`,
)
}

const tokenApproval =
kind === 'wrapped-locked' && (fuses & CANNOT_APPROVE) !== 0
? await client.readContract({
address: nameWrapper,
abi: nameWrapperAbi,
functionName: 'getApproved',
args: [nodeId],
})
: zeroAddress

const owner = c.options.owner ? getAddress(c.options.owner) : currentOwner
const resolverPayloadIgnored = kind === 'wrapped-locked' && (fuses & CANNOT_SET_RESOLVER) !== 0
const ownedResolver =
c.options.resolver == null && !resolverPayloadIgnored
? await resolveDeployedOwnedResolver({
client,
factory: v2Deployment.resolverFactory,
proxyLogic: v2Deployment.resolverProxyLogic,
owner,
})
: zeroAddress
const resolver = c.options.resolver
? getAddress(c.options.resolver)
: !isAddressEqual(ownedResolver, zeroAddress)
? ownedResolver
: currentResolver
const subregistry = c.options.subregistry ? getAddress(c.options.subregistry) : zeroAddress
const lockedController = c.options.lockedController
? getAddress(c.options.lockedController)
: v2Deployment.lockedMigrationController
const unlockedController = c.options.unlockedController
? getAddress(c.options.unlockedController)
: v2Deployment.unlockedMigrationController
for (const [field, address] of [
['owner', owner],
['locked controller', lockedController],
['unlocked controller', unlockedController],
] as const) {
if (isAddressEqual(address, zeroAddress))
throw new Error(`${field} cannot be the zero address`)
}

const flags: string[] = []
if (
c.options.resolver == null &&
!resolverPayloadIgnored &&
isAddressEqual(ownedResolver, zeroAddress)
) {
flags.push(
`Recommendation: no canonical OwnedResolver is deployed for ${owner}, so the current ENSv1 resolver is being reused. Verify it authorizes the ENSv2 owner to update records, or run "ens resolver deploy ${owner} --chain ${chain}" and regenerate this migration with --resolver <address>.`,
)
}
if (wrapped) {
flags.push(
`Assumption: registry ownership by ${nameWrapper} identifies the name as wrapped; the CANNOT_UNWRAP fuse selects the ${kind} path.`,
'Assumption: the sender will be the current token owner or an approved operator; authorization is not checked.',
)
if (resolverPayloadIgnored) {
flags.push(
'Warning: CANNOT_SET_RESOLVER causes the resolver payload to be ignored; the v1 resolver is preserved and a known PublicResolver may be replaced with PublicResolverV2.',
)
}
if (
kind === 'wrapped-locked' &&
(fuses & CANNOT_APPROVE) !== 0 &&
!isAddressEqual(tokenApproval, zeroAddress)
) {
flags.push(
`Warning: CANNOT_APPROVE is burned while getApproved() is ${tokenApproval}; migration is expected to revert with FrozenTokenApproval.`,
)
}
if (kind === 'wrapped-locked' && c.options.subregistry != null) {
flags.push(
'Warning: the locked controller ignores --subregistry and deploys a WrapperRegistry from the v1 fuse state.',
)
}
if (
(kind === 'wrapped-locked' && c.options.lockedController != null) ||
(kind === 'wrapped-unlocked' && c.options.unlockedController != null)
) {
flags.push(
'Warning: the controller override is assumed compatible with the configured NameWrapper and ENSv2 registry; its bytecode and configuration are not verified.',
)
}
}

const payload = encodeAbiParameters(
[
{
type: 'tuple',
components: [
{ name: 'label', type: 'string' },
{ name: 'owner', type: 'address' },
{ name: 'subregistry', type: 'address' },
{ name: 'resolver', type: 'address' },
],
},
],
[{ label, owner, subregistry, resolver }],
)
const controller = kind === 'wrapped-locked' ? lockedController : unlockedController
const transaction =
kind === 'unwrapped'
? {
to: baseRegistrar,
data: encodeFunctionData({
abi: baseRegistrarAbi,
functionName: 'safeTransferFrom',
args: [currentOwner, controller, labelId, payload],
}),
}
: {
to: nameWrapper,
data: encodeFunctionData({
abi: nameWrapperAbi,
functionName: 'safeTransferFrom',
args: [currentOwner, controller, nodeId, 1n, payload],
}),
}

return {
to: transaction.to,
data: transaction.data,
value: '0',
name,
kind,
currentOwner,
owner,
resolver,
...(kind === 'wrapped-locked' ? {} : { subregistry }),
flags,
controller,
}
},
})
6 changes: 3 additions & 3 deletions src/commands/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const registerCommands = Cli.create('register', {
.string()
.optional()
.describe(
'Resolver address (defaults to chain public resolver on ENSv1; on ENSv2, defaults to the owner owned resolver if deployed, otherwise zero address).',
"Resolver address (defaults to chain public resolver on ENSv1; on ENSv2, defaults to the owner's deployed OwnedResolver, otherwise zero address).",
),
subregistry: z
.string()
Expand Down Expand Up @@ -117,7 +117,7 @@ export const registerCommands = Cli.create('register', {

const resolverHint =
resolver === zeroAddress
? `Optional: deploy a per-account resolver with: ens resolver deploy ${owner} --chain ${chain}, then re-run commit/reveal with --resolver <addr>.`
? `Optional: deploy an OwnedResolver with: ens resolver deploy ${owner} --chain ${chain}, then re-run commit/reveal with --resolver <addr>.`
: undefined

return {
Expand Down Expand Up @@ -227,7 +227,7 @@ export const registerCommands = Cli.create('register', {
.string()
.optional()
.describe(
'Resolver address (must match commit; defaults to deployed owner owned resolver on ENSv2, otherwise zero)',
"Resolver address (must match commit; defaults to the owner's deployed OwnedResolver on ENSv2, otherwise zero)",
),
subregistry: z
.string()
Expand Down
Loading
Loading