Skip to content
docs

Names

Users type vitalik.eth, jesse.base.eth or alice.cb.id instead of an address. names-ens turns the name into the address for the chain the user is sending on, flags names written to look like other names, and re-checks the name right before signing.

The resolver implements NameResolver from core. Solana (.sol), TON (.ton) and NEAR names will plug into the same interface. Snippets come from examples/names, which our CI typechecks and tests; the live run resolves real names on Ethereum and Base.

Terminal window
npm install @anywalletkit/names-ens
import { NameError, assertNameUnchanged, isNameLike, nameResolvers, type NameResolver, type ResolvedName } from '@anywalletkit/core'
import { ensResolver } from '@anywalletkit/names-ens'
export function names(): NameResolver {
return nameResolvers([
// More specific resolvers (.sol, .ton) go before ENS when they exist: ENS also serves DNS names.
ensResolver({
rpcUrl: 'https://ethereum-rpc.publicnode.com', // always Ethereum mainnet, whatever chain you send on
cacheTtlMs: 5 * 60_000,
ipfsGateway: 'https://ipfs.io/ipfs/', // point at your own proxy in production
}),
])
}

ENS lives on Ethereum mainnet, so the resolver always needs an Ethereum RPC, even when the user sends on Base or Arbitrum. Names that resolve off-chain (Coinbase cb.id, Basenames, uni.eth) go through CCIP-Read without extra setup in a browser, an extension or Node. On React Native they go through your server (React Native below). Results are cached for five minutes by default.

export type Recipient =
| { kind: 'address'; address: string }
| { kind: 'name'; resolved: ResolvedName }
| { kind: 'error'; message: string }
/** The recipient field: an address passes through, a name is resolved for THIS chain. */
export async function readRecipient(resolver: NameResolver, input: string, chain: string): Promise<Recipient> {
const text = input.trim()
// On NEAR, `alice.near` IS the account address; elsewhere a dotted string is never an address.
if (!isNameLike(text) || chain.startsWith('near:')) return { kind: 'address', address: text } // validate it as an address next
// Not served: an invalid name, a lookalike-letter mixture (`vitаlik.eth` with a Cyrillic "а"), or a TLD with no resolver yet.
if (!resolver.supports(text)) return { kind: 'error', message: 'This name is not valid, or names like it are not supported yet.' }
try {
return { kind: 'name', resolved: await resolver.resolve(text, chain) }
} catch (e) {
if (!(e instanceof NameError)) throw e
const messages: Record<string, string> = {
'invalid-name': 'This is not a valid name.',
'not-found': 'No one owns this name.',
'no-address-for-chain': 'This name has no address for this network.', // never send to its Ethereum address instead
unsupported: 'Names like this are not supported yet.',
'lookup-failed': 'Could not look the name up. Try again.',
}
return { kind: 'error', message: messages[e.code] ?? e.message }
}
}
  • One chain per lookup. resolve(name, 'eip155:8453') reads the name’s Base address. A name with no Base record is an error, no-address-for-chain, and never its Ethereum address: sending to the mainnet address on another chain is how funds get lost.
  • Bitcoin and Solana records are read the same way: bip122:… and solana:… as the chain.
  • Invalid names are refused before any network call, with the reason. Names are normalized per ENSIP-15, so mixed scripts like vitаlik.eth (a Cyrillic “а”) don’t resolve at all.
  • On NEAR, alice.near is the account address itself, not a name to resolve.
NameError code What to tell the user
invalid-name Not a valid name
not-found No one owns this name
no-address-for-chain The name has no address for this network
unsupported Names like this aren’t supported yet
lookup-failed The lookup failed; try again (the name may exist)
changed The name points elsewhere now (see below)
/** The review screen shows the address the name resolved to, and every warning. */
export function reviewLines(resolved: ResolvedName): string[] {
return [`${resolved.name} → ${resolved.address}`, ...resolved.warnings.map((w) => `⚠ ${w.message}`)]
}
/** Right before signing: resolve again; refuse if the name now points elsewhere. */
export async function beforeSigning(resolver: NameResolver, resolved: ResolvedName): Promise<void> {
await assertNameUnchanged(resolver, resolved.name, resolved.chain, resolved.address) // throws NameError 'changed'
}
  • Show the resolved address, not only the name. The address gets the funds.
  • Show every warning that comes with the result: confusable (Latin letters replaced by lookalikes), mixed-script, punycode. Each has a sentence you can show as-is.
  • Resolve again right before signing. An owner can point the name somewhere else between the review screen and the tap on “Send”. assertNameUnchanged throws changed when that happens; go back to the review screen with the new address.
/** The account header: the verified primary name and its avatar URL, if any. */
export async function profile(resolver: NameResolver, address: string, chain: string) {
const name = await resolver.reverse(address, chain) // counts only if it resolves back to this address
const avatar = name ? await resolver.avatar(name) : null // a URL: fetch it through your proxy
return { name, avatar }
}

