Skip to content
ArcNS docs
DocsQuickstart

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.

Note

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 }.

  • namespace is handle, arc or circle. namespaceLabel(namespace) gives the display form (@handle, .arc, .circle).
  • registry is the contract the answer came from; product says whose it is (arcns or khenzarr-arcns).
  • controller is who owns the name. It is never the payment address and the SDK never falls back to it.
  • epoch bumps on every ownership change; records from an older epoch are unreachable by construction.
  • verification is verified or unverified. 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;
    }
  }
}
codeMeaning
unrecognizednot a handle or a known domain; probably a raw address
ambiguousboth shapes at once, e.g. @jack.arc
invalid-handle, invalid-domainright shape, invalid content; reason names the rule (HyphenAtEdge, NonAscii, AllDigits, …)
not-foundvalid name, not registered (or reason: "nft-burned"); only after a successful chain read
no-record-for-chainregistered, but no address record for the requested chain; never the controller
conflictpinned registries disagree; candidates carries every answer
tld-retired, tld-sunset, tld-pausedTLD lifecycle gates (reads: tld-retired; writes: all three)
unknown-registryan unpinned registry address without opt-in
offchain-lookupthe resolver asked for CCIP-read; v1 treats that as an error, never as data
rpc-errortransport or decoding failure; never reported as not-found
not-configuredno 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

Questions

Questions: [email protected].