diff --git a/README.md b/README.md index 465e15f..6fcb42e 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/src/cli.ts b/src/cli.ts index 3085fef..52b9e0b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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, @@ -31,3 +32,4 @@ export const cli = Cli.create('ens', { .command(setCommands) .command(subnameCommands) .command(subregistryCommands) + .command(migrateCommand) diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts new file mode 100644 index 0000000..aaaa231 --- /dev/null +++ b/src/commands/migrate.ts @@ -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
.`, + ) + } + 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, + } + }, +}) diff --git a/src/commands/register.ts b/src/commands/register.ts index cf629f2..3030845 100644 --- a/src/commands/register.ts +++ b/src/commands/register.ts @@ -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() @@ -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