Developer primitive

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.

1

Issue a challenge

Verifier

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.

@
2

Sign & verify

ProverVerifier

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-handlesProtocol tag — no other product's signatures collide.
ctlPurpose 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.
v1Format 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

  1. 1
    You 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.

  2. 2
    The epoch pins the owner

    Read registered_at from 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.

  3. 3
    Verify 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 ≥ L and anything that fails RFC 8032 decoding. WebCrypto Ed25519 (the SDK default), ed25519-dalek verify_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.owner is 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.

1 · Issue and store a challengeVerifier
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>"
2 · Sign the raw bytesProver
// 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 bytes
3 · Verify against what you storedVerifier
import { 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-registeredNo handle account exists (or the account is not owned by the registry program).
epoch-changedregistered_at changed after issuance — the name was sold or re-registered. Issue a fresh challenge.
nft-burnedTokenized handle whose NFT was burned: nobody controls the name, no proof is valid.
nft-not-in-authority-ataThe NFT sits outside its holder's associated token account — the registry recognises no authority.
signer-not-authorityThe signer is not the current authority. The signature may be flawless; it proves nothing.
bad-signatureThe 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.