Skip to main content

Understanding the Registry Hierarchy

In ENS v2, each name can have its own dedicated registry contract that manages its direct subdomains. This creates a tree structure that mirrors the DNS hierarchy, enabling unprecedented flexibility in ownership models and access control.
Core Principle: A registry is responsible for exactly one name and all of its direct children. It has no knowledge of or control over its grandchildren or deeper descendants.

Registry Responsibility Scope

What a Registry Controls

Each registry contract manages:
  1. Direct Subdomains Only: Registration, ownership, and expiry of immediate children
  2. Subdomain Metadata: Resolver addresses and subregistry pointers for children
  3. Access Permissions: Role-based permissions for subdomain operations
  4. NFT Ownership: ERC1155 tokens representing subdomain ownership
Example: The registry for example.eth controls:
  • sub.example.eth
  • www.example.eth
  • deep.sub.example.eth (controlled by sub.example.eth’s registry)

Registry Boundaries

// The .eth registry
IRegistry ethRegistry = rootRegistry.getSubregistry("eth");

// It can register vitalik.eth
ethRegistry.register("vitalik", owner, subregistry, resolver, roles, expiry);

// But it has NO control over sub.vitalik.eth
// That's controlled by vitalik.eth's subregistry

The Registry Tree Structure

Hierarchical Organization

                    Root Registry ("")
                    |
         +----------+----------+
         |                    |
    .eth Registry        .box Registry
         |
    +----+----+
    |         |
vitalik   nick       
  Registry  Registry
    |         |
  sub     www
Each node in this tree:
  • Is represented by a registry contract (or null if no subregistry exists)
  • Implements the IRegistry interface
  • Maintains its own storage for direct children
  • Can have a completely different implementation from its parent

Traversal Example

Resolving sub.vitalik.eth involves:
// Start at root
IRegistry current = rootRegistry;

// Traverse to .eth
current = current.getSubregistry("eth");  // Returns .eth registry

// Traverse to vitalik.eth
current = current.getSubregistry("vitalik");  // Returns vitalik registry

// Get resolver for sub.vitalik.eth
address resolver = current.getResolver("sub");  // Returns resolver or address(0)
Reference: LibRegistry.sol:19-47

Registry Lifecycle

Creating a Subregistry

When you register a name, you can optionally specify a subregistry:
// Register without a subregistry
// sub.example.eth cannot have children
registry.register(
    "sub",           // label
    owner,           // owner address
    IRegistry(address(0)),  // no subregistry
    resolver,        // resolver address
    roles,           // initial roles
    expiry           // expiration time
);

Setting the Parent Relationship

Registries maintain a reference to their canonical parent:
// The subregistry should know its parent location
subRegistry.setParent(parentRegistry, "sub");

// Now it can report its canonical name
(IRegistry parent, string memory label) = subRegistry.getParent();
// parent = parentRegistry
// label = "sub"
This enables:
  • Canonical name construction
  • Verification that a registry is properly configured
  • Walking up the tree to the root
Reference: IRegistry.sol:68-72

Registry Patterns

1. Infinite Nesting

Create unlimited subdomain depth by chaining registries:
// Level 1: example.eth has a registry
IRegistry level1 = new PermissionedRegistry(...);
ethRegistry.register("example", owner1, level1, resolver1, roles1, expiry1);

// Level 2: sub.example.eth has a registry
IRegistry level2 = new PermissionedRegistry(...);
level1.register("sub", owner2, level2, resolver2, roles2, expiry2);

// Level 3: deep.sub.example.eth has a registry
IRegistry level3 = new PermissionedRegistry(...);
level2.register("deep", owner3, level3, resolver3, roles3, expiry3);

// And so on...

2. Different Registry Types

Each level can use a different registry implementation:
// .eth uses PermissionedRegistry with expiry
PermissionedRegistry ethRegistry = new PermissionedRegistry(...);

// vitalik.eth uses a custom registry with different rules
CustomRegistry customRegistry = new CustomRegistry(...);
ethRegistry.register("vitalik", owner, customRegistry, resolver, roles, expiry);

// As long as it implements IRegistry, it works!

3. Wildcard Resolution via No Subregistry

If a name has a resolver but no subregistry, the resolver can handle all subdomains:
// Register example.eth with resolver but NO subregistry
ethRegistry.register(
    "example",
    owner,
    IRegistry(address(0)),  // No subregistry
    wildcardResolver,       // This resolver handles *.example.eth
    roles,
    expiry
);

// Now ANY subdomain of example.eth will use wildcardResolver
// - sub.example.eth → wildcardResolver
// - www.example.eth → wildcardResolver
// - anything.example.eth → wildcardResolver
This pattern is extremely powerful for:
  • IPFS gateways ({cid}.ipfs.eth → IPFS resolver)
  • Decentralized websites (*.myapp.eth → app resolver)
  • Dynamic subdomains without gas costs per subdomain

Registry Discovery

Finding Registries in the Hierarchy

The UniversalResolverV2 provides helper functions for registry discovery:
// Get the registry for a name (if it's canonical)
IRegistry registry = universalResolver.findCanonicalRegistry(
    dnsEncode("vitalik.eth")
);

// Returns null if:
// - Registry doesn't exist
// - Registry's parent doesn't point to it
// - Registry's getParent() doesn't match
Reference: UniversalResolverV2.sol:21-50

Registry States

A position in the hierarchy can be in different states:

1. Non-Existent

IRegistry sub = parentRegistry.getSubregistry("nonexistent");
// sub == IRegistry(address(0))
// No one has registered this name yet

2. Registered Without Subregistry

IRegistry sub = parentRegistry.getSubregistry("exists");
// sub == IRegistry(address(0))

address resolver = parentRegistry.getResolver("exists");
// resolver != address(0)
// Name exists but has no subregistry (leaf node)

