Skip to main content

Overview

The UnlockedMigrationController handles migration of unlocked .eth second-level domain (2LD) names from ENS v1 to the v2 registry system. This includes both unwrapped names (ERC721 tokens from the Registrar) and wrapped names without the CANNOT_UNWRAP fuse set.
Contract Location: contracts/src/migration/UnlockedMigrationController.solImplements IERC1155Receiver and IERC721Receiver to accept transfers from both NameWrapper and Registrar.

Supported Name Types

The controller handles two categories of unlocked names:

Unwrapped Names

ERC721 tokens held in the v1 Base Registrar, never wrapped in NameWrapper.

Unlocked Wrapped Names

ERC1155 tokens in NameWrapper without CANNOT_UNWRAP fuse, allowing unwrapping.

Architecture

Interface Implementation

Key Components

Handles wrapped name transfers from NameWrapper:
  • onERC1155Received(): Single name transfer
  • onERC1155BatchReceived(): Batch name transfer
  • Validates sender is NameWrapper
  • Unwraps names before migration
Handles unwrapped name transfers from Registrar:
  • onERC721Received(): Single name transfer
  • Validates sender is the Base Registrar
  • Directly migrates without unwrapping
Target registry for v2 names:
  • Direct registration without reserved status
  • Accepts full configuration in migration data
  • No subregistry deployment needed

Contract Interface

Constructor

constructor(
    INameWrapper nameWrapper,
    IPermissionedRegistry ethRegistry
)
Parameters:
  • nameWrapper: The v1 NameWrapper contract
  • ethRegistry: The v2 ETH Registry contract

Immutable State

INameWrapper public immutable NAME_WRAPPER;
IPermissionedRegistry public immutable ETH_REGISTRY;

Interface Support

function supportsInterface(bytes4 interfaceId) public view returns (bool) {
    return
        interfaceId == type(IERC1155Receiver).interfaceId ||
        interfaceId == type(IERC721Receiver).interfaceId ||
        super.supportsInterface(interfaceId);
}

Migration Flows

Unwrapped Name Migration (ERC721)

1

Prepare Migration Data

Create MigrationData with transfer details:
MigrationData memory data = MigrationData({
    transferData: TransferData({
        dnsEncodedName: dnsEncode("myname.eth"),
        owner: newOwner,
        subregistry: address(0),  // Can specify custom subregistry
        resolver: resolverAddress,
        roleBitmap: desiredRoles,
        expires: expiryTimestamp
    }),
    salt: 0  // Not used for unlocked migration
});
2

Transfer ERC721 to Controller

Transfer the name from the Registrar:
registrar.safeTransferFrom(
    msg.sender,
    address(unlockedMigrationController),
    tokenId,
    abi.encode(data)
);
3

Receive Hook Triggered

onERC721Received() is called by the Registrar.
4

Validate Caller

Verify transfer is from the Base Registrar:
if (msg.sender != address(NAME_WRAPPER.registrar())) {
    revert UnauthorizedCaller(msg.sender);
}
5

Validate Token ID

Ensure token ID matches label hash:
(bytes32 labelHash, ) = NameCoder.readLabel(dnsEncodedName, 0);
if (tokenId != uint256(labelHash)) {
    revert TokenIdMismatch(tokenId, uint256(labelHash));
}
6

Register in v2

Call ETH Registry to register:
ETH_REGISTRY.register(
    label,
    owner,
    IRegistry(subregistry),
    resolver,
    roleBitmap,
    expires
);

Wrapped Unlocked Name Migration (ERC1155)

1

Prepare Migration Data

Same as unwrapped migration - create MigrationData struct.
2

Transfer ERC1155 to Controller

Transfer from NameWrapper:
nameWrapper.safeTransferFrom(
    msg.sender,
    address(unlockedMigrationController),
    tokenId,
    1,
    abi.encode(data)
);
3

Receive Hook Triggered

onERC1155Received() or onERC1155BatchReceived() is called.
4

Validate Caller

