Skip to main content

Overview

The LockedMigrationController handles migration of locked .eth second-level domain (2LD) names from the ENS v1 NameWrapper to the v2 registry system. Locked names are those with the CANNOT_UNWRAP fuse set, indicating permanent commitment to the wrapped state.
Contract Location: contracts/src/migration/LockedMigrationController.solInherits from WrapperReceiver to handle ERC1155 token transfers and migration logic.

Architecture

Inheritance Structure

Key Components

Provides the core migration functionality:
  • ERC1155 receiver implementation
  • Fuse to role translation logic
  • Subregistry deployment via VerifiableFactory
  • Migration data validation
  • Batch migration support
Deploys deterministic subregistry contracts:
  • CREATE2 deployment for predictable addresses
  • Verification of deployment bytecode
  • Initialization with migration parameters
Target registry for v2 names:
  • Permissioned registration system
  • Role-based access control
  • Hierarchical subregistry support

Contract Interface

Constructor

constructor(
    IPermissionedRegistry ethRegistry,
    INameWrapper nameWrapper,
    VerifiableFactory verifiableFactory,
    address wrapperRegistryImpl
)
Parameters:
  • ethRegistry: The v2 ETH Registry contract
  • nameWrapper: The v1 NameWrapper contract
  • verifiableFactory: Factory for deploying WrapperRegistry subregistries
  • wrapperRegistryImpl: Implementation contract for WrapperRegistry proxies

Immutable State

IPermissionedRegistry public immutable ETH_REGISTRY;
INameWrapper public immutable NAME_WRAPPER;
VerifiableFactory public immutable VERIFIABLE_FACTORY;
address public immutable WRAPPER_REGISTRY_IMPL;

Migration Process

Step-by-Step Flow

1

Pre-Migration: Reserve Names

Names must be marked as RESERVED in the v2 ETH Registry before migration. This prevents registration through other flows.
// Performed by admin with ROLE_REGISTER_RESERVED
ethRegistry.setReserved(labelHash, true);
2

Prepare Migration Data

Encode the migration data for the name:
IWrapperRegistry.Data memory data = IWrapperRegistry.Data({
    label: "myname",
    owner: ownerAddress,
    resolver: resolverAddress,
    salt: saltValue
});

bytes memory encodedData = abi.encode(data);
3

Transfer to Controller

Transfer the locked name to the LockedMigrationController:
nameWrapper.safeTransferFrom(
    msg.sender,
    address(lockedMigrationController),
    tokenId,
    1,
    encodedData
);
4

Controller Receives Token

The onERC1155Received() hook is triggered, starting the migration process.
5

Validate Migration Data

Controller validates:
  • Token ID matches computed namehash
  • Name is locked (CANNOT_UNWRAP set)
  • Owner is not zero address
  • Name is not expired
6

Deploy Subregistry

A WrapperRegistry is deployed deterministically:
IRegistry subregistry = IRegistry(
    VERIFIABLE_FACTORY.deployProxy(
        WRAPPER_REGISTRY_IMPL,
        salt,
        initData
    )
);
7

Translate Fuses to Roles

v1 fuses are converted to v2 role bitmaps for both token and subregistry.
8

Register in v2

Name is registered in the ETH Registry:
ETH_REGISTRY.register(
    label,
    owner,
    subregistry,
    resolver,
    roleBitmap,
    0  // expiry (ignored for reserved names)
);
9

Burn Migration Fuses

If fuses aren’t frozen, migration-specific fuses are burned on the v1 token.

Fuse Translation

The migration translates v1 fuses to v2 roles through the _generateRoleBitmapsFromFuses() function:

Token Roles (Parent Registry Permissions)

if ((fuses & CAN_EXTEND_EXPIRY) != 0) {
    tokenRoles |= ROLE_RENEW;
    if (!fusesFrozen) {
        tokenRoles |= ROLE_RENEW_ADMIN;
    }
}

Subregistry Roles (Child Registry Permissions)

