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
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
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>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
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.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 isversion-changed; a signer who is not the current controller issigner-not-controller— however perfect the signature.verifyControlProofLocalonly 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.Rule 3: Verify EIP-191 strictly, over the exact string
The signed message is the challenge string under the EIP-191
personal_signprefix — nothing hashed or wrapped by you. Recover with viem’srecoverMessageAddress(what the SDK does) or any ecrecover that rejects high-sand malformedv; compare addresses case-insensitively. Parse the challenge withparseChallengebefore 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).
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>"// 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 ‖ vimport { 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:
Thrown by the SDK, before any verdict
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).