An ENS reverse lookup is the difference between a dashboard that shows 0xd8dA6BF2…6045 and one that shows vitalik.eth. Every wallet UI does it, most block explorers do it, and almost every team that tries to build it themselves either bolts on a vendor’s proprietary endpoint or discovers — usually in production — that they wired up the naive version and it can be spoofed. The correct way is a single contract read, and it has been a single contract read since ENS shipped the Universal Resolver.
TL;DR
Call reverse(address, 60) on the ENS Universal Resolver at
0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe on Ethereum mainnet. It returns
the address’s primary name and — critically — internally verifies that the
name forward-resolves back to that address, so you don’t have to do the
round-trip check yourself. Anything that skips that verification is
spoofable.
Why reverse lookups are a security question, not a convenience
Forward resolution — name to address — is trustworthy by construction. The owner of vitalik.eth controls what it points at, and if they point it somewhere wrong, that’s their problem.
Reverse resolution runs the other way, and there the trust model inverts. Reverse records live in a registry where any address can set its own reverse record to any string. Nothing stops an attacker from pointing their address’s reverse record at vitalik.eth. If your UI reads that record and renders it as a label, you have just built a phishing surface: the attacker’s address displays under someone else’s identity.
The fix is the round-trip. After reading the name from the reverse record, resolve that name forward and confirm it comes back to the address you started with. If it doesn’t, discard the name and show the raw address.
This is not optional, and it is exactly the step hand-rolled implementations skip.
The Universal Resolver does the round-trip for you
ENS deployed the Universal Resolver to collapse that two-step dance into one call. It lives at the same address on Ethereum mainnet and testnets:
0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEeThe ENS documentation is explicit about the guarantee: reverse internally checks that the name forward-resolves to the address you’re looking up, so your implementation doesn’t need to do any additional checks.
Resolving its ABI live gives the shape you care about:
reverse(lookupAddress: bytes, coinType: uint256) -> (primary: string, resolver: address, reverseResolver: address)coinType is 60 for Ethereum mainnet — the SLIP-44 coin type for ETH. (More on other chains below; the short version is that it’s a trap.)
Note that lookupAddress is bytes, not address. That’s deliberate — the Universal Resolver is chain-agnostic and accepts raw address bytes of any length. It also means that when you declare the parameter for a query, you declare it as bytes.
One request, one name
Here is the whole thing as a curl against evmquery’s REST API. No SDK, no ABI file, no node connection:
curl -s -X POST https://api.evmquery.com/api/v1/query \ -H "Content-Type: application/json" \ -H "x-api-key: $EVMQUERY_API_KEY" \ -d '{ "chain": "evm_ethereum", "schema": { "contracts": { "ur": { "address": "0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe" } }, "context": { "addr": "bytes" } }, "context": { "addr": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" }, "expression": "ur.reverseWithGateways(addr, 60, []).primary" }'{ "result": { "value": "vitalik.eth", "type": "string" }, "units": { "consumed": 1 }}The ABI is resolved automatically from the verified source — you passed an address and a method name, not an artifact.
Use reverseWithGateways, not reverse
The two-argument reverse(address, coinType) and the three-argument
reverseWithGateways(address, coinType, gateways) do the same job, but the
overloaded return struct on reverse decodes ambiguously — its first field
loses its name, and reading .primary off it fails. Passing an empty gateway
list to reverseWithGateways gives you a cleanly named
(primary, resolver, reverseResolver) struct. Use the explicit form.
Reading a name that isn’t there
Two non-obvious behaviours, both worth handling before you ship.
An address with no primary name returns an empty string, not a revert. The USDC contract has never set a reverse record:
{ "result": { "value": "", "type": "string" } }Branch on name !== "". Don’t wrap the call in a try/catch and assume failure means “no name” — a genuine failure and a genuine absence look completely different, and conflating them hides real errors.
An unregistered name resolves to the zero address, not a revert either. Query the ENS registry for a name nobody owns and you get 0x0000…0000 back. Same rule: check the value, don’t catch the exception.
Batching: label a whole table in one round trip
The single-lookup case is the boring one. The case that actually costs you money is a transactions table with 200 addresses in it, where the naive implementation fires 200 RPC calls on every render.
evmquery’s expression language has a map macro, so the batch is still one HTTP request:
{ "chain": "evm_ethereum", "schema": { "contracts": { "ur": { "address": "0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe" } }, "context": { "addrs": "list<bytes>" } }, "context": { "addrs": [ "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "0xb8c2C29ee19D8307cb7255e1Cd9CbDE883A267d5", "0xFe89cc7aBB2C4183683ab71653C4cdc9B02D44b7" ] }, "expression": "addrs.map(a, ur.reverseWithGateways(a, 60, []).primary)"}Returned live, in order:
["vitalik.eth", "", "nick.eth", "wallet.ensdao.eth"]The empty slot is USDC — a contract, no reverse record. The results come back positionally, so you can zip them straight back onto your input list.
Note the context type is list<bytes>, not bytes. Declaring the singular type while passing an array is a type error at evaluation time, and it’s the single most common mistake with parameterised list queries.
Wrapped up for a frontend, that’s a labeling helper in about thirty lines:
const EVMQUERY_URL = "https://api.evmquery.com/api/v1/query";const UNIVERSAL_RESOLVER = "0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe";
interface QueryResponse<T> { result: { value: T; type: string }; units: { consumed: number };}
async function evmquery<T>(body: Record<string, unknown>): Promise<T> { const res = await fetch(EVMQUERY_URL, { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.EVMQUERY_API_KEY!, }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`evmquery ${res.status}: ${await res.text()}`); const json = (await res.json()) as QueryResponse<T>; return json.result.value;}
/** Resolve ENS primary names for a batch of addresses. Unnamed addresses are omitted. */export async function primaryNames( addresses: string[],): Promise<Map<string, string>> { const names = await evmquery<string[]>({ chain: "evm_ethereum", schema: { contracts: { ur: { address: UNIVERSAL_RESOLVER } }, context: { addrs: "list<bytes>" }, }, context: { addrs: addresses }, expression: "addrs.map(a, ur.reverseWithGateways(a, 60, []).primary)", });
return new Map( addresses .map((address, i): [string, string] => [address, names[i] ?? ""]) .filter(([, name]) => name !== ""), );}Run against the four addresses above, that returns a two-entry Map — vitalik.eth and nick.eth — with the unnamed addresses filtered out, ready to fall back to a truncated hex label in the UI.
Going the other way: names, addresses, and text records
Forward resolution is where the profile data lives — avatar, Twitter handle, GitHub username, website. It’s a two-hop read: ask the ENS registry which resolver owns the name, then ask that resolver for the records.
evmquery ships sel.namehash() as a built-in, so you never have to precompute the node hash out-of-band:
{ "chain": "evm_ethereum", "schema": { "contracts": { "registry": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e" } }, "context": { "name": "string" } }, "context": { "name": "vitalik.eth" }, "expression": "registry.resolver(sel.namehash(name))"}{ "result": { "value": "0x231b0ee14048e9dccd1d247744d114a4eb5e8e63" } }With the resolver known, one more query pulls the address and any text records you want, in a single round trip:
{ "expression": "cel.bind(node, sel.namehash(name), { \"address\": dyn(resolver.addr(node)), \"avatar\": dyn(resolver.text(node, \"avatar\")), \"url\": dyn(resolver.text(node, \"url\")), \"twitter\": dyn(resolver.text(node, \"com.twitter\")) })"}Live result for vitalik.eth:
{ "address": { "value": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" }, "avatar": "https://euc.li/vitalik.eth", "url": "https://vitalik.ca", "twitter": "VitalikButerin"}Two things in that expression earn their keep. cel.bind computes the namehash once and reuses it across all four reads instead of recomputing it per call. And dyn() wraps each value because a map literal otherwise infers a single value type from its first entry and rejects the rest — mixing an address with string records without it fails with Map value uses wrong type.
The resolver trap: there is no single “the” Public Resolver
Here is the mistake that will cost you an afternoon.
Plenty of guides hardcode the ENS Public Resolver address and call addr() on it directly, skipping the registry hop. It works — right up until it silently doesn’t. Ask the registry which resolver four well-known names actually use:
["vitalik.eth", "nick.eth", "ens.eth", "wallet.ensdao.eth"][ "0x231b0ee14048e9dccd1d247744d114a4eb5e8e63", "0x4976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41", "0x4976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41", "0x4976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41"]Two different resolvers across four names — and neither is wrong. ENS has shipped several Public Resolver revisions, names point at whichever one they were configured with, and custom resolvers are a supported feature. Hardcode one and query nick.eth against it and you get:
{ "address": { "value": "0x0000000000000000000000000000000000000000" }, "avatar": "", "github": ""}No error. No revert. Just a zero address and empty strings that look exactly like “this name has no records set” — when in reality you asked the wrong contract. Against the resolver the registry actually names, the same query returns the real data:
{ "address": { "value": "0xb8c2c29ee19d8307cb7255e1cd9cbde883a267d5" }, "avatar": "https://euc.li/nick.eth", "github": "arachnid", "url": "https://ens.domains/"}That address, 0xb8c2…67d5, is the same one the reverse lookup earlier resolved to nick.eth — the round-trip closes.
Always read registry.resolver(namehash(name)) first, or guard the hardcoded path explicitly:
registry.resolver(node) == solAddress("0x231b...E63")If you’d rather not think about any of this, that’s the argument for the Universal Resolver: it walks the registry, finds the correct resolver, and verifies the round-trip in one call. The registry-plus-resolver path is what you reach for when you want text records, which the Universal Resolver’s reverse doesn’t return.
What doesn’t work: L2 primary names and offchain names
Being straight about the limits.
ENSIP-19 lets an address hold a different primary name per chain, keyed by a coin type derived from the chain ID (chainId ^ 0x80000000 — Base’s 8453 becomes 2147492101). Passing that coin type to reverseWithGateways with an empty gateway list reverts, because L2 reverse records are served over CCIP-Read: the contract intentionally throws an OffchainLookup error that the caller is expected to catch, fetch from an HTTP gateway, and resubmit.
evmquery executes onchain reads. It does not follow CCIP-Read offchain callbacks, so ENSIP-19 L2 primary names and offchain/wildcard names — the .cb.id-style names that resolve through a gateway rather than a contract — are out of scope here. Coin type 60 (mainnet) and coin type 0x80000000 (the default EVM record) resolve fine; both are plain onchain reads. For the L2-specific records, use a CCIP-Read-aware client like viem’s getEnsName with the appropriate coinType.
Worth noting this affects a small minority of names in practice. Mainnet primary names, the ones that cover the overwhelming bulk of what a dashboard needs to label, are a straight contract read.
Two quirks in the response envelope
Queries that pass a bytes argument to a contract method — which every ENS
call does, since node hashes and lookup addresses are both bytes — return
meta: null rather than a blockNumber/totalCalls block, and bill a flat
one unit. The map macro does the same. Explicit list literals over plain
value-typed methods return the full metadata. If you need a block number
pinned alongside an ENS read, fetch it in a separate query.
Where this fits
If you’re building agent tooling, address labeling is one of the highest-leverage reads there is: an LLM handed vitalik.eth reasons about it far better than one handed forty hex characters, and the same single call works from an AI agent framework as from a React component. For developers wiring this into an existing app, the practical win is that a table of 200 addresses becomes one request instead of 200, without standing up an indexer or paying for a proprietary name-resolution endpoint.
The broader pattern — resolve the ABI automatically, batch dependent reads into one expression, skip the artifact management entirely — is the same one behind reading ERC-20 balances across a wallet list and batching contract reads with Multicall3.
Next steps
- Scan ERC-20 balances across many wallets in one call — the same
mapmacro, applied to token balances. - How evmquery resolves contract ABIs — why the Universal Resolver query above needed no ABI file.
- Multicall3 batching, explained — what’s happening underneath a batched expression.
- evmquery for developers — REST, MCP, and n8n surfaces in one place.