Verify transfer is from NameWrapper:
if (msg.sender != address(NAME_WRAPPER)) {
    revert UnauthorizedCaller(msg.sender);
}
5

Check Lock Status

Verify name is unlocked:
(, uint32 fuses, ) = NAME_WRAPPER.getData(tokenId);
if (fuses & CANNOT_UNWRAP != 0) {
    revert MigrationNotSupported();
}
6

Unwrap Name

Controller unwraps the name to itself:
bytes32 labelHash = bytes32(tokenId);
NAME_WRAPPER.unwrapETH2LD(labelHash, address(this), address(this));
7

Validate and Register

Same validation and registration as unwrapped flow.

Migration Data Structures

TransferData

Contains the complete registration information:
struct TransferData {
    bytes dnsEncodedName;   // DNS wire format (e.g., "\x06myname\x03eth\x00")
    address owner;          // Owner in v2 system
    address subregistry;    // Subregistry contract (0x0 for none)
    address resolver;       // Resolver address
    uint256 roleBitmap;     // Permission roles bitmap
    uint64 expires;         // Expiration timestamp
}
Type: bytesDNS wire format encoding of the name:
  • First byte: label length
  • Next N bytes: label characters
  • Continues for each label
  • Terminates with 0x00
Example: “myname.eth” → 0x066d796e616d650365746800
  • 0x06: Length of “myname” (6)
  • 0x6d796e616d65: “myname” in hex
  • 0x03: Length of “eth” (3)
  • 0x657468: “eth” in hex
  • 0x00: Terminator
Type: addressOwner of the name in v2 system.Constraints:
  • Cannot be zero address
  • Receives roles specified in roleBitmap
Type: addressOptional subregistry contract.Usage:
  • address(0): No subregistry, standard name
  • Custom address: Specific subregistry implementation
  • Unlike locked migration, no automatic deployment
Type: addressResolver contract for name resolution.Usage:
  • Can be any valid resolver
  • Owner needs ROLE_SET_RESOLVER to change later
Type: uint256Bitmap of roles to grant the owner.Common Roles:
  • ROLE_RENEW: Can extend expiry
  • ROLE_SET_RESOLVER: Can change resolver
  • ROLE_CAN_TRANSFER_ADMIN: Can transfer name
  • ROLE_REGISTRAR: Can create subdomains (if has subregistry)
Type: uint64Expiration timestamp (Unix time).Constraints:
  • Must be in the future
  • Should match or extend v1 expiry

MigrationData

Wrapper for transfer data with optional salt:
struct MigrationData {
    TransferData transferData;  // Core migration data
    uint256 salt;              // Unused for unlocked migration
}
The salt field is present for consistency with locked migration but is not used by UnlockedMigrationController.

Batch Migration

Migrate multiple wrapped unlocked names in one transaction:
// Prepare migration data array
MigrationData[] memory dataArray = new MigrationData[](3);

dataArray[0] = MigrationData({
    transferData: TransferData({
        dnsEncodedName: dnsEncode("alice.eth"),
        owner: aliceOwner,
        subregistry: address(0),
        resolver: aliceResolver,
        roleBitmap: aliceRoles,
        expires: aliceExpiry
    }),
    salt: 0
});

// ... set dataArray[1] and dataArray[2]

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(unlockedMigrationController),
    tokenIds,
    amounts,
    abi.encode(dataArray)
);
Important: Batch migration only works for wrapped names. Unwrapped ERC721 names must be migrated individually.

Security Validations

Caller Authorization

if (msg.sender != address(NAME_WRAPPER)) {
    revert UnauthorizedCaller(msg.sender);
}

Lock Status Verification

(, uint32 fuses, ) = NAME_WRAPPER.getData(tokenId);

if (fuses & CANNOT_UNWRAP != 0) {
    // Name is locked - must use LockedMigrationController
    revert MigrationNotSupported();
}
Locked names (with CANNOT_UNWRAP) are rejected. They must use LockedMigrationController instead.

Token ID Validation

