Quickstart
Install @arcns/resolve, create one resolver, resolve @nike, nike.arc and nike.circle, branch on typed errors, read records with verification, reverse an address, and call the API with curl.
Ten minutes from install to a resolved name with its namespace, registry and verification status on screen. Everything below is Arc testnet (chain id 5042002), package 0.1.0-testnet.N.
Install
npm install @arcns/resolve@testnet viem
@arcns/resolve ships on the npm dist-tag testnet (pre-releases never move latest, so name the tag). As of 2026-09-07 the first testnet release is not on the registry yet: until it lands, clone the repository and npm ci && npm run build inside sdk/, then npm link it. viem 2.x is a peer dependency. ESM and CommonJS builds are both shipped. Licence: MIT.
The ArcNS contract addresses are filled into the package when the contracts deployment publishes deployments/5042002.json. Until then createResolver needs an explicit deployment for @handle, our .arc / .circle registry and reverse(); the pinned peer registry already answers for .arc / .circle without one. See Contracts.
One resolver, one client
Create one resolver per process, not one per component or request. One instance gives you one shared 5-second cache and one TLD table read.
// resolver.ts — a module singleton: one viem client, one resolver, one short cache.
import { createPublicClient, http } from "viem";
import { arcTestnet, createResolver, type Resolver } from "@arcns/resolve";
export const arcns: Resolver = createResolver({
client: createPublicClient({
chain: arcTestnet,
transport: http(), // or http(process.env.ARC_RPC_URL)
// v1 has no CCIP-read for payment records; an OffchainLookup is an error, not data.
ccipRead: false,
}),
// Until the package ships the testnet addresses, or on a fork:
// deployment: deploymentFromJson(JSON.parse(readFileSync("deployments/5042002.json", "utf8"))),
});
The full configuration surface:
interface ResolverConfig {
client: PublicClient; // viem; chain set; pass ccipRead: false
deployment?: ArcnsDeployment; // default ARCNS_DEPLOYMENTS[chainId]; absent: not-configured for our paths
tldTable?: TldTable; // default: loadTldTable(client, deployment.tldDirectory), lazily
registries?: readonly RegistryPin[]; // extra pins; unpinned refused unless allowUnpinnedRegistries
allowUnpinnedRegistries?: boolean;
cacheTtlMs?: number; // default 5_000; 0 disables; resolve() only
now?: () => number; // unix seconds for TLD gates
}
Resolve a name
const r = await arcns.resolve("@nike");
// { name: "@nike", namespace: "handle", registry: "0x…", product: "arcns",
// address: "0x…", coinType: 2152525650, verification: "verified", epoch: 3n, … }
await arcns.resolve("nike.arc"); // every pinned .arc registry consulted, one labelled answer
await arcns.resolve("nike.circle"); // same, for .circle
await arcns.resolve("@nike.arc"); // throws ResolveError { code: "ambiguous" }
Dispatch is on shape (@ prefix, or a known TLD suffix), never "try one, fall back to the other". Use looksLikeName(input) to decide whether to attempt resolution at all, so a pasted 0x… address does not surface a validation error.
Every Resolved carries input, name, namespace, chainId, registry, product, resolver, node, controller, epoch, coinType, chain, address, addressBytes, verification and, for TLD names, tld: { status, sunsetAt }.
namespaceishandle,arcorcircle.namespaceLabel(namespace)gives the display form (@handle,.arc,.circle).registryis the contract the answer came from;productsays whose it is (arcnsorkhenzarr-arcns).controlleris who owns the name. It is never the payment address and the SDK never falls back to it.epochbumps on every ownership change; records from an older epoch are unreachable by construction.verificationisverifiedorunverified. See Verified records.
Multi-chain: the default chain is ARC (ENSIP-11 coin type 0x80000000 | 5042002 = 2152525650). Pass { chain: "ETH" | "BTC" | "SOL" | "X1" } to read that coin type's record, or { chain: "ARC", fallbackChains: ["ETH"] } to try the coin-type-60 record next; the result names the coinType that answered. A name with no record for the requested chain is an explicit no-record-for-chain, never a wrong address on the wrong chain.
Branch on the error code
resolve() throws a ResolveError with a typed code, so a recipient field can tell "still typing" from "does not exist" from "no address for this chain" from "two registries disagree".
import { ResolveError, namespaceLabel } from "@arcns/resolve";
export async function resolveRecipient(input: string, chain: "ARC" | "ETH" | "BTC" | "SOL" = "ARC") {
try {
const r = await arcns.resolve(input, { chain });
// SHOW ALL OF THESE before enabling "Send":
// name + namespaceLabel(r.namespace) -> "alice.arc · .arc"
// short(r.registry) + r.product -> "registry 0xc3…3a01 (arcns)"
// r.verification -> "verified" | "unverified" (warn)
// r.tld?.status === "sunset" -> banner: this TLD sunsets on r.tld.sunsetAt
return r;
} catch (e) {
if (!(e instanceof ResolveError)) throw e;
switch (e.code) {
case "unrecognized": return null; // probably a raw address — pass it through
case "invalid-handle":
case "invalid-domain": return null; // still typing; e.reason names the rule
case "ambiguous": throw new Error("@name and name.arc are different names — type one of them");
case "not-found": throw new Error(`${input} is not registered`);
case "no-record-for-chain": throw new Error(`${input} has no ${chain} address`);
case "conflict": return chooseRegistry(e.candidates!); // never auto-pick
case "tld-retired": throw new Error(`${input}: this TLD has been retired`);
case "offchain-lookup":
case "rpc-error": throw new Error("Couldn't read the chain — try again");
default: throw e;
}
}
}
code | Meaning |
|---|---|
unrecognized | not a handle or a known domain; probably a raw address |
ambiguous | both shapes at once, e.g. @jack.arc |
invalid-handle, invalid-domain | right shape, invalid content; reason names the rule (HyphenAtEdge, NonAscii, AllDigits, …) |
not-found | valid name, not registered (or reason: "nft-burned"); only after a successful chain read |
no-record-for-chain | registered, but no address record for the requested chain; never the controller |
conflict | pinned registries disagree; candidates carries every answer |
tld-retired, tld-sunset, tld-paused | TLD lifecycle gates (reads: tld-retired; writes: all three) |
unknown-registry | an unpinned registry address without opt-in |
offchain-lookup | the resolver asked for CCIP-read; v1 treats that as an error, never as data |
rpc-error | transport or decoding failure; never reported as not-found |
not-configured | no deployment for this chain; never a guessed address |
Records with verification per coin type
records(input) returns every non-empty address record of a name in our registry, each with its own verification flag.
const recs = await arcns.records("alice.arc");
for (const rec of recs) {
// rec = { coinType, chain, value, address, verification }
// coinType 2152525650 · chain "ARC" · verification "verified"
// coinType 60 · chain "ETH" · verification "verified"
// coinType 0 · chain "BTC" · verification "unverified" -> warn
// coinType 501 · chain "SOL" · verification "unverified" -> warn
}
verification === "verified" means the owner proved control of that address for that coin type on chain. Anything else is a claimed-but-unproven record: show a warning and make the user look twice. Coin types: BTC 0, ETH 60, SOL 501, ARC 2152525650. text(input, key) reads an ENS text record from our resolver, or null.
Reverse: the one primary name
const p = await arcns.reverse("0xce42…54AC");
// { name: "alice.arc", namespace: "arc", registry, product, chainId, node, address, controller } | null
There is exactly one primary per address across all three namespaces. The SDK returns it only if it forward-confirms: an @handle primary must still be owned by the address; a .arc / .circle primary must resolve back to the address (addr(node, 0x804cef52), then coin type 60) in our registry. A stale string is null, never the previous owner. reverse() is not cached and makes 2 to 4 eth_calls; coalesce concurrent reads of the same address in a table.
Two registries, one name: conflict
Another project sells .arc / .circle names on Arc testnet from its own registry (0xc20B…fD1A, pinned in the SDK as KHENZARR_ARCNS_TESTNET). A .arc or .circle lookup consults every pinned registry for that TLD and labels each answer with its registry and product. Most of the time only one registry holds the name, or both agree, and resolve() returns one labelled answer. When they disagree:
try { await arcns.resolve("alice.arc"); }
catch (e) {
if (e instanceof ResolveError && e.code === "conflict") {
for (const c of e.candidates!) {
// c.product: "arcns" | "khenzarr-arcns"; c.registry: the contract; c.address; c.verification
}
}
}
Render every candidate with its product and registry short-address and let the user choose. Never auto-select. resolveAll() returns the same list without throwing, for search results and "show all matches" surfaces. The API maps conflict to 409 with every candidate in the body. A registry address that is not pinned is refused unless you pass allowUnpinnedRegistries: true.
Try the API
https://api.arcns.io reads the same chain and returns the same fields for callers that cannot run an EVM client. @ must be URL-encoded as %40 in a path.
curl -s https://api.arcns.io/health
# {"ok":true,"service":"api","version":"…"} — no RPC call; liveness only
curl -s https://api.arcns.io/resolve/%40nike # @nike
curl -s https://api.arcns.io/resolve/nike.arc # every pinned .arc registry consulted
curl -s https://api.arcns.io/resolve/nike.circle
curl -si https://api.arcns.io/resolve/%40nike.arc | head -1 # HTTP 409 — mixed shape, decided with no chain read
curl -s https://api.arcns.io/reverse/0xce42…54AC # the one primary, forward-confirmed
curl -s https://api.arcns.io/version # build, chain id, RPC host, head block
The full status contract and every response field are on the API page.
Next
- Building a send flow: Wallets.
- Showing names next to addresses: Explorers.
- What
verifiedmeans and how proofs are made: Verified records.
Questions
Questions: [email protected].

