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 .` + ? `Optional: deploy an OwnedResolver with: ens resolver deploy ${owner} --chain ${chain}, then re-run commit/reveal with --resolver .` : undefined return { @@ -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() diff --git a/src/commands/resolver.ts b/src/commands/resolver.ts index 61e10bd..2157e49 100644 --- a/src/commands/resolver.ts +++ b/src/commands/resolver.ts @@ -34,7 +34,7 @@ export const resolverCommands = Cli.create('resolver', { }) .command('deploy', { description: - 'Generate calldata to deploy a per-account permissioned resolver via the ENSv2 VerifiableFactory. The resolver address is determined by (factory, proxyLogic, deployer, salt) and must be deployed from the deployer address. If a resolver already exists at the predicted address, returns alreadyDeployed=true with no transaction needed.', + 'Generate calldata to deploy an OwnedResolver via the ENSv2 VerifiableFactory. The resolver address is determined by (factory, proxyLogic, deployer, salt) and must be deployed from the deployer address. If a resolver already exists at the predicted address, returns alreadyDeployed=true with no transaction needed.', args: z.object({ deployer: z .string() @@ -115,12 +115,12 @@ export const resolverCommands = Cli.create('resolver', { nextSteps: alreadyDeployed ? [ `Resolver already deployed at ${proxy.address}. No transaction needed.`, - `Pass it as --resolver ${proxy.address} when running ens register.`, + `Pass it as --resolver ${proxy.address} when registering, migrating, or creating a subname.`, ] : [ `1. Broadcast this transaction from ${deployer}`, `2. Confirm the resolver is live at ${proxy.address}`, - `3. Pass it as --resolver ${proxy.address} when running ens register`, + `3. Pass it as --resolver ${proxy.address} when registering, migrating, or creating a subname`, ], } }, diff --git a/src/commands/subname.ts b/src/commands/subname.ts index 57321a4..cb14aca 100644 --- a/src/commands/subname.ts +++ b/src/commands/subname.ts @@ -29,7 +29,7 @@ export const subnameCommands = Cli.create('subname', { .string() .optional() .describe( - 'Resolver address for the subname (defaults to deployed owner owned resolver on ENSv2, chain public resolver on ENSv1)', + "Resolver address for the subname (defaults to the owner's deployed OwnedResolver on ENSv2, chain public resolver on ENSv1)", ), subregistry: z .string() diff --git a/src/lib/contracts.ts b/src/lib/contracts.ts index d00db7f..1bc050a 100644 --- a/src/lib/contracts.ts +++ b/src/lib/contracts.ts @@ -207,6 +207,8 @@ export const publicResolverAbi = [ export const baseRegistrarAbi = parseAbi([ 'function nameExpires(uint256 id) external view returns (uint256)', + 'function ownerOf(uint256 tokenId) external view returns (address)', + 'function safeTransferFrom(address from, address to, uint256 tokenId, bytes data) external', ]) export const ensRegistryAbi = parseAbi([ @@ -247,6 +249,9 @@ export const permissionedResolverAbi = parseAbi([ export const nameWrapperAbi = parseAbi([ 'function ownerOf(uint256 id) external view returns (address)', + 'function getData(uint256 id) external view returns (address owner, uint32 fuses, uint64 expiry)', + 'function getApproved(uint256 id) external view returns (address)', + 'function safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes data) external', 'function setResolver(bytes32 node, address resolver) external', 'function setSubnodeRecord(bytes32 parentNode, string label, address owner, address resolver, uint64 ttl, uint32 fuses, uint64 expiry) external returns (bytes32)', ]) @@ -275,6 +280,8 @@ export const addresses = { resolverImplementation: '0xdcE5205A553573FFd47629327DDdf36186022FfA', resolverProxyLogic: '0x917C561a74Df398646e06f3FFAA51DB8e8330C5A', subregistryImplementation: '0x0F99e7Ea74903AfCB7224d0354fD7428A6f92917', + lockedMigrationController: '0xF91c34ED840889Ed96F806f882fD50506A336Edb', + unlockedMigrationController: '0x056138Ef5660F7113a3B0ADC08ac3683310e7FBC', }, }, } as const