For App Developers
This guide walks through integrating ENSv2 into an application: resolving names like nick.eth to addresses, reading profile records, displaying primary names, and letting users update their own records.
What Changes for Apps
The short answer: very little, by design. Forward resolution, text records, avatars, and primary names all work through the same library calls you use today; for read-only integrations, a library update is the entire migration. The ENSv2-specific parts of this page start at Writing Records: letting users update their records touches the new permission model.
What changes underneath:
- One entry point, new address. All resolution goes through the Universal Resolver, which walks the new hierarchical registries and orchestrates CCIP-Read (the actual gateway HTTP requests are made by your client library, as in ENSv1; a contract cannot fetch offchain data itself). Updated libraries target it automatically.
- Records move to per-account resolvers. In ENSv1 most names shared a single Public Resolver contract. In the standard ENSv2 flow each account gets its own Permissioned Resolver instance. Reading records is unchanged (the Universal Resolver finds the right contract for you), but writing records means calling whatever resolver the name actually uses instead of a well-known shared one.
- Names are ERC1155 tokens in per-name registries. Each parent name can have its own registry contract, so subnames form separate NFT collections. Token IDs are mutable, which matters if you index or cache them.
- ENSv1 names keep working. Names that have not migrated resolve through a mirror resolver that forwards lookups into the v1 registry, so your integration does not need to distinguish between migrated and unmigrated names.
Supported Libraries
The examples in this guide show four libraries side by side: viem, wagmi (which inherits ENS support from its installed viem), ethers, and ENSjs. Everything below assumes an ENSv2-ready version of your library; the ENSv2 readiness page tracks which versions those are, both for these four and for the wider ecosystem (web3.py, web3j, and others). ENSjs stands out for write flows: among the four shown here, it is the only one with purpose-built helpers for updating records.
Project Setup
ENSv2 is currently deployed on Sepolia for testing. The Universal Resolver is an upgradeable proxy that lives at the same address on mainnet and Sepolia, and supported libraries ship that address for both networks. Targeting the ENSv2 test deployment is therefore nothing more than selecting the Sepolia chain; no address configuration is needed, and the same code runs against mainnet by switching the chain back.
import { createPublicClient, http } from 'viem'
import { sepolia } from 'viem/chains'
const client = createPublicClient({
chain: sepolia,
transport: http(),
})The client, config, and provider objects created here are reused by every snippet below. Write snippets additionally assume a connected wallet client (wallet in viem/ENSjs, signer in ethers).
Resolving Names
Forward resolution (name to address) is one call. Always normalize user input first:
import { normalize } from 'viem/ens'
const address = await client.getEnsAddress({
name: normalize('nick.eth'),
})Under the hood, the Universal Resolver starts at the root registry and walks down one label at a time, asking each registry for the next one (sub.nick.eth: root to eth to nick to sub). Along the way it remembers the nearest resolver it has seen and calls it. When a name's data lives offchain or on an L2, the Universal Resolver drives the CCIP-Read protocol by telling your library which gateway to query; the library performs the HTTP request and feeds the response back for onchain verification. Your app never touches this machinery directly, but one consequence is worth knowing:
- A subname without its own resolver is served by the closest ancestor resolver. Registration alone is enough for a subname to resolve if its parent's resolver has records for it.
For chain-specific addresses (resolving a name for use on an L2), pass a coinType. See Multichain Considerations for the full pattern, including why resolution always runs against L1 even for L2 apps.
Reading Records
Text records and avatars follow the same shape:
import { normalize } from 'viem/ens'
const twitter = await client.getEnsText({
name: normalize('nick.eth'),
key: 'com.twitter',
})
const avatar = await client.getEnsAvatar({
name: normalize('nick.eth'),
})The standard record keys (avatar, description, com.twitter, and so on) are unchanged from ENSv1; see Text Records for the list.
Primary Names
Displaying a primary name (reverse resolution: address to name) is also unchanged at the library level:
const name = await client.getEnsName({
address: '0x1111111111111111111111111111111111111111',
})A primary name must never be displayed without verifying that it forward-resolves back to the address. In ENSv2 the Universal Resolver enforces this onchain: during reverse resolution it forward-resolves the returned name and reverts with ReverseAddressMismatch if the addresses differ, so any result your library hands you has already passed the check. How primary names are set is evolving in ENSv2, including multi-chain primary names; see Reverse Resolution for the current state.
Writing Records
ENSv2 changes the resolver write interface as well as where records live and who can update them. The Permissioned Resolver takes DNS-encoded names in its setters, while the ENSv1 Public Resolver takes namehashes. In the standard ENSv2 flow, each account's records live on its own resolver instance.
The flow: find the resolver the name actually uses, then call its supported setters from an account with the required resolver roles. Name ownership alone does not grant those roles.
Find the Resolver
The examples below target an ENSv2 Permissioned Resolver. Check its setter interfaces before writing. For unmigrated ENSv1 names, the Universal Resolver can return a mirror resolver instead of the writable v1 resolver. Use the version-aware ENSjs example to discover the writable resolver and select the correct v1 or v2 action.
import { normalize } from 'viem/ens'
const resolverAddress = await client.getEnsResolver({
name: normalize('nick.eth'),
})Set Records
ENSjs 5.2.5 or later provides stable ENSv2 record-writing helpers through @ensdomains/ensjs/wallet/v2. Its setRecords action encodes names and batches multiple updates for you. With the other libraries, call the resolver contract directly using its ENSv2 setter ABI and a DNS-encoded name, as shown below.
import { parseAbi, toHex } from 'viem'
import { normalize, packetToBytes } from 'viem/ens'
const resolverAbi = parseAbi([
'function setAddress(bytes name, uint256 coinType, bytes addressBytes)',
'function setText(bytes name, string key, string value)',
'function multicall(bytes[] data) returns (bytes[])',
])
const dnsName = toHex(packetToBytes(normalize('nick.eth')))
// wallet is a viem wallet client with an account holding ROLE_SET_TEXT
// resolverAddress: from "Find the Resolver" above
await wallet.writeContract({
address: resolverAddress,
abi: resolverAbi,
functionName: 'setText',
args: [dnsName, 'com.twitter', 'nicksdjohnson'],
})To update several records in one transaction with viem, wagmi, or ethers, encode the individual calls and batch them through the resolver's multicall (ENSjs's setRecords does this automatically whenever you pass more than one record; wagmi's writeContract takes the same arguments as the viem call below):
import { encodeFunctionData, parseAbi, toHex } from 'viem'
import { normalize, packetToBytes } from 'viem/ens'
const resolverAbi = parseAbi([
'function setAddress(bytes name, uint256 coinType, bytes addressBytes)',
'function setText(bytes name, string key, string value)',
'function multicall(bytes[] data) returns (bytes[])',
])
const dnsName = toHex(packetToBytes(normalize('nick.eth')))
// userAddress: the address the name should resolve to
await wallet.writeContract({
address: resolverAddress,
abi: resolverAbi,
functionName: 'multicall',
args: [[
encodeFunctionData({
abi: resolverAbi,
functionName: 'setAddress',
args: [dnsName, 60n, userAddress],
}),
encodeFunctionData({
abi: resolverAbi,
functionName: 'setText',
args: [dnsName, 'com.twitter', 'nicksdjohnson'],
}),
]],
})Who Can Write
Writes are permissioned through Enhanced Access Control roles on the resolver. In the common case this is invisible to your app: an account that registers a name and deploys its resolver typically holds every role, so its setter calls simply succeed.
The case to be aware of is subnames. A subname owner typically uses the parent's resolver and holds no roles on it, so a setText from their wallet reverts with EACUnauthorizedAccountRoles. Depending on the setup, records for such names are managed by the parent owner, delegated by setter argument (such as a text key) via grantSetterRoles() or by root roles via grantRootRoles(), or moved fully under the subname owner's control by pointing the subname at a resolver of their own. See Permissioned Resolver for the delegation model.
Listing a User's Names
Enumerating all names an account owns is an indexed-data problem in ENSv2, same as in ENSv1: onchain lookups alone cannot enumerate names. Two v2-specific points if you build or consume an index:
- Names are ERC1155 tokens, but each registry is its own contract and collection, and token IDs change when roles change. Key any cache by labelhash, never by token ID; see Mutable Token IDs.
- See Indexing ENSv2 for the event-level details needed to index registries and resolvers yourself.
For the general patterns (and ENSv1 options that keep working), see Listing Names.
Testing Your Integration
- Resolution path: the readiness page provides test names (like
ur.integration-tests.eth) that verify your app reaches the correct Universal Resolver and handles CCIP-Read. - End to end on Sepolia: register a test name on the Sepolia deployment, set records with the snippets above, and confirm the resolution calls return them. The protocol contract addresses are in the Deployments table.
- DNS names: make sure your name detection does not assume
.eth; see name detection.
Getting Test Funds
Registering a name on the Sepolia deployment costs two things: Sepolia ETH for gas (any public faucet works) and the registration fee, which the ETH Registrar collects in an ERC20 token. On Sepolia that token is MockUSDC (address in the Deployments table), and it is free: its mint function has no access control, so anyone can mint themselves a balance.
import { parseAbi } from 'viem'
// MockUSDC, from the Deployments table
await wallet.writeContract({
address: mockUsdcAddress,
abi: parseAbi(['function mint(address to, uint256 amount)']),
functionName: 'mint',
args: [account, 100_000_000n], // 100 USDC (6 decimals)
})Before registering, approve the ETH Registrar to spend the minted balance; the registration flow itself is described on the ETH Registrar page.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Names that resolve in other apps return null in yours | Library version predates ENSv2 support; check the readiness page minimums |
A name your user just registered resolves to null | No address record set yet; registration and records are separate steps |
Record writes revert with EACUnauthorizedAccountRoles | The connected account holds no roles on that resolver, most commonly a subname owner writing to the parent's resolver (see Who Can Write) |
| Record writes succeed but reads return old values | The write went to a resolver the name no longer points at; look up the resolver again instead of caching it |
| Resolution works in scripts but fails in the app | The app's environment blocks the HTTP requests CCIP-Read needs; see CCIP Read |
Next Steps
- Track library support and test names on the ENSv2 readiness page
- Understand the resolution machinery in Universal Resolver V2
- Go deeper on record permissions and delegation in Permissioned Resolver
- Building a subname product on top of your integration? Continue with the contract developers guide