reverse returns the primary name of an address only when that name resolves back to the same address. Anyone can set any primary name on their own address, so an unverified one proves nothing. L2 primary names (ENSIP-19) are read for the chain you pass.

avatar returns the name’s avatar record when it is an https URL on a public host, or an IPFS one through your gateway, and never fetches it. NFT avatars come back null: resolving one would fetch the metadata URL the NFT’s issuer chose. Fetch avatars through your own proxy, so the user’s IP and address list don’t reach arbitrary servers.

Offchain names (CCIP-Read) make the device fetch gateway URLs the name’s resolver chooses. The resolver built from rpcUrl asks only https gateway URLs whose host, as written, is not a private one, refuses redirects, and reads at most 1 MiB within 10 s per gateway (ccip: { maxResponseBytes, timeoutMs }). Where the host really resolves depends on the runtime:

  • Node, no ccip.fetch: gateways go through the same pinned transport as createEnsCcipHandler — every DNS answer is checked and the connection is pinned to it, so a public-looking name that resolves to a private address is not asked. A host whose records mix public and private addresses is refused; behind a corporate split-horizon DNS or an outbound proxy, pass ccip.fetch.
  • Browser and other runtimes: the global fetch connects. The URL is checked, the address it resolves to is not — no DNS-level isolation. Send gateway calls through your server (ensCcipViaProxy, below) where that matters.

If you pass your own viem client, create it with ccipRead: ensCcipRead from @anywalletkit/names-ens (or ccipRead: false): viem’s default would fetch any URL, a router’s admin page on the user’s network included.

React Native’s fetch follows redirects whatever redirect: 'error' says, so a gateway could redirect the device to a LAN or loopback address, and the request goes out before the SDK can see the final URL. There, offchain names are off by default: they fail with lookup-failed, and the error’s reason is ccip-proxy-required. The safe setup sends each gateway call to your own server, which refuses redirects and checks the address it connects to.

On the server, one route with createEnsCcipHandler from @anywalletkit/names-ens/server (Node: it connects with node:https). Authenticate and rate-limit it like your other routes: without that it fetches https URLs for anyone.

// app/api/ccip/route.ts (Next.js; any framework works the same way)
import { createEnsCcipHandler } from '@anywalletkit/names-ens/server'
export const runtime = 'nodejs'
const ccip = createEnsCcipHandler() // options: allowHosts, denyHosts, timeoutMs, maxResponseBytes
export async function POST(req: Request): Promise<Response> {
await requireUser(req) // your auth + rate limit: throw a 401 / 429 Response
const raw = await req.text()
if (raw.length > 160 * 1024) return Response.json({ error: 'request too large' }, { status: 413 })
let body: unknown
try { body = JSON.parse(raw) } catch { return Response.json({ error: 'invalid JSON' }, { status: 400 }) }
const out = await ccip(body) // { url, method, body? } → { data } or { error }
return Response.json(out.body, { status: out.status })
}

The handler asks only https gateways on public hosts. It resolves the host and refuses it when any address is private, loopback, link-local, CGNAT or an IPv6 address carrying one of those, then connects to the checked address, so a second DNS answer cannot move the request (DNS rebinding). It never follows a redirect, applies a timeout (10 s) and an answer cap (1 MiB), sends GET or POST exactly as EIP-3668 defines them, and forwards none of the app’s headers or cookies.

In the app, ensCcipViaProxy is the fetch for the resolver. It needs no Node API and runs on Hermes:

/**
* ENS names on React Native: offchain names (cb.id, Basenames, uni.eth) ask their CCIP-Read gateways through
* the server's `/ccip` route, because the device's fetch cannot refuse a gateway's redirect.
*/
export function namesOverServer(api: string, token: string, ethereumRpc: string): NameResolver {
return ensResolver({
rpcUrl: ethereumRpc,
ccip: {
fetch: ensCcipViaProxy(`${api}/ccip`, {
headers: { authorization: `Bearer ${token}` },
// https in production; plain http only for a server on this machine (the tests).
allowInsecureProxyUrl: /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(api),
}),
},
})
}

The reference server has this route at POST /ccip, behind the same token check and rate limits as its other routes:

if (url.pathname === '/ccip') {
guard.ccip(req) // the token and a per-caller limit, before the body is read
const raw = await readBody(req, 160 * 1024) // the gateway URL or calldata: up to 64 KiB of hex each
let body: unknown
try { body = JSON.parse(raw) } catch { return send(res, 400, { error: 'invalid JSON' }) }
const out = await ccip(body) // { url, method, body? } → { data } or { error }
return send(res, out.status, out.body)
}

If you would rather not run the route, ccip: { allowRedirectFollowingFetch: true } asks gateways directly and accepts that a redirecting gateway can make the device send one blind request (the answer is never used) to a host on the user’s network.

With your own client, the same options go to ccipRead: createEnsCcipRead({ … }).