(bytes32 labelHash, ) = NameCoder.readLabel(dnsEncodedName, 0);

if (tokenId != uint256(labelHash)) {
    revert TokenIdMismatch(tokenId, uint256(labelHash));
}
This prevents:
  • Migrating wrong name for a token
  • Name/token mismatch attacks
  • Data encoding errors

Error Reference

UnauthorizedCaller(address caller)
error
Selector: 0x315ec9f5Transfer not from NameWrapper or Registrar.Solutions:
  • Use NameWrapper.safeTransferFrom() for wrapped names
  • Use Registrar.safeTransferFrom() for unwrapped names
TokenIdMismatch(uint256 tokenId, uint256 expectedTokenId)
error
Selector: 0x4fa09b3fToken ID doesn’t match label hash from DNS-encoded name.Solutions:
  • Verify DNS encoding is correct
  • Ensure label matches the token being transferred
  • Check for encoding errors
MigrationNotSupported()
error
Selector: 0x80da7148Attempted to migrate a locked name through unlocked controller.Solution: Use LockedMigrationController for locked names

Complete Example

Migrating an Unwrapped Name

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

import {IBaseRegistrar} from "@ens/contracts/ethregistrar/IBaseRegistrar.sol";
import {NameCoder} from "@ens/contracts/utils/NameCoder.sol";
import {UnlockedMigrationController} from "./UnlockedMigrationController.sol";
import {MigrationData, TransferData} from "./types/MigrationTypes.sol";
import {RegistryRolesLib} from "../registry/libraries/RegistryRolesLib.sol";

contract UnwrappedMigrationExample {
    IBaseRegistrar public immutable registrar;
    UnlockedMigrationController public immutable controller;
    
    constructor(address _registrar, address _controller) {
        registrar = IBaseRegistrar(_registrar);
        controller = UnlockedMigrationController(_controller);
    }
    
    function migrateUnwrappedName(
        string calldata label,
        address newOwner,
        address resolver
    ) external {
        uint256 tokenId = uint256(keccak256(bytes(label)));
        
        // Verify ownership
        require(registrar.ownerOf(tokenId) == msg.sender, "Not owner");
        
        // Get expiry from registrar
        uint64 expires = uint64(registrar.nameExpires(tokenId));
        
        // Build DNS-encoded name
        bytes memory dnsName = NameCoder.dnsEncodeName(
            string(abi.encodePacked(label, ".eth"))
        );
        
        // Define roles for new owner
        uint256 roles = RegistryRolesLib.ROLE_RENEW |
                       RegistryRolesLib.ROLE_RENEW_ADMIN |
                       RegistryRolesLib.ROLE_SET_RESOLVER |
                       RegistryRolesLib.ROLE_SET_RESOLVER_ADMIN |
                       RegistryRolesLib.ROLE_CAN_TRANSFER_ADMIN;
        
        // Create migration data
        MigrationData memory data = MigrationData({
            transferData: TransferData({
                dnsEncodedName: dnsName,
                owner: newOwner,
                subregistry: address(0),
                resolver: resolver,
                roleBitmap: roles,
                expires: expires
            }),
            salt: 0
        });
        
        // Transfer to controller (triggers migration)
        registrar.safeTransferFrom(
            msg.sender,
            address(controller),
            tokenId,
            abi.encode(data)
        );
    }
}

Migrating a Wrapped Unlocked Name

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

import {INameWrapper, CANNOT_UNWRAP} from "@ens/contracts/wrapper/INameWrapper.sol";
import {NameCoder} from "@ens/contracts/utils/NameCoder.sol";
import {UnlockedMigrationController} from "./UnlockedMigrationController.sol";
import {MigrationData, TransferData} from "./types/MigrationTypes.sol";
import {RegistryRolesLib} from "../registry/libraries/RegistryRolesLib.sol";

