@arcns/resolve
v0.2.0-testnet.1
Published
Resolve @handles, .arc and .circle names on Circle's Arc chain. Every result names its namespace and the registry it was read from; a mixed shape is refused, never guessed.
Maintainers
Readme
@arcns/resolve
Resolve @handles, .arc and .circle names to addresses on
Circle's Arc chain — the SDK behind arcns.io.
Built for wallets, explorers and payment flows. One rule drives the whole
design: a name is never just an address. @jack, jack.arc and
jack.circle are different namespaces that can belong to different people —
so every result tells you which namespace answered and which
registry it was read from. Resolution is single-registry and
authoritative: this SDK reads only the FortiBlox ArcNS registry, never a
competing one. A mixed shape (@jack.arc) is refused rather than guessed; a
name absent from the registry is a plain not-found.
import { createPublicClient, http } from "viem";
import { arcTestnet, createResolver } from "@arcns/resolve";
const client = createPublicClient({ chain: arcTestnet, transport: http(), ccipRead: false });
const arcns = createResolver({ client });
const r = await arcns.resolve("@jack");
// { name: "@jack", namespace: "handle", registry: "0x…", product: "arcns",
// address: "0x…", coinType: 2152525650, verification: "verified", epoch: 3n, … }
await arcns.resolve("jack.arc"); // our registry only, one authoritative answer
await arcns.resolve("@jack.arc"); // throws ResolveError { code: "ambiguous" }
await arcns.reverse("0xce42…54AC"); // the address's one primary across all namespaces, forward-confirmed, or nullStatus: pre-release, Arc testnet (chain id 5042002), not yet published to npm. Current version:
0.2.0-testnet.1(seepackage.json); the@arcnsnpm org has not been created yet (CEO action), so nothing under this name is installable from the registry today — see Install and Releasing below. The arcns contract addresses are filled in when the contracts lane deploys (deployments/5042002.json); until thencreateResolverneeds an explicitdeployment— every read (@handle, our.arc/.circleregistry,reverse()) throwsnot-configuredwithout one. The contract ABIs mirror the exportedcontracts/abi/*.json(M1 registry + TLD stack + oracle):test/abi-drift.test.jspins every function, error and event fragment andtest/enum-drift.test.jspins the Solidity enums, so a signature or enum change on chain fails the SDK build instead of shipping a silent mismatch.
Install
Not yet on the npm registry. The @arcns npm org has not been created
(CEO action, tracked in RELEASING.md), so npm install @arcns/resolve
will fail today — there is nothing to resolve. Once it is published
(pre-release versions ship under the testnet dist-tag, never latest):
npm install @arcns/resolve@testnet viemUntil then, build it from a checkout of this repo and install the local tarball or path:
git clone https://forgejo.selfhsted.com/fortiblox/fortiblox-arcns.git
cd fortiblox-arcns/sdk
npm ci && npm run build
npm pack # arcns-resolve-<version>.tgz
# in your project:
npm install /path/to/fortiblox-arcns/sdk/arcns-resolve-<version>.tgz viem
# or, for local iteration: npm install /path/to/fortiblox-arcns/sdk viemviem 2.x is a peer dependency. ESM and CommonJS builds are both shipped.
The one rule: show the namespace and the registry before you send
There is no API in this SDK that returns a bare address. Every Resolved
carries namespace, registry, product, chainId and epoch (SR-04).
Render the namespace badge and the registry short-address in your recipient
field before the user commits a transfer (SR-72).
Dispatch is on shape (@ prefix vs a known TLD suffix), never "try one,
fall back to the other". @jack.arc throws ambiguous. Use
looksLikeName(input) to decide whether to attempt resolution at all, so a
pasted 0x… address does not surface a validation error.
Namespaces
| Shape | Namespace | Where it lives |
|---|---|---|
| @alice, pay.@alice | handle | HandleRegistry (ERC-721, permanent, ASCII-only, reserved genesis) |
| alice.arc, pay.alice.arc | arc | verbatim ENS registry, .arc registrar pair |
| alice.circle | circle | verbatim ENS registry, .circle registrar pair |
TLDs are data: the SDK reads the live table from TldDirectory
(loadTldTable — every row tldNodes() lists, or an explicit label set) and
parseName / resolve / reverse take the loaded set. Adding a TLD is a
table row, not a code change. A TLD in sunset keeps
resolving until its sunsetAt, then every read is tld-retired — never a
stale address.
Normalization
normalizeHandle / tryNormalizeHandle are byte-for-byte the Solidity
HandleNormalize library (itself a port of the Rust handle-normalize
crate — the single source of truth, SR-01). .arc / .circle labels use the
same grammar: ASCII a-z0-9-, 1–32 bytes, no edge or double hyphen, not
all-digit. No Unicode, no emoji — for a payment identifier a homograph is a
live attack (@аlice with a Cyrillic а). The conformance suite
(test/conformance.test.js) pins all 65 generated fixtures, reason names
included; CI regenerates the fixtures from Solidity and fails on drift.
Rejections carry the same reason name the chain reports:
ResolveError { code: "invalid-handle" | "invalid-domain", reason: "HyphenAtEdge" | … }.
Single-registry, authoritative
.arc / .circle resolve only through the FortiBlox ArcNS registry
(deployment.ensRegistry). There is no cross-resolution against, comparison
with, or warning about any other registry on Arc — resolve() /
resolveAll() never read a second registry, and a name this registry does
not hold is a plain ResolveError { code: "not-found" }, exactly like any
other unregistered name (CEO decision 2026-09-09: single-registry,
winner-take-all — see docs/plan/open-questions.md).
API
createResolver(config: ResolverConfig): Resolver
interface ResolverConfig {
client: PublicClient; // viem; chain set; pass ccipRead: false (SR-24)
deployment?: ArcnsDeployment; // default ARCNS_DEPLOYMENTS[chainId]; absent → not-configured for our paths
tldTable?: TldTable; // default: loadTldTable(client, deployment.tldDirectory), lazily
cacheTtlMs?: number; // default 5_000 (SR-25); 0 disables; resolve() only
now?: () => number; // unix seconds for TLD gates
}
interface Resolver {
resolve(input, opts?: { chain?: Chain; fallbackChains?: Chain[] }): Promise<Resolved>; // the one authoritative answer, or `not-found`
resolveAll(input, opts?): Promise<readonly ResolvedCandidate[]>; // `[resolve(input, opts)]` — kept for API shape stability
reverse(address): Promise<Primary | null>; // one primary across all namespaces, forward-confirmed
records(input): Promise<readonly AddressRecord[]>; // every coin type with a record, each with `verification`
text(input, key): Promise<string | null>;
texts(input, keys): Promise<Readonly<Record<string, string | null>>>; // several text records, one gate (WP-211)
state(input): Promise<NameState>; // controller / epoch / node, no record read
record(input, chain): Promise<AddressRecord | null>; // one coin type
tlds(): Promise<TldTable>;
challenge(input, chain, nonce?): Promise<RecordChallenge>; // SR-41 challenge + EIP-712 typed data, bound to versionOf(node)
clearCache(): void;
}Chain is "ARC" | "ETH" | "BTC" | "SOL" | "X1"; COIN_TYPE.ARC is the
ENSIP-11 coin type 0x80000000 | 5042002 = 2152525650. The default
chain is ARC; fallbackChains: ["ETH"] tries the coin-type-60 record
next and reports which one answered via coinType.
Every Resolved carries input, name, namespace, chainId, registry,
product, resolver, node, controller, epoch, coinType, chain,
address, addressBytes, verification, (for handles) handleType and
(for TLDs) tld: { status, sunsetAt }. controller is who owns the name; it
is never used as the payment address (SR-21).
Agent handles (WP-315)
import { isAgentHandle, handleTypeName, HANDLE_TYPE_NAMES } from "@arcns/resolve";
const r = await arcns.resolve("@payday-bot");
r.handleType; // "Human" | "Merchant" | "Org" | "Agent" (handles only; undefined for .arc / .circle)
isAgentHandle(r); // true only for namespace "handle" + handleType "Agent"
isAgentHandle(await (await fetch("https://api.arcns.io/resolve/%40payday-bot")).json()); // same rule on an API body
handleTypeName(3); // "Agent"; an unknown code is "Unknown", never a guesshandleType is the IHandleRegistry.HandleType the registrant declared at
registration. It is descriptive — nobody verifies it — so it tells you how to
address a name (an agent may publish an agent.manifest text record, see
docs/architecture/agent-records.md), never whether to pay it; that is
verification. The REST equivalent is handleType in the /resolve body
(api/schema/resolve.json) and /search?handleType=Agent on the query API;
docs/resolution-integration.md §6 has the agent checklist.
Agent records (WP-211)
An agent name carries its endpoints and identity as agent.* text records
(docs/architecture/agent-records.md); the payment address is never a text
record — it is the on-chain Arc record with its verified flag.
import { readAgentRecords, verifyErc8004Owner, agentVerificationLevel, validateManifest, x402AcceptsFromManifest } from "@arcns/resolve";
const r = await readAgentRecords(client, "@payday-bot"); // { manifest?, x402?, mcp?, a2a?, erc8004?, capabilities?, payTo?, verified, rejected, controller, … }
agentVerificationLevel(r); // "none" | "L0" (L0: declared by records or handleType Agent)
const l1 = await verifyErc8004Owner(client, r); // IdentityRegistry.ownerOf(agentId) == controller? { ok, agentOwner, nameOwner, reason? }
agentVerificationLevel(r, l1); // "L1" when l1.ok and l1.link is the name's own agent.erc8004
const json = await (await fetch(r.manifest)).json(); // your fetch, your policy (https:// or ipfs://<cid>)
const v = validateManifest(json, { records: r }); // schema (sdk/schema/agent-manifest.json) + cross-checks; { ok, manifest | issues }
if (v.ok) x402AcceptsFromManifest(v.manifest, { payTo: r.payTo }); // accepts[]-shaped hints; the live 402 is authoritative| Export | What it does |
|---|---|
| AGENT_RECORD_KEYS | agent.manifest / agent.x402 / agent.mcp / agent.a2a / agent.erc8004 / agent.capabilities (D1) |
| readAgentRecords(client, name, opts?) | every key plus payTo = addr(node, 0x804cef52) and verified, through createResolver (state / texts / record). Same gate as records(): not-found, nft-burned, TLD lifecycle, rpc-error. A value that breaks the reader rules is listed in rejected, never returned |
| checkAgentUrl(value, key?) | D2: https:// only; ipfs://<cid> additionally for agent.manifest; credentials, empty host, anything else refused |
| parseErc8004Link / tryParseErc8004Link / formatErc8004Link | eip155:<chainId>:<IdentityRegistry>:<agentId>; checksum-validated registry, decimal ids; AgentRecordError { code: "invalid-erc8004-link", reason } |
| ERC8004_IDENTITY_REGISTRY[5042002] | 0x8004A818BFB912233c491871b3d84c89A494BD9e — a link to any other registry is unknown-registry, never read |
| verifyErc8004Owner(client, name \| records, link?, opts?) | L1: ownerOf(agentId) vs the name's controller; wrong-chain / unknown-registry / agent-not-found / owner-mismatch; transport → rpc-error |
| isAgentName(records) | D3: agent.manifest or agent.erc8004 set, or an Agent handle. isAgentHandle stays handle-only |
| agentVerificationLevel(records, l1?) | none / L0 / L1. L2 (the agent.x402 URL answers 402 with payTo == addr record) is a live HTTP fact against an arbitrary host: the MCP server probes it, the SDK never does |
| validateManifest(json, { records?, addressRecords? }) / assertManifest | JSON Schema draft 2020-12 (AGENT_MANIFEST_SCHEMA, strict, closed objects), then canonical name, link checksum, D2 URLs, and agreement with the chain: arcns.name/chainId/node, identity.erc8004, the x402 endpoint, and every payment.addresses[] mirror (never more verified than the chain). No content pinning (D6) |
| x402AcceptsFromManifest(manifest, { payTo? }) | one x402 v2 accepts[]-shaped hint per x402 endpoint (scheme, network, resource, asset?, payTo?, facilitator?, extra?); pass the chain's payTo so it wins over the manifest's mirror |
The resolver gained the primitives these are built on: state(name) (controller / epoch / node, no record read), texts(name, keys) (one gated pass, keys in parallel) and record(name, chain) (one coin type).
Reverse
const p = await arcns.reverse("0xce42…54AC");
// { name: "alice.arc", namespace: "arc", registry, product, chainId, node, address, controller } | nullReturned only while the primary still forward-confirms: @alice must be
owned by the address; alice.arc / bob.circle must resolve back to it
(addr(node, 0x804cef52), then coin type 60) in our registry. A stale string
is null, never the previous owner.
Writes — build, sign, relay
Every builder returns a PreparedCall { chainId, to, data, value, abi,
functionName, args, description } you hand to walletClient.sendTransaction
(or writeContract with abi/functionName/args). The SDK never holds
keys. Registration is commit → wait minCommitmentAge → reveal. Every
reveal field is inside the commitment (SR-10: handleType for a handle;
resolver, data, reverseRecord for a TLD name), so pass the same fields
to buildCommit and buildRegister:
import { generateSecret, buildCommit, buildRegister, buildSetAddr, buildSetPrimary, buildVerifyAddrSelf, tldRegistrarControllerAbi } from "@arcns/resolve";
const secret = generateSecret();
const reg = { deployment, name: "alice.arc", owner, secret, reverseRecord: true };
await wallet.sendTransaction(buildCommit(reg));
// … wait minCommitmentAge (read it from the controller) …
const price = await client.readContract({ address: deployment.tlds.arc.controller, abi: tldRegistrarControllerAbi, functionName: "quote", args: ["alice"] });
await wallet.sendTransaction(buildRegister({ ...reg, price })); // price = msg.value (18-dec USDC wei); maxPrice defaults to price
await wallet.sendTransaction(buildSetAddr({ deployment, name: "alice.arc", chain: "BTC", address: "bc1q…" }));
await wallet.sendTransaction(buildVerifyAddrSelf({ deployment, name: "alice.arc" })); // proves the Arc address (msg.sender)
await wallet.sendTransaction(buildSetPrimary({ deployment, name: "alice.arc" })); // one primary per address, any namespaceregister reverts PriceChanged when the live quote exceeds maxPrice and
credits any overpayment to the payer — buildWithdraw({ deployment, namespace })
pulls it (SR-11/31). buildTokenize({ name, fee, maxPrice? }) pays exactly
ArcNSPriceOracle.quoteTokenize(HANDLE_ROOT, label) (priceOracleAbi).
Also: buildTransfer / buildTransferFrom, buildClearAddr,
buildSetText, buildVerifyAddr / buildVerifyAddrSelf1271 (with a proof),
buildApproveDelegate, buildMulticall, buildRelease, buildLock /
buildInitiateUnlock / buildCompleteUnlock / buildCancelUnlock,
buildSetRecovery / buildInitiateRecovery / buildCancelRecovery /
buildCompleteRecovery, buildCreateSubname / buildRevokeSubname,
buildSetSubnodeRecord. TLD writes are refused with tld-paused /
tld-sunset / tld-retired when the directory says so.
Ownership proofs (SR-41, onchain-design §5)
A proof is bound to ArcNSResolver.versionOf(node) — the record-version key
that changes on every ownership change — so a captured proof from a previous
owner never validates for the next. arcns.challenge() reads it and returns
both signable forms:
import { buildVerifyAddr } from "@arcns/resolve";
const c = await arcns.challenge("alice.arc", "ETH");
// c.challenge = "arcns:v1:rec:0x<node>:60:0x<version>:5042002:0x<resolver>:<nonce>" (Challenge.sol, byte for byte)
// c.typedData = EIP-712 { domain: { name: "arcns", version: "1", chainId, verifyingContract }, RecordProof(node, coinType, version, nonce) }
const signature = await ethWallet.signTypedData(c.typedData); // EVM records sign the typed data
buildVerifyAddr({ deployment, name: "alice.arc", chain: "ETH", nonce: c.nonce, proof: { signature } }); // → verifyAddrSecp256k1| record | wallet signs | resolver entry point (chosen by buildVerifyAddr) |
|---|---|---|
| ARC, EOA | nothing — msg.sender == record | buildVerifyAddrSelf → verifyAddrSelf |
| ARC, contract wallet | EIP-712 RecordProof (ERC-1271) | buildVerifyAddrSelf1271 |
| ETH / any EVM | EIP-712 RecordProof | verifyAddrSecp256k1 |
| BTC | BIP-137 over c.challenge, plus the 64-byte uncompressed pubkey | verifyAddrBtc |
| SOL / X1 | ed25519 over c.challenge bytes, plus the 32-byte pubkey | verifyAddrEd25519 |
recordProofDigest equals ArcNSResolver.recordProofDigest on chain;
verifyEthTypedProofLocal / verifyControlProofLocal are client-side
pre-checks so a wallet refuses a doomed transaction before it is sent. Control
challenges (ctl, issueControlChallenge) share the layout and are verified
off chain by the API, never by the resolver.
Errors
ResolveError carries a code a UI can branch on:
| 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 |
| 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 |
| tld-retired / tld-sunset / tld-paused | TLD lifecycle gates (reads: tld-retired; writes: all three) |
| offchain-lookup | the resolver asked for CCIP-read; v1 treats that as an error, never as data |
| rpc-error | transport / decoding failure — never reported as not-found |
| not-configured | no deployment for this chain — never a guessed address |
Configuration without a deployment
import { deploymentFromJson, deploymentFromEnv } from "@arcns/resolve";
const deployment = deploymentFromJson(JSON.parse(readFileSync("deployments/5042002.json", "utf8")));
// or, in a service: deploymentFromEnv(process.env) — HANDLE_REGISTRY_ADDRESS, ENS_REGISTRY_ADDRESS, …Missing contracts throw not-configured; there are no default addresses.
Developing
npm ci
npm run build # tsc → dist/esm + dist/cjs
npm run lint # eslint (type-aware on src); publint runs at the end of build
npm test # node --test (fixture conformance is the first suite)Releasing (current: 0.2.0-testnet.1, dist-tag testnet)
The npm org @arcns does not exist yet (CEO action); nothing is published
from this repo until it does. The recipe, run by the release-director from a
clean checkout of the tagged commit:
cd sdk
npm ci --no-audit --no-fund
npm run build && npm run lint && npm test # publint runs at the end of build
npm pack --dry-run # inspect the file list: dist/, README, CHANGELOG, LICENSE
NODE_AUTH_TOKEN="$(cat /path/to/npm-token)" npm publish --dry-run --access public --tag testnet
git tag -s "sdk-v$(node -p "require('./package.json').version")" -m "@arcns/resolve $(node -p "require('./package.json').version")"
git push origin --tags
NODE_AUTH_TOKEN="$(cat /path/to/npm-token)" npm publish --access public --tag testnet --provenance # drop --provenance if CI OIDC is unavailable
npm view @arcns/resolve@testnet version # must equal package.jsonRules: pre-releases go to the testnet tag, never latest; the token is
read from a file at publish time, never exported into a shell profile or
committed; rollback is npm deprecate @arcns/resolve@<version> "<reason>"
(npm does not allow unpublishing after 72 h) plus moving the testnet tag
back: npm dist-tag add @arcns/resolve@<previous> testnet.
License
MIT © Fortiblox
