Prove a wallet controls a name.
A control proof lets any third party establish that a wallet controls a @handleright now, using nothing but a signed challenge and public chain state — no program call, no auth service, no API key. It is the primitive under “sign in with your handle”, 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 X1 testnet, entirely in your browser. Signing with a wallet that does not control the handle is worth trying: the refusal it produces is the point of the primitive.
Issue a challenge
This page reads the handle’s current ownership epoch from chain and binds a fresh single-use nonce to it. The verifier keeps the issued object — a prover can never substitute its own challenge.
Sign & verify
Your wallet signs the raw UTF-8 challenge bytes — a signature request, never a transaction. Any connected wallet may sign: if it isn’t the handle’s current authority, verification refuses the proof with the honest reason.
Issue a challenge above to enable signing.
The challenge
x1-handles:ctl:v1:<handle>:<registered_at>:<nonce>| x1-handles | Protocol tag — no other product's signatures collide. |
| ctl | Purpose tag. Control proofs are a separate signature space from record verification (x1-handles:v1:…) — a signature obtained for one claim can never validate as the other. |
| v1 | Format version. |
| <handle> | The canonical handle: lowercase, no @. Non-canonical input is refused, never normalized into the string. |
| <registered_at> | The handle's CURRENT ownership epoch (Handle.registered_at), read from chain by the verifier at issuance. It changes on every sale and re-registration, so a past owner's captured proof is worthless the moment the name changes hands. |
| <nonce> | Verifier-issued, ASCII alphanumeric, 1–64 chars. Never chosen by the prover. |
No segment may contain :, so the segmentation is unambiguous. Parse strictly — six segments, segment 2 ctl, segment 3 v1 — and every record-verification challenge is rejected by construction.
Three rules the verifier keeps
- 1You issue the nonce
At least 16 bytes of CSPRNG entropy, single-use — store the issued challenge and delete it the moment a proof against it is checked, pass or fail — and refuse challenges older than 5 minutes. A prover-chosen nonce is not a nonce: anyone who ever observes one proof could replay it to every verifier for the rest of the ownership epoch. A verifier that cannot keep state cannot securely accept control proofs.
- 2The epoch pins the owner
Read
registered_atfrom chain when issuing, and again when verifying: if it changed in between, reject (epoch-changed) and issue a fresh challenge. Accepting a prover-supplied epoch — or skipping the re-read — reopens the replay this field exists to close. - 3Verify ed25519 strictly
The signed bytes are the raw UTF-8 of the challenge string — no prefix, no envelope. Verification must be RFC-8032 strict: reject
s ≥ Land anything that fails RFC 8032 decoding. WebCryptoEd25519(the SDK default), ed25519-dalekverify_strict, @noble/ed25519 and libsodium all qualify; a lax verifier accepts a second byte-form of every signature.
Who must sign
The proof establishes control only if the signer is the handle’s current authority, resolved exactly as the registry program resolves it:
- Untokenized handle
- The authority is
Handle.owner. - Tokenized handle
- The authority is the current holder of the handle’s NFT, and only while the NFT sits in that holder’s associated token account.
Handle.owneris stale the moment a name is tokenized — the NFT is a bearer instrument, and a verifier that reads the field naively verifies the wrong party for exactly the names that trade hands. NFT parked outside the ATA or burned: no wallet is the authority, and no proof is valid. The SDK resolves all of this for you.
No delegation: only the current authority’s key signs. Never accept a signature from a handle’s recovery key, its record-editing delegate, or any other related account.
Integrate it
Two SDK calls from @x1id/resolve, one per side of the exchange — the demo above runs exactly this flow.
import { WasmResolver, createControlChallenge } from "@x1id/resolve";
const wasm = await WasmResolver.fromBytes(
await (await fetch(wasmUrl)).arrayBuffer(),
);
const cfg = { rpcUrl: "https://rpc.testnet.x1.xyz", wasm };
// Reads the handle's LIVE ownership epoch from chain and binds a fresh
// 16-byte CSPRNG nonce to it.
const issued = await createControlChallenge(cfg, "alice");
// Store `issued` server-side. It is single-use, and you must refuse it
// after 5 minutes. Send only the string to the prover:
send(issued.challenge); // "x1-handles:ctl:v1:alice:<epoch>:<nonce>"// Sign the raw UTF-8 bytes of the challenge string — no length prefix,
// no hashing, no wallet envelope. This is exactly what Solana-style
// wallets and Privy's signMessage produce.
const signature = await wallet.signMessage(
new TextEncoder().encode(challenge),
);
reply({ signer: walletAddress, signature }); // base58 pubkey + 64 bytesimport { verifyControlProof } from "@x1id/resolve";
// Pass the challenge object YOU stored — never a string the prover sent
// back — and delete it the moment this returns, pass or fail.
const result = await verifyControlProof(cfg, issued, { signer, signature });
if (result.valid) {
// `signer` controls @${result.handle} right now, in ownership epoch
// result.registeredAt. Not a lasting credential: re-challenge when you
// need the fact again.
} else {
// result.reason — "epoch-changed" | "signer-not-authority" | … (below)
}Every way a proof is refused
verifyControlProof returns { valid: false, reason } — never a bare throw for a verdict — so your integration can branch honestly:
| handle-not-registered | No handle account exists (or the account is not owned by the registry program). |
| epoch-changed | registered_at changed after issuance — the name was sold or re-registered. Issue a fresh challenge. |
| nft-burned | Tokenized handle whose NFT was burned: nobody controls the name, no proof is valid. |
| nft-not-in-authority-ata | The NFT sits outside its holder's associated token account — the registry recognises no authority. |
| signer-not-authority | The signer is not the current authority. The signature may be flawless; it proves nothing. |
| bad-signature | The 64 bytes do not verify (RFC-8032 strict) over the challenge bytes with the signer's key. |
A valid proof is evidence of control between issuance and verification, within one ownership epoch — nothing more. It is not storable as a credential: re-challenge whenever you need the fact again.