contract WrappedUnlockedMigrationExample {
    INameWrapper public immutable nameWrapper;
    UnlockedMigrationController public immutable controller;
    
    constructor(address _nameWrapper, address _controller) {
        nameWrapper = INameWrapper(_nameWrapper);
        controller = UnlockedMigrationController(_controller);
    }
    
    function migrateWrappedUnlockedName(
        string calldata label,
        address newOwner,
        address resolver
    ) external {
        uint256 tokenId = uint256(keccak256(bytes(label)));
        
        // Verify ownership
        require(nameWrapper.ownerOf(tokenId) == msg.sender, "Not owner");
        
        // Verify name is unlocked
        (, uint32 fuses, uint64 expiry) = nameWrapper.getData(tokenId);
        require(
            (fuses & CANNOT_UNWRAP) == 0,
            "Name is locked, use LockedMigrationController"
        );
        
        // Build DNS-encoded name
        bytes memory dnsName = NameCoder.dnsEncodeName(
            string(abi.encodePacked(label, ".eth"))
        );
        
        // Define roles
        uint256 roles = RegistryRolesLib.ROLE_RENEW |
                       RegistryRolesLib.ROLE_RENEW_ADMIN |
                       RegistryRolesLib.ROLE_SET_RESOLVER |
                       RegistryRolesLib.ROLE_SET_RESOLVER_ADMIN |
                       RegistryRolesLib.ROLE_CAN_TRANSFER_ADMIN;
        
        // Create migration data
        MigrationData memory data = MigrationData({
            transferData: TransferData({
                dnsEncodedName: dnsName,
                owner: newOwner,
                subregistry: address(0),
                resolver: resolver,
                roleBitmap: roles,
                expires: expiry
            }),
            salt: 0
        });
        
        // Transfer to controller (triggers migration)
        nameWrapper.safeTransferFrom(
            msg.sender,
            address(controller),
            tokenId,
            1,
            abi.encode(data)
        );
    }
}

Comparison with Locked Migration

FeatureUnlocked MigrationLocked Migration
Name StateUnlocked or unwrappedLocked with CANNOT_UNWRAP
Token TypeERC721 or unlocked ERC1155Locked ERC1155
UnwrappingAutomatic if wrappedNot supported
SubregistryOptional, user-specifiedAutomatic WrapperRegistry deployment
RolesUser-specified via roleBitmapTranslated from fuses
ReservationNot requiredMust be pre-reserved
ComplexitySimplerMore complex
Use CaseStandard namesPremium/locked names

Best Practices

Verify Lock Status

Always check CANNOT_UNWRAP before choosing controller.

Preserve Expiry

Use the current expiry from v1 to maintain registration period.

Set Appropriate Roles

Grant roles based on intended name usage and management needs.

Validate DNS Encoding

Ensure DNS-encoded name is correctly formatted to avoid mismatches.

Test on Testnet

Test migration flow on testnet before migrating mainnet names.

Handle Both Types

Support both wrapped and unwrapped migrations in your integration.

Common Patterns

Helper: DNS Encoding

function encodeDnsName(string memory label) internal pure returns (bytes memory) {
    return NameCoder.dnsEncodeName(
        string(abi.encodePacked(label, ".eth"))
    );
}

Helper: Standard Roles

function getStandardRoles() internal pure returns (uint256) {
    return RegistryRolesLib.ROLE_RENEW |
           RegistryRolesLib.ROLE_RENEW_ADMIN |
           RegistryRolesLib.ROLE_SET_RESOLVER |
           RegistryRolesLib.ROLE_SET_RESOLVER_ADMIN |
           RegistryRolesLib.ROLE_CAN_TRANSFER_ADMIN;
}

Helper: Check Lock Status

function isLocked(INameWrapper wrapper, uint256 tokenId) 
    internal 
    view 
    returns (bool) 
{
    (, uint32 fuses, ) = wrapper.getData(tokenId);
    return (fuses & CANNOT_UNWRAP) != 0;
}

Migration Overview

Understand the complete migration system

Locked Migration

Learn about locked name migration

Registry Roles

Understand the role-based permission system

ETH Registrar

Learn about the v2 ETH Registrar

Build docs developers (and LLMs) love