if ((fuses & CANNOT_CREATE_SUBDOMAIN) == 0) {
    registryRoles |= ROLE_REGISTRAR;
    if (!fusesFrozen) {
        registryRoles |= ROLE_REGISTRAR_ADMIN;
    }
}
Fuses Frozen: When CANNOT_BURN_FUSES is set, admin roles are not granted, making permissions permanent.

Resolver Handling

The migration intelligently handles resolver configuration:
if ((fuses & CANNOT_SET_RESOLVER) != 0) {
    // Resolver is locked, preserve it
    data.resolver = NAME_WRAPPER.ens().resolver(node);
} else {
    // Resolver is mutable, clear it
    NAME_WRAPPER.setResolver(node, address(0));
}
  • Current v1 resolver address is read from the registry
  • Resolver address is transferred to v2 registration
  • Ensures continuity of name resolution
  • v1 resolver is cleared to prevent confusion
  • v2 owner can set their own resolver
  • Grants ROLE_SET_RESOLVER and ROLE_SET_RESOLVER_ADMIN

Subregistry Deployment

CREATE2 Deployment

Subregistries are deployed deterministically using CREATE2:
address predictedAddress = VERIFIABLE_FACTORY.computeProxyAddress(
    WRAPPER_REGISTRY_IMPL,
    salt
);

IRegistry subregistry = IRegistry(
    VERIFIABLE_FACTORY.deployProxy(
        WRAPPER_REGISTRY_IMPL,
        salt,
        abi.encodeCall(
            IWrapperRegistry.initialize,
            IWrapperRegistry.ConstructorArgs({
                node: node,
                owner: owner,
                ownerRoles: registryRoles
            })
        )
    )
);

Benefits of Deterministic Deployment

Predictable Addresses

Subregistry addresses can be computed off-chain before deployment.

Verifiable Bytecode

VerifiableFactory ensures deployed bytecode matches expected implementation.

No Duplicate Deployments

CREATE2 prevents deploying the same subregistry twice.

Cross-Chain Consistency

Same salt produces same address across different chains.

Security Guarantees

Critical Requirements:
  1. Controller must have ROLE_REGISTER_RESERVED on ETH Registry
  2. Names must be pre-reserved before migration
  3. Only locked names (CANNOT_UNWRAP set) are accepted
  4. Token ID must match computed namehash

Validation Checks

modifier onlyWrapper() {
    if (msg.sender != address(NAME_WRAPPER)) {
        revert UnauthorizedCaller(msg.sender);
    }
    _;
}
Only the NameWrapper can trigger migration.
if ((fuses & CANNOT_UNWRAP) == 0) {
    revert NameNotLocked(uint256(node));
}
Ensures only locked names are migrated through this controller.
bytes32 node = bytes32(ids[i]);
bytes32 labelHash = keccak256(bytes(data.label));
if (node != NameCoder.namehash(parentNode, labelHash)) {
    revert NameDataMismatch(uint256(node));
}
Prevents name mismatch attacks.
if (data.owner == address(0)) {
    revert ERC1155InvalidReceiver(data.owner);
}
Ensures valid owner address.
assert(expiry >= block.timestamp);
Expired names cannot be transferred by NameWrapper.

Batch Migration

Migrate multiple locked names in a single transaction:
// Prepare migration data for multiple names
IWrapperRegistry.Data[] memory dataArray = new IWrapperRegistry.Data[](3);
dataArray[0] = IWrapperRegistry.Data("alice", aliceOwner, aliceResolver, salt1);
dataArray[1] = IWrapperRegistry.Data("bob", bobOwner, bobResolver, salt2);
dataArray[2] = IWrapperRegistry.Data("charlie", charlieOwner, charlieResolver, salt3);

uint256[] memory tokenIds = new uint256[](3);
tokenIds[0] = uint256(keccak256("alice"));
tokenIds[1] = uint256(keccak256("bob"));
tokenIds[2] = uint256(keccak256("charlie"));

uint256[] memory amounts = new uint256[](3);
amounts[0] = amounts[1] = amounts[2] = 1;

// Batch transfer
nameWrapper.safeBatchTransferFrom(
    msg.sender,
    address(lockedMigrationController),
    tokenIds,
    amounts,
    abi.encode(dataArray)
);
Gas Savings: Batch migration can save significant gas compared to individual transfers, especially for the ERC1155 transfer overhead.

