Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Universal Resolver V2

The Universal Resolver V2 is the primary public entry point for ENS name resolution. It resolves names by traversing the hierarchical registry tree, walking from the root registry down through subregistries to locate the correct resolver for any name. Its companion contract UniversalHelper provides functions for navigating and verifying the registry hierarchy itself.

Architecture

Both Universal Resolver versions share a base contract that implements all resolution logic: forward resolution, reverse resolution, CCIP-Read gateway batching, and callback chaining. The only function the base leaves abstract is findResolver(), which each version overrides with its own registry traversal strategy:

  • URv1 walks a flat ENS registry
  • URv2 walks the hierarchical registry tree via getSubregistry() at each level

Because the walk passes through each parent registry, if a parent name expires or its subregistry is removed, the registry returns address(0) and all subnames stop resolving automatically.

resolve(), reverse(), and all CCIP-Read infrastructure work identically across both versions.

Functions for inspecting the registry hierarchy itself live on the separate UniversalHelper contract, keeping the Universal Resolver focused on resolution.

Resolution

The Universal Resolver resolves names by walking down the registry tree from the root, looking for the deepest resolver along the path. At each level, it calls getResolver(label) on the current registry. If a resolver exists, it's remembered. Then it calls getSubregistry(label) to descend to the next level. The resolver that covers the longest matching suffix of the name wins.

resolve() and reverse() both call findResolver internally via requireResolver, which reverts if no suitable resolver is found.

Using the structure from the registry hierarchy diagrams, here are two examples showing how findResolver() walks the tree:

Example 1

Resolving inigo.montoya.eth: the walk descends from root, checking for resolvers at each level:

Resolver 1 is found at the .eth level, but Resolver 2 is found one level deeper at montoya.eth. Resolver 2 wins because it covers the longest matching suffix, in this case the full name inigo.montoya.eth.

Example 2

Resolving domingo.montoya.eth: the walk follows the same path, but domingo has no resolver:

Resolver 1 is found at the .eth level, but domingo has no resolver set. Resolver 1 wins as the longest-suffix match, covering montoya.eth. Any subname of montoya.eth without its own resolver will fall back to Resolver 1 the same way.

The Algorithm

findResolver() implements this longest-suffix match. It recursively descends from the root, and at each level:

  1. Calls getResolver(label). If non-zero, overwrites the previously remembered resolver.
  2. Calls getSubregistry(label). If non-zero, continues descending into the subregistry.

The final remembered resolver is the one used for the actual record query.

UniversalHelper

ENSv2 provides functions for locating registries within the hierarchy and verifying their position. All functions described here are exposed on the UniversalHelper contract, a companion to the Universal Resolver deployed alongside it (see Deployments).

All navigation functions walk down from the root registry except findCanonicalName, which walks up via getParent(), and findCanonicalRegistry, which does both.

The findNearest* functions return a byte offset alongside their result. It marks the label boundary in the DNS-encoded input where the returned result was found: cutting the input bytes at that position (name[offset:] in pseudocode, where name is the DNS-encoded byte array) yields the ancestor's own DNS-encoded name, because every suffix of a DNS-encoded name that starts at a label boundary is itself a valid DNS-encoded name. The cut result can be passed directly to any other function that takes a name (onchain via a bytes slice such as BytesUtils.substring, offchain by slicing the byte array).

findExactRegistry and findNearestRegistry

findExactRegistry

Walks top-down from root, calling getSubregistry() at each label. Returns the subregistry that the target name points to, or address(0) if any link in the chain is missing.

For nick.eth:

root
└── getSubregistry("eth") → .eth registry
    └── getSubregistry("nick") → nick.eth subregistry ← returned

This is the registry where nick.eth's subnames live. If nick.eth has no subregistry set, the final step returns address(0).

findNearestRegistry

The tolerant variant: instead of failing when the chain stops early, it returns the deepest registry found along the path, together with the offset of the ancestor name it belongs to, so that findExactRegistry(name[offset:]) returns the same registry. An offset of 0 means the exact registry exists.

findParentRegistry

Walks top-down to find the registry that contains a name's entry, rather than the subregistry the name points to. It strips the first label and calls findExactRegistry on the parent suffix.

For nick.eth:

root
└── getSubregistry("eth") → .eth registry ← returned

The .eth registry is where nick is an entry. Contrast with findExactRegistry("nick.eth"), which returns nick.eth's own subregistry (one level deeper).

This is the registry that holds ownership information for a name, which is what findExactOwner queries.

findRegistries

Walks top-down and builds the complete ancestry array for a name, from innermost to outermost. Each position in the array corresponds to one label in the name, with the root registry appended at the end.

findRegistries("")           → [<root>]
findRegistries("eth")        → [<eth>, <root>]
findRegistries("nick.eth")   → [<nick>, <eth>, <root>]
findRegistries("sub.nick.eth") → [address(0), <nick>, <eth>, <root>]

If a name has no subregistry at some level, that position in the array is address(0). In the last example, sub.nick.eth has no subregistry, so the first element is zero, but its parent registries are all present.

findCanonicalName

Because registries have no inherent concept of "their name" (a registry can be mounted at multiple positions via namespace aliasing), a canonical name only exists when both directions of the hierarchy agree: setParent() establishes the backward pointer, and findCanonicalName verifies at each step that the parent's forward pointer (getSubregistry) points back to the same registry.

For the nick.eth subregistry (walking upward):

