Developer primitive

Prove a wallet controls a name.

A control proof lets any third party establish that a wallet controls @alice, alice.arc or alice.circle right now, using nothing but an EIP-191 signature over a challenge and public chain state — no transaction, no auth service, no API key. It is the primitive under “sign in with your name”, gated communities and marketplace attestations; sessions and accounts are whatever you build on top.

Try it

This page acts as the verifier and your wallet as the prover, live against the chain this build is pinned to, entirely in your browser. Signing with a wallet that does not control the name is worth trying: the refusal it produces is the point of the primitive.

Issue a challenge

Verifier

This page reads the name’s current controller and version (ArcNSResolver.versionOf) from Arc Testnet and binds a fresh single-use nonce to that version. The verifier keeps the issued object — a prover can never substitute its own challenge.

Sign and verify

ProverVerifier

Your wallet signs the challenge string with EIP-191 personal_sign — a signature request, never a transaction. Any connected wallet may sign: if it is not the name’s current controller, verification refuses the proof with the honest reason.

Issue a challenge above to enable signing.

The challenge

arcns:v1:ctl:<node>:0:<version>:<chainId>:<resolver>:<nonce>
Challenge segments and their meaning
arcnsProtocol tag — no other product's signatures collide.
v1Format version.
ctlPurpose. Control proofs are a separate signature space from record proofs (rec) — disjoint by construction, so a signature obtained for one can never validate as the other.
<node>The name’s node, lowercase 0x hex, 32 bytes (parseName(...).node): keccak256(HANDLE_ROOT ‖ labelhash) for @names, ENS namehash(label.tld) for .arc / .circle. The namespace is bound through it — one resolver serves every namespace, and no TLD name can produce a HANDLE_ROOT-derived node.
0coinType. A control proof is about the name, not a record, so it is 0 by convention; record proofs carry the SLIP-44 / ENSIP-11 coin type.
<version>ArcNSResolver.versionOf(node), lowercase 0x hex, 32 bytes, read from chain by the verifier at issuance — the same read for every namespace. It changes on every ownership change (SR-12), so a past controller’s captured proof is worthless the moment the name changes hands.
<chainId>The chain the resolver lives on (5042002 = Arc testnet). A proof issued for one chain never validates on another.
<resolver>Lowercase hex of the ArcNSResolver that verifies the proof — the one whose versionOf the version came from. A different deployment’s alice.arc cannot borrow this proof.
<nonce>Verifier-issued: 16 CSPRNG bytes as 32 hex chars (generateNonce). ASCII alphanumeric, 1–64 chars; never chosen by the prover.

Fields are :-separated and no field may contain :, so the split is unambiguous. parseChallenge is the strict inverse of buildChallenge: prefix arcns:v1:, exactly 7 fields after it, purpose rec or ctl, lowercase 32-byte node and version, canonical decimals, lowercase resolver — anything else is a TypeError, and every record proof is rejected by construction. The same string shape, with purpose rec, is what non-EVM record proofs sign on chain (SR-41).

Three rules the verifier keeps

  1. Rule 1: You issue the nonce

    At least 16 bytes of CSPRNG entropy, single-use — keep the issued object, delete it the moment a proof against it is checked, pass or fail — and refuse challenges older than 5 minutes (issued.issuedAt). A prover-chosen nonce is not a nonce: anyone who ever observes one proof could replay it to every verifier for as long as the version holds. A verifier that cannot keep state cannot securely accept control proofs.

  2. Rule 2: The version and the controller are read twice

    Read versionOf(node) from chain when issuing, and read version and controller again when verifying. A changed version is version-changed; a signer who is not the current controller is signer-not-controller — however perfect the signature. verifyControlProofLocal only recovers the signer; it says so in its name. The issued object carries the node, not the name: keep the name beside it, so the re-read is for the name you issued for. Accepting a prover-supplied version, or skipping the re-read, reopens the replay this field exists to close.

  3. Rule 3: Verify EIP-191 strictly, over the exact string

    The signed message is the challenge string under the EIP-191 personal_sign prefix — nothing hashed or wrapped by you. Recover with viem’s recoverMessageAddress (what the SDK does) or any ecrecover that rejects high-s and malformed v; compare addresses case-insensitively. Parse the challenge with parseChallenge before trusting any field: prefix, exactly 7 fields after it, lowercase 32-byte node and version, canonical decimals, lowercase resolver, and a round-trip to the same string.

Who must sign

The proof establishes control only if the signer is the name’s current controller, read the way the registry reads it:

@handle
HandleRegistry.ownerOf(handleTokenId(label)). The handle is an ERC-721: the controller is whoever holds the token. A burned token reverts — nobody controls the name, no proof is valid, and the last owner is never returned (SR-22).
name.arc / name.circle
ENSRegistry.owner(namehash(name)) — verbatim ENS semantics. The registrar’s NFT holder and the registry owner are usually the same address but the registry is what resolution reads, so it is what a control proof reads.

No delegation: only the controller’s key signs. Never accept a signature from a name’s recovery address, an approved operator, a record-editing delegate, or the payment address a record points to (SR-21 — the controller is never the payment address).

Integrate it

Two SDK calls from @arcns/resolve (opens in a new tab) plus two chain reads, one side each — the demo above runs exactly this flow. Everything is eth_call over the RPC you configure; nothing depends on api.arcns.io (SR-26).

1 · Issue and store a challengeVerifier
import { createPublicClient, http } from "viem";
import {
  arcTestnet, ARCNS_DEPLOYMENTS, parseName, handleTokenId,
  handleRegistryAbi, ensRegistryAbi, resolverAbi, issueControlChallenge,
} from "@arcns/resolve";

