Integrate identity
An ethername is a portable onchain identity your app can read without asking anyone. No API keys, no accounts, no rate limits, no company in the middle: a name resolves to an address, carries public records (avatar, links, bio), and can hand out subnames that act as memberships. Everything on this page is a view call on an immutable contract, so reading an identity costs nothing and can never be revoked or repriced under you.
This is the read side: consuming identities other people own. To register names programmatically, see AI agents & integrators; for the full ABI and events, see the Contracts reference.
The model in one minute
- Every name is an ERC-721 token on the NameNFT contract, which is also the resolver: one contract holds ownership, addresses and records together.
- A name’s
tokenIdis the uint256 of its namehash, rooted at the.ethernode. Soalice.ether, its records, and its subnames all hang off one deterministic id. - Given that id, everything is a view call:
resolvefor the address,textfor records,ownerOffor the holder,recordsfor lifecycle state. - The protocol runs only on Ethereum mainnet (chain id 1). The same address
on any other chain is not this contract; confirm
chainId === 1before you trust a read.
Setup
The examples use viem. The same calls work with ethers,
wagmi, or a raw eth_call.
import { createPublicClient, http, namehash, parseAbi } from 'viem'
import { normalize } from 'viem/ens'
import { mainnet } from 'viem/chains'
// NameNFT: registry, resolver and ERC-721 in one. Address on the Contracts page.
const NAME_NFT = '0x…' as const
const abi = parseAbi([
'function resolve(uint256 tokenId) view returns (address)',
'function reverseResolve(address addr) view returns (string)',
'function text(uint256 tokenId, string key) view returns (string)',
'function ownerOf(uint256 tokenId) view returns (address)',
'function isExpired(uint256 tokenId) view returns (bool)',
'function records(uint256 tokenId) view returns (string label, uint256 parent, uint64 expiresAt, uint64 epoch, uint64 parentEpoch)',
])
const client = createPublicClient({ chain: mainnet, transport: http() })
const read = (functionName: string, args: unknown[]) =>
client.readContract({ address: NAME_NFT, abi, functionName, args })
// A name's tokenId is the uint256 of its namehash.
const nameToId = (name: string) => BigInt(namehash(normalize(name)))
nameToIdcomputes the id in the browser, which is fine for display..etheruses standard namehash, but for anything security-sensitive let the contract be the source of truth: it exposes purecomputeNamehash(name)andnormalize(label)helpers that always match how it stored the name.
Resolve a name to an address
The everyday lookup: turn alice.ether into the address it points at.
const address = await read('resolve', [nameToId('alice.ether')])
// 0x0000…0000 when the name is unset, expired, or does not exist.
resolve falls back to the name’s owner when no address record is set, so a
freshly registered name resolves to its owner with zero setup. Treat the zero
address as “no resolution” and handle it.
Resolve an address to a name
The reverse: show alice.ether instead of 0xd8dA…6045.
const name = await read('reverseResolve', [user]) // '' when none is set
This returns the address’s primary name. The contract only honors it while that name is active and still resolves back to the same address, so a stale or sold name can never be used to impersonate someone. Even so, treat it as a display convenience: for authorization, verify ownership (below) rather than trusting a label.
Build a profile from records
Names carry free-form text records under keys most wallets already understand. Read several in one round trip with multicall:
const id = nameToId('alice.ether')
const keys = ['avatar', 'url', 'description', 'com.twitter', 'com.github', 'org.telegram']
const values = await client.multicall({
contracts: keys.map((key) => ({ address: NAME_NFT, abi, functionName: 'text', args: [id, key] })),
allowFailure: false,
})
const profile = Object.fromEntries(keys.map((key, i) => [key, values[i]]))
| Key | Meaning |
|---|---|
avatar |
The name’s picture, as a reference to an NFT (see below) |
url |
Website |
description |
Short bio |
com.twitter / com.github / org.telegram |
Social handles |
You can also read any custom key your own app writes, covered under Store your app’s data.
The avatar record is a claim, not proof
This is the one record you should not read naively, so it is worth spelling out.
An avatar names a token, not a picture, in the same form ENS uses:
eip155:1/erc721:0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb/5822
Storing the token rather than an image URL is what makes the claim checkable at all. A URL would let anyone point at a CryptoPunk they do not own, permanently and undetectably. A reference can be verified by reading who holds the token.
Nothing enforces it for you. The record is free-form text, and its owner can write any token id they like, including one they have never held. So before you render it as somebody’s picture:
// 1. the token the record names
const [, chainId, standard, contract, tokenId] =
/^eip155:(\d+)\/(erc721|erc1155):(0x[0-9a-f]{40})\/(\d+)$/i.exec(record) ?? []
// 2. who holds that token, and who holds the name
const [tokenHolder, nameOwner] = await Promise.all([
client.readContract({ address: contract, abi: erc721Abi, functionName: 'ownerOf', args: [BigInt(tokenId)] }),
client.readContract({ address: NAME_NFT, abi, functionName: 'ownerOf', args: [nameToId('alice.ether')] }),
])
// 3. they must be the same address
const verified = tokenHolder.toLowerCase() === nameOwner.toLowerCase()
Three rules that are easy to get wrong:
- Verify against the name’s owner, never its address record.
setAddris self-asserted, so a lookalike name can point its address at a whale. OnlyownerOfis enforced by the contract. - Do not cache the verdict. A token can be borrowed and the record written inside a single transaction. A fresh read makes that worthless from the next block; a cached one does not.
- Owning the token is not the same as owning a known token. A reference proves someone holds token 5822 of some contract, never that the contract is the real CryptoPunks. Anyone can deploy their own and honestly own token 5822 of it. Match the contract address against a list you curate.
When verification fails, fall back to the name’s onchain certificate
(tokenURI): every name has a deterministic card drawn onchain, so there is
always something to show. Treat a failure as unverified, not as sold: it
reads the same for a token held in a second wallet, staked, or in a vault.
One more thing worth knowing when you fetch the picture: do not treat a single
gateway failure as proof the image is gone. Testing one collection’s art from a
browser, several well-known public gateways failed to serve it while others
returned it fine, and the same URLs succeeded from curl on the same machine.
We could not pin down why, and it may not reproduce everywhere, which is rather
the point: try more than one source before you fall back.
Gate access by ownership
Token-gating on a specific name, or on holding any name at all:
async function holdsName(name: string, user: `0x${string}`) {
const id = nameToId(name)
try {
const [owner, expired] = await Promise.all([read('ownerOf', [id]), read('isExpired', [id])])
return owner.toLowerCase() === user.toLowerCase() && !expired
} catch {
return false // ownerOf reverts for a name that was never registered
}
}
Ownership alone is not enough: a name can expire and lapse to someone else.
Always pair ownerOf with isExpired (or inGracePeriod) so you never grant
access on a dead name, and re-check at the moment of access rather than caching
the result forever.
Gate a community by subname
The membership pattern: a community owns dabot.ether and hands out subnames
(alice.dabot.ether) as passes. An event, a forum, or a chat gate then admits
only holders of a live subname under that parent.
Verifying a member’s claimed name takes three checks: they hold it, it is really a child of your parent, and the parent has not changed hands since it was minted.
const PARENT = nameToId('dabot.ether')
async function isMember(claimed: string, user: `0x${string}`) {
const id = nameToId(claimed) // e.g. 'alice.dabot.ether'
try {
const [owner, sub, parent, parentExpired] = await Promise.all([
read('ownerOf', [id]),
read('records', [id]),
read('records', [PARENT]),
read('isExpired', [PARENT]),
])
return (
owner.toLowerCase() === user.toLowerCase() && // holds the subname
sub.parent === PARENT && // it is a child of dabot.ether
sub.parentEpoch === parent.epoch && // parent has not been re-registered since
!parentExpired // and the community is still live
)
} catch {
return false
}
}
The parentEpoch === parent.epoch check is the important one. Subnames do not
expire on their own, but if the parent ever lapses past its grace period and is
re-registered, the contract bumps the parent’s epoch and invalidates every
subname from the previous era. Skip this check and you keep admitting
members whose passes a new owner has already wiped. To list every current member
instead of verifying one, index the SubdomainRegistered(tokenId, parentId, label) event for your parentId and apply the same liveness checks.
Attributes and verification
Text records are self-asserted: the owner can write anything. That is perfect for a bio or a link, and not enough on its own for a claim like age or KYC. Never gate access on one.
It helps to keep three tiers straight, because the difference is invisible from the outside:
| Enforced by the contract | Safe to gate on | |
|---|---|---|
ownerOf, resolve, subname parentage |
yes | yes |
avatar, once you have checked ownerOf yourself |
no, but checkable | for display |
| every other text record | no | never |
For trustless attributes, store an attestation rather than a bare value: have a trusted attester sign a statement (for example, that a given address is over 18, with an expiry), publish it in a record under your namespace, and have your app verify the attester’s signature before it trusts the claim. The record is only transport; the signature is what you check. Apply the same expiry and epoch discipline as everywhere else.
Store your app’s data
Your app can also write to a name whose owner has connected, keeping its own namespace on every name:
// Owner-only, sent from the owner's wallet client. Reverse-DNS keys stay collision-free.
await walletClient.writeContract({
address: NAME_NFT, abi, functionName: 'setText',
args: [nameToId('alice.ether'), 'com.myapp.profile', value],
})
Use a reverse-DNS key you control (com.myapp.*) and no other app can clash with
you. Records are wiped automatically when a name expires and is re-registered, so
a new owner never inherits a stranger’s data. For anything bigger than a string,
deploy your own contract keyed on the tokenId and store the name’s epoch
alongside your data, treating a changed epoch as a fresh identity. Both patterns
are covered in Building on Ethernames.
Rules that keep integrations correct
- Mainnet only. Everything is on Ethereum mainnet, chain id 1. Verify the chain and the contract address before trusting any read.
- Zero address means no resolution.
resolvereturns0x000…0for unset, expired, or nonexistent names. Handle it explicitly. - Expiry and grace are real. A name can lapse and change hands. Pair
ownership with
isExpired/inGracePeriod, and re-check rather than caching forever. - Token ids are reused. A name that lapsed and was re-registered is a
different identity with the same id. For anything you persist, store
records(id).epochand treat a change as a new owner. - Beware look-alikes. Confusable and homograph labels can be used to deceive; normalize before comparing, and never rely on a label alone for anything that matters, verify ownership onchain.
- The chain is the source of truth. Never trust a frontend’s claim of ownership or resolution; confirm it with a view call.
Next steps
- Contracts reference: every function, event and constant.
- Records & resolution: the record types in depth.
- AI agents & integrators: registering and managing names programmatically.