nick.eth subregistry
├── getParent() → (.eth registry, "nick")
│   └── verify: eth.getSubregistry("nick") == nick.eth subregistry ✓
└── .eth registry
    ├── getParent() → (root, "eth")
    │   └── verify: root.getSubregistry("eth") == .eth registry ✓
    └── root reached → canonical name: "nick.eth"

Returns empty bytes if any link is broken: if a registry has no parent set, or if parent.getSubregistry(label) points to a different address.

findCanonicalRegistry

findCanonicalRegistry verifies this by combining both directions:

For nick.eth:

Down: findExactRegistry("nick.eth")
└── root.getSubregistry("eth") → .eth registry
    └── eth.getSubregistry("nick") → 0xABCD

Up: findCanonicalName(0xABCD)
├── 0xABCD.getParent() → (.eth registry, "nick")
│   └── verify: eth.getSubregistry("nick") == 0xABCD ✓
└── reconstructed name: "nick.eth" == input name ✓ → return 0xABCD

The bidirectional check prevents aliasing attacks: a registry could be mounted at one position in the tree but claim (via getParent()) to be at another. This is the function to use when verifying a registry's legitimacy, for example in marketplaces or any context where a name is being purchased or trusted.

findExactOwner and findNearestOwner

findExactOwner

Finds the current owner of a name. IOwnedRegistry is a minimal interface that extends IRegistry with a single findOwner(label) function, returning the owner of a label. PermissionedRegistry implements it, but any custom registry with ownership can too.

For nick.eth:

findParentRegistry("nick.eth") → .eth registry
├── supports IOwnedRegistry? (ERC-165 check) ✓
└── .eth.findOwner("nick") → 0x1234 ← returned

Returns address(0) if the parent registry doesn't exist, doesn't implement IOwnedRegistry, or the label has no owner (unregistered, expired, or reserved).

findNearestOwner

Returns the owner of the closest owned ancestor instead: the deepest owner found along the path, together with the offset of the ancestor it belongs to, so that findExactOwner(name[offset:]) returns the same owner. An offset of 0 means the name itself is owned.

This answers "who is responsible for this name?" for subnames that exist only as resolver data: a name with no registry entry of its own has no owner, but its closest registered ancestor does.

Reference

Universal Resolver Functions

findResolver(name)Find the resolver for a name by walking down the registry hierarchy.
resolve(name, data)Forward-resolve a single record for a DNS-encoded name. For batch resolution, encode data as multicall(bytes[]).
resolveWithGateways(name, data, gateways)Same as resolve, but with custom CCIP-Read gateway URLs.
resolveWithResolver(resolver, name, data, gateways)Resolve using a specific resolver address, bypassing the findResolver lookup.
reverse(lookupAddress, coinType)Reverse resolution per ENSIP-19: look up the primary name for an address, then verify via forward resolution.
reverseWithGateways(lookupAddress, coinType, gateways)Same as reverse, but with custom CCIP-Read gateway URLs.
requireResolver(name)Same as findResolver, but reverts if no suitable resolver is found. Reverts with ResolverNotFound if: no resolver exists, or the resolver does not support IExtendedResolver and was found at a parent suffix (non-zero offset). Reverts with ResolverNotContract if the resolver was matched exactly (zero offset), does not support IExtendedResolver, and has no deployed code. Used internally by resolve() and reverse().
resolveWithNormalization(name, data, ensip15)Like resolve, but first normalizes the name onchain via the supplied ENSIP-15 normalizer contract. Intended for future use, no normalizer contract is deployed yet. The variant resolveWithGatewaysAndNormalization additionally takes custom gateway URLs.
reverseWithNormalization(lookupAddress, coinType, ensip15)Like reverse, but verifies onchain that the returned primary name is normalized per the supplied ENSIP-15 normalizer contract. Intended for future use. The variant reverseWithGatewaysAndNormalization additionally takes custom gateway URLs.
normalize(name, ensip15)Normalize a DNS-encoded name label by label via the supplied ENSIP-15 normalizer contract.
isENSv2Marker distinguishing the v2 Universal Resolver from v1. Returns true.

UniversalHelper Functions

findExactOwner(name)Find the current owner of a name.
findNearestOwner(name)Find the owner of the closest owned ancestor of a name.
findExactRegistry(name)Find the registry at a name's position by walking down from root.
findNearestRegistry(name)Find the deepest registry along the path of a name.
findParentRegistry(name)Find the parent registry for a name (the registry containing the name's entry).
findRegistries(name)Return all registries in a name's ancestry, innermost first.
findCanonicalName(registry)Reconstruct a registry's DNS-encoded name by walking up via getParent().
findCanonicalRegistry(name)Find the registry for a name, verified canonical via bidirectional walk.

Constants

ROOT_REGISTRYThe ENSv2 root registry. All hierarchy traversal starts from this address. Set once at deployment (immutable). UniversalHelper exposes the same constant.
batchGatewayProviderDefault gateway provider for CCIP-Read batching. Set once at deployment (immutable).

Errors

ResolverNotFound(name)No resolver found for the name.
ResolverNotContract(name, resolver)Resolver address has no deployed code.
UnsupportedResolverProfile(selector)Resolver doesn't support the requested function.
ResolverError(errorData)Resolver reverted during resolution.
ReverseAddressMismatch(primary, primaryAddress)Forward resolution of the primary name doesn't match the original address.
NormalizationChangedName(normalizedName, result, resolver)resolveWithNormalization (or its gateway variant) was given a non-normalized name. The error carries the result for the normalized name instead.
PrimaryNameNotNormalized(primary)reverseWithNormalization (or its gateway variant) found a primary name that is not normalized.
HttpError(status, message)HTTP error from a CCIP-Read gateway.