const client = createPublicClient({ chain: arcTestnet, transport: http(), ccipRead: false });
const dep = ARCNS_DEPLOYMENTS[arcTestnet.id]!;
const parsed = parseName("@alice"); // { namespace: "handle", label: "alice", node }

// Read the LIVE version the challenge binds — the same read for every namespace:
//   ArcNSResolver.versionOf(node), on the one shared resolver.
// It changes on every ownership change (SR-12).
const version = await client.readContract({
  address: dep.resolver, abi: resolverAbi, functionName: "versionOf", args: [parsed.node],
});

// Remember who controls the name (see step 3 — you read it again there):
//   @handle   → HandleRegistry.ownerOf(handleTokenId(label))   (reverts when burned)
//   name.arc  → ENSRegistry.owner(node)                        (zero when nobody holds it)
const controller = await client.readContract({
  address: dep.handleRegistry, abi: handleRegistryAbi,
  functionName: "ownerOf", args: [handleTokenId(parsed.label)],
});

// Binds a fresh 16-byte CSPRNG nonce to node + version + chainId + resolver.
// coinType is 0 for control.
const issued = issueControlChallenge({
  node: parsed.node, version, chainId: arcTestnet.id, resolver: dep.resolver,
});

// Store `issued` server-side WITH the name it is for (the object carries only
// the node): single-use, refuse after 5 minutes (issued.issuedAt).
// Send only the string to the prover:
send(issued.challenge); // "arcns:v1:ctl:0x<node>:0:0x<version>:5042002:0x<resolver>:<nonce>"
2 · Sign the stringProver
// EIP-191 personal_sign over the challenge STRING — exactly the string the
// verifier sent, no hashing of your own, no EIP-712 envelope. viem, wagmi
// and Privy's signMessage all produce this.
const signature = await walletClient.signMessage({ account, message: challenge });

reply({ signer: account.address, signature }); // 65 bytes r ‖ s ‖ v
3 · Verify against what you storedVerifier
import { verifyControlProofLocal, handleRegistryAbi, resolverAbi, handleTokenId } from "@arcns/resolve";

// Pass the object YOU stored — never a string the prover sent back — and
// delete it the moment this runs, pass or fail.
if (Date.now() - issued.issuedAt > 5 * 60_000) return refuse("expired");

// 1. The signature: EIP-191 recovery of issued.challenge must equal `signer`
//    (and the purpose must be "ctl" — a record proof can never pass here).
if (!(await verifyControlProofLocal({ issued, signer, signature }))) return refuse("bad-signature");

// 2. Chain state, re-read now: a good signature only proves the signer.
//    `label` is what you stored next to `issued` for this name.
const [controller, version] = await Promise.all([
  client.readContract({ address: dep.handleRegistry, abi: handleRegistryAbi, functionName: "ownerOf", args: [handleTokenId(label)] })
    .catch(() => null), // ownerOf reverts for a burned token: nobody controls the name
  client.readContract({ address: issued.resolver, abi: resolverAbi, functionName: "versionOf", args: [issued.node] }),
]);
if (controller === null) return refuse("not-registered");
if (version.toLowerCase() !== issued.version) return refuse("version-changed");
if (controller.toLowerCase() !== signer.toLowerCase()) return refuse("signer-not-controller");

// Valid: `signer` controls @alice right now, at version issued.version.
// Not a lasting credential — re-challenge when you need the fact again.

Every way a proof is refused

The SDK’s verifyControlProofLocal answers one question with a boolean: did this signer sign this challenge. The verdicts below are the verifier’s — the demo above uses these names — so an integration can branch honestly:

Verifier refusal reasons
expiredMore than five minutes since issued.issuedAt. Issue a fresh challenge.
bad-signatureverifyControlProofLocal returned false: EIP-191 recovery of the challenge does not yield the claimed signer, or the challenge's purpose is not ctl.
not-registeredNobody holds the name at verification time — never registered, or a released handle (ownerOf reverts; the last owner is never returned, SR-22).
version-changedversionOf(node) read at verification differs from issued.version — the name changed hands or was re-registered. Issue a fresh challenge.
signer-not-controllerThe signer is not HandleRegistry.ownerOf / ENSRegistry.owner right now. The signature may be flawless; it proves nothing.

Thrown by the SDK, before any verdict

Errors the SDK throws
TypeErrorThrown by issueControlChallenge for a malformed input: node must be a 0x-prefixed 32-byte hex string, version must be a 0x-prefixed 32-byte hex string, chainId must be a positive integer, resolver … is not an address, nonce must be ASCII alphanumeric, 1..64 chars. Thrown by parseChallenge (which verifyControlProofLocal runs first) for a malformed challenge: not an arcns:v1 challenge, expected 7 fields, got …, bad purpose …, node must be lowercase 0x hex, 32 bytes, numeric fields must be canonical decimals, version must be lowercase 0x hex, 32 bytes, resolver must be lowercase 0x hex, challenge is not in canonical form. A malformed challenge is a bug on the verifier’s side, not a verdict.
ResolveErrorFrom parseName and chain reads: unrecognized, ambiguous (@alice.arc), invalid-handle / invalid-domain with the normalizer reason, not-configured. An rpc-error means the chain could not be read — refuse, never report it as not registered (SR-25).

A valid proof is evidence of control between issuance and verification, at one version — nothing more. It is not storable as a credential: re-challenge whenever you need the fact again. Full contract: docs.arcns.io/sdk (opens in a new tab).