ENS Reverse Lookup: Turning Addresses Into Names Without an Indexer

Resolve ENS primary names from wallet addresses with one REST call — using the Universal Resolver, which verifies forward resolution for you. Live, tested examples.

evmquery team··10 min read
Share
ENS reverse lookup — resolving wallet addresses to primary names with a REST API

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:

0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe

The 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 Mapvitalik.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

Share

Label a batch of addresses in one request

Free tier, no credit card. Point the expression below at any list of wallets.