Migration Data Structure

IWrapperRegistry.Data

struct Data {
    string label;       // Name label (e.g., "myname" for myname.eth)
    address owner;      // Owner address in v2
    address resolver;   // Resolver address (may be overridden)
    uint256 salt;       // CREATE2 salt for subregistry deployment
}

Field Details

Type: stringThe first label of the name being migrated. For “myname.eth”, this is “myname”.Constraints:
  • Length must be 1-255 bytes
  • Must match the token ID being transferred
Type: addressThe owner of the name in the v2 system.Constraints:
  • Cannot be zero address
  • Receives all granted roles
Type: addressResolver address for the name.Behavior:
  • If CANNOT_SET_RESOLVER is set: v1 resolver is used regardless of this value
  • If CANNOT_SET_RESOLVER is not set: This value is used as the resolver
Type: uint256Salt value for CREATE2 deployment of the WrapperRegistry subregistry.Usage:
  • Enables deterministic subregistry addresses
  • Should be unique per name to avoid deployment conflicts
  • Can be zero for simple deployments

Error Reference

LockedMigrationController Errors

UnauthorizedCaller(address caller)
error
Transfer not initiated by the NameWrapper contract.Solution: Only transfer through NameWrapper.safeTransferFrom()
NameNotLocked(uint256 tokenId)
error
Name doesn’t have CANNOT_UNWRAP fuse set.Solution: Use UnlockedMigrationController instead
NameDataMismatch(uint256 tokenId)
error
Token ID doesn’t match computed namehash from label.Solution: Verify label is correct for the token being migrated
ERC1155InvalidReceiver(address receiver)
error
Owner address is zero address.Solution: Provide valid owner address
InvalidData()
error
Migration data is too small or malformed.Solution: Ensure data is properly ABI-encoded

Example: Complete Migration

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {INameWrapper} from "@ens/contracts/wrapper/INameWrapper.sol";
import {IWrapperRegistry} from "./interfaces/IWrapperRegistry.sol";
import {LockedMigrationController} from "./LockedMigrationController.sol";

contract MigrationExample {
    INameWrapper public immutable nameWrapper;
    LockedMigrationController public immutable controller;
    
    constructor(address _nameWrapper, address _controller) {
        nameWrapper = INameWrapper(_nameWrapper);
        controller = LockedMigrationController(_controller);
    }
    
    function migrateLockedName(
        string calldata label,
        address newOwner,
        address resolver,
        uint256 salt
    ) external {
        // Compute token ID
        uint256 tokenId = uint256(keccak256(bytes(label)));
        
        // Verify caller owns the name
        require(
            nameWrapper.ownerOf(tokenId) == msg.sender,
            "Not owner"
        );
        
        // Verify name is locked
        (, uint32 fuses, ) = nameWrapper.getData(tokenId);
        require(
            fuses & CANNOT_UNWRAP != 0,
            "Name not locked"
        );
        
        // Prepare migration data
        IWrapperRegistry.Data memory data = IWrapperRegistry.Data({
            label: label,
            owner: newOwner,
            resolver: resolver,
            salt: salt
        });
        
        // Transfer to controller (triggers migration)
        nameWrapper.safeTransferFrom(
            msg.sender,
            address(controller),
            tokenId,
            1,
            abi.encode(data)
        );
    }
}

Best Practices

Verify Lock Status

Always verify CANNOT_UNWRAP is set before using locked migration.

Choose Salt Carefully

Use unique, deterministic salt values for predictable subregistry addresses.

Pre-Reserve Names

Ensure names are reserved in v2 registry before migration.

Test on Testnet

Test migration process on testnet before migrating mainnet names.

Verify Roles

Check resulting role bitmap matches expected permissions.

Batch When Possible

Use batch transfers for multiple names to save gas.

Migration Overview

Understand the complete migration system

Unlocked Migration

Learn about unlocked name migration

Access Control

Understand v2 role-based permissions

Wrapper Registry

Learn about the WrapperRegistry subregistry

Build docs developers (and LLMs) love