3. Registered With Subregistry

IRegistry sub = parentRegistry.getSubregistry("exists");
// sub != IRegistry(address(0))
// Name exists and can have children

4. Expired

// Name was registered but expired
IRegistry sub = parentRegistry.getSubregistry("expired");
// sub == IRegistry(address(0))  (treated as non-existent)

uint64 expiry = parentRegistry.getExpiry(tokenId);
// expiry < block.timestamp
Reference: PermissionedRegistry.sol:257-260

Advanced: Emancipated Subdomains

Create subdomains that the parent owner cannot control:
1

Create Restricted Subregistry

Deploy a registry where the owner has no root-level roles:
PermissionedRegistry emancipatedRegistry = new PermissionedRegistry(
    hcaFactory,
    metadata,
    subdomainOwner,
    0  // No root-level admin roles!
);
2

Lock the Subregistry Pointer

Set the subregistry and revoke the ability to change it:
// Set the subregistry
parentRegistry.setSubregistry(tokenId, emancipatedRegistry);

// Revoke the role to change it
parentRegistry.revokeRoles(
    tokenId,
    ROLE_SET_SUBREGISTRY,
    parentOwner
);
3

Result: True Emancipation

The parent owner now cannot:
  • Change the subregistry pointer
  • Interfere with subdomain registrations
  • Modify subdomain permissions
But they still own the parent name itself.
Careful! Emancipated names are permanent. Once you revoke ROLE_SET_SUBREGISTRY, you cannot undo it unless you also retained that role at the root level.

Canonical vs Non-Canonical Registries

Canonical Registry

A registry is canonical for a name if:
  1. Its parent’s getSubregistry(label) points to it
  2. Its getParent() returns the correct parent and label
  3. This relationship holds true all the way to the root
// Check if a registry is canonical
function isCanonical(IRegistry rootRegistry, IRegistry registry) 
    public view returns (bool) 
{
    bytes memory constructedName = LibRegistry.findCanonicalName(
        rootRegistry, 
        registry
    );
    return constructedName.length > 0;
}

Non-Canonical Registry

A registry becomes non-canonical if:
  • The parent changes the subregistry pointer
  • The registry’s parent reference is incorrect
  • The chain to root is broken
// Original registry
IRegistry oldRegistry = parentRegistry.getSubregistry("sub");

// Parent changes the pointer
parentRegistry.setSubregistry(tokenId, newRegistry);

// oldRegistry is now non-canonical
// It still exists but is disconnected from the hierarchy
Reference: LibRegistry.sol:97-119

Gas Optimization Strategies

Shared Registries

Multiple names can share the same registry implementation:
// Deploy one registry contract
PermissionedRegistry sharedRegistry = new PermissionedRegistry(...);

// Use it for multiple names
ethRegistry.register("alice", owner1, sharedRegistry, resolver1, roles1, expiry1);
ethRegistry.register("bob", owner2, sharedRegistry, resolver2, roles2, expiry2);

// ⚠️ WARNING: This is usually NOT what you want!
// Both names would share the same subdomain namespace

On-Demand Registry Creation

Only create subregistries when needed:
// Initially register without subregistry
registry.register("name", owner, IRegistry(address(0)), resolver, roles, expiry);

// Later, if subdomains are needed:
IRegistry newSubregistry = new PermissionedRegistry(...);
registry.setSubregistry(tokenId, newSubregistry);

Registry Factories

Use factory patterns for deterministic addresses:
interface IRegistryFactory {
    function createRegistry(
        address owner,
        uint256 roles
    ) external returns (IRegistry);
}

// Predictable registry creation
IRegistry registry = factory.createRegistry(owner, roles);

Integration Examples

Walking Up the Tree

function getFullPath(IRegistry registry, IRegistry root) 
    public view returns (string[] memory) 
{
    string[] memory labels = new string[](10);  // Max depth
    uint256 count = 0;
    
    IRegistry current = registry;
    while (address(current) != address(0) && address(current) != address(root)) {
        (IRegistry parent, string memory label) = current.getParent();
        labels[count++] = label;
        current = parent;
    }
    
    // Reverse and trim array
    return _reverseAndTrim(labels, count);
}

Walking Down the Tree

function resolveToRegistry(IRegistry root, string[] memory labels) 
    public view returns (IRegistry) 
{
    IRegistry current = root;
    
    for (uint256 i = labels.length; i > 0; i--) {
        current = current.getSubregistry(labels[i - 1]);
        if (address(current) == address(0)) {
            return IRegistry(address(0));  // Path doesn't exist
        }
    }
    
    return current;
}

Best Practices

When creating a subregistry, immediately set its parent reference:
IRegistry sub = new PermissionedRegistry(...);
sub.setParent(parentRegistry, "label");
This ensures canonical name resolution works correctly.
Before setting a subregistry, verify it implements IRegistry:
require(
    IERC165(address(subregistry)).supportsInterface(
        type(IRegistry).interfaceId
    ),
    "Invalid registry"
);
Child names should not outlive their parents:
// When registering a subdomain, ensure:
require(
    subdomainExpiry <= parentExpiry,
    "Subdomain cannot outlive parent"
);
Design your system to handle registry changes:
// Allow changing subregistry for upgrades
function upgradeSubregistry(uint256 tokenId, IRegistry newRegistry) external {
    require(hasRoles(tokenId, ROLE_SET_SUBREGISTRY, msg.sender));
    setSubregistry(tokenId, newRegistry);
}

Next Steps

Canonical ID System

Learn how token IDs work in the registry system

Resolution Process

Understand how the resolver traverses registries

PermissionedRegistry

Explore the full-featured registry implementation

Access Control

Deep dive into role-based permissions

Build docs developers (and LLMs) love