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 tokenId is the uint256 of its namehash, rooted at the .ether node. So alice.ether, its records, and its subnames all hang off one deterministic id.
  • Given that id, everything is a view call: resolve for the address, text for records, ownerOf for the holder, records for 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 === 1 before 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)))

nameToId computes the id in the browser, which is fine for display. .ether uses standard namehash, but for anything security-sensitive let the contract be the source of truth: it exposes pure computeNamehash(name) and normalize(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. setAddr is self-asserted, so a lookalike name can point its address at a whale. Only ownerOf is 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. resolve returns 0x000…0 for 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).epoch and 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