Add @handle support to a send flow
This walks through wiring @x1id/resolve into a wallet's recipient field. The live reference integration is the X1ID app itself — app.x1id.io uses exactly this SDK for its own resolution and reverse lookup. It covers the part that's easy to get wrong: what to show the user for each thing resolve() can do, so a mistyped or ambiguous name never turns into a transfer to the wrong recipient.
@handle resolution is live on X1 mainnet. The examples below point rpcUrl at X1 mainnet; the same registry at the same program ID is also on X1 testnet (https://rpc.testnet.x1.xyz) for development. X1NS .x1 / .xnt / .xen domain resolution — a separate, older system — exists on mainnet only. This page targets @x1id/[email protected].
The setup
Most wallets already have a send screen with a recipient input. The only new piece: before that input is treated as an address, check whether it looks like a name, and if so, resolve it before rendering a recipient.
import { createResolver, WasmResolver, ResolveError, looksLikeName } from '@x1id/resolve';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const wasmPath = require.resolve('@x1id/resolve/wasm/x1_resolve_wasm.wasm');
const wasm = await WasmResolver.fromBytes(readFileSync(wasmPath));
// testnet works the same: rpcUrl: 'https://rpc.testnet.x1.xyz'
const x1id = createResolver({ rpcUrl: 'https://rpc.mainnet.x1.xyz', wasm });(For a browser build, swap the WASM-loading block for the fetch-based one in the SDK reference.)
Step 1 — decide whether to resolve at all
A recipient field gets two kinds of input: raw addresses (pasted or scanned) and names. Don't run a raw address through resolve() — it'll just throw unrecognized. Use looksLikeName() as the branch point:
function onRecipientInputChange(value: string) {
if (looksLikeName(value)) {
resolveRecipient(value); // → Step 2
} else {
// Treat as a raw address: run your existing validation/checksum path
setRecipient(validateRawAddress(value));
}
}Step 2 — resolve and shape the result
This is the core of the integration. resolve() either returns a Resolved or throws a ResolveError with a code your UI can switch on — there's no bare-string error to match against.
/** What the recipient field renders. The address is never shown alone. */
interface Recipient {
address: string;
display: string; // "@jack" or "jack.x1" — the user must see which one they're paying
verified: boolean; // false → show a warning before the user can send
}
type ResolveOutcome = { recipient: Recipient } | { hint: string };
async function resolveRecipient(input: string): Promise<ResolveOutcome> {
try {
const r = await x1id.resolve(input);
return {
recipient: {
address: r.address,
display: r.namespace === 'handle' ? `@${r.name}` : r.name,
verified: r.verification === 'verified',
},
};
} catch (e) {
if (e instanceof ResolveError) {
switch (e.code) {
case 'not-found': return { hint: 'No name found' };
case 'ambiguous': return { hint: 'Type either @jack or jack.x1 — not both' };
case 'invalid-handle': return { hint: 'Handles allow letters, digits and hyphens only' };
case 'invalid-domain': return { hint: "That doesn't look like a valid domain" };
case 'no-record-for-chain': return { hint: 'This name has no address on the selected chain' };
case 'rpc-error': return { hint: 'Network issue — try again' };
default: return { hint: 'Could not resolve that name' };
}
}
throw e; // an unexpected, non-ResolveError failure — don't swallow it
}
}Step 3 — the outcomes
for (const input of ['@nike', 'jack.x1', '@jack.x1', 'not a name']) {
console.log(input, '→', await resolveRecipient(input));
}
// "@nike" → { recipient: { address: "E2cELM…", display: "@nike", verified: true } } ← held by the treasury: Step 4 refuses it
// "jack.x1" → { recipient: { address: "9pXw…", display: "jack.x1", verified: false } } (mainnet RPC)
// "@jack.x1" → { hint: "Type either @jack or jack.x1 — not both" }
// "not a name" → never reaches resolveRecipient — looksLikeName() returns false first- Resolved, verified — a handle resolved and control of the address was proved (the owner holds the registry account, or — for a tokenized handle — holds its NFT in their ATA). Show the recipient, then run the pre-send checks before enabling send.
verifiedsays who controls the address, not who the name represents:@nikeabove isverifiedbecause the X1ID treasury holds it as a reserved name. - Resolved, unverified — an X1NS domain resolved to its owner, or a tokenized handle whose NFT sits outside the holder's ATA. Show the recipient, but surface the distinction — don't hide it behind the same confident UI as a verified result.
- Not found / invalid —
not-found,invalid-handle,invalid-domain: nothing to render. Show an inline hint, leave send disabled, don't clear what the user typed. - Ambiguous — never guess.
The one case that must never silently pick a side. @jack and jack.x1 can belong to different people. Input shaped like both — @jack.x1 — throws ambiguous instead of the SDK guessing. Your UI must not guess either: don't fall back to "try the handle first, then the domain." Ask the user to disambiguate.
Step 4 — check before you sign
What a user sees while typing can come from cache, and some names resolve to an address you must not pay. Right before you ask the wallet to sign a send, run these checks against fresh chain state and refuse the send if any fails:
- Re-resolve uncached. The SDK caches
resolve()for 30 s by default and has no per-call bypass, so keep a second resolver withcacheTtlMs: 0(or callclearCache()first) for the final resolve. If the address differs from the one the user confirmed, show the new one and ask again. - Refuse held (reserved) names. Brands, tickers, well-known words and a curated set of short names are registered to the X1ID treasury (
E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6) until they reach an owner through the marketplace. They resolve normally, to the treasury, and neither the SDK nor/v1/resolveflags them. Check the free REST/quote/{name}:status: "held"means treasury-held. Also refuse any resolution whose address is the treasury. - Refuse lapsed leases. The SDK and
/v1/resolveare lease-unaware: a lease-to-own handle past its paid-through date still resolves to the current holder, and can be reclaimed by someone else after its grace period. The free REST/resolve/{name}addslease: { expires_at, lapsed }for a leased handle; refuse whenlapsedistrue.
const TREASURY = 'E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6'; // Config.treasury (immutable)
const fresh = createResolver({ rpcUrl: 'https://rpc.mainnet.x1.xyz', wasm, cacheTtlMs: 0 });
type PreSend = { ok: true; address: string } | { ok: false; reason: string };
async function checkBeforeSend(input: string, chain: 'X1' | 'SOL' | 'ETH' | 'BTC' = 'X1'): Promise<PreSend> {
const r = await fresh.resolve(input, { chain }); // uncached; throws ResolveError as in Step 2
if (r.address === TREASURY) return { ok: false, reason: 'This name is held by X1ID and has no owner yet.' };
// The REST checks take top-level @handles only. Scoped names (name.tld) and
// subnames (pay.alice) are resolved by the SDK above; /quote rejects them.
const topLevelHandle = r.namespace === 'handle' && !r.name.includes('.');
if (topLevelHandle) {
const base = 'https://api.x1id.io';
const name = encodeURIComponent(r.name);
const [quote, rest] = await Promise.all([fetch(`${base}/quote/${name}`), fetch(`${base}/resolve/${name}?fresh=1`)]); // fresh: skip the ~20 s answer cache
// Fail closed: if a check can't be read (429, 5xx), don't send.
if (!quote.ok || !rest.ok) return { ok: false, reason: 'Could not verify this name right now. Try again.' };
const q = await quote.json();
const res = await rest.json();
if (q.status === 'held') return { ok: false, reason: 'This name is reserved and held by X1ID.' };
if (res.lease?.lapsed === true) return { ok: false, reason: "This name's lease has lapsed." };
}
return { ok: true, address: r.address };
}The REST checks cover top-level @handles only: /quote rejects a dotted name, so for scoped names (name.tld) and subnames use the SDK (the uncached resolve plus the treasury check above) and don't refuse them just for being dotted. An unregistered name of any kind throws not-found from the SDK, so it never reaches a send. The REST API is rate-limited per IP (100 requests/s, burst 200; see Rate limits), so a backend that runs these checks for many users should expect 429 or 503 under load, wait the Retry-After seconds, and keep failing closed.
Edge cases
| Case | SDK resolve() | REST /resolve | /v1/resolve | Do |
|---|---|---|---|---|
| Unregistered | throws not-found | 404 | 404 | show "No name found" |
| Held / reserved (treasury) | resolves to the treasury, no flag | same | same | /quote status: "held" → refuse |
| Leased, past paid-through date | resolves, no lease signal | resolves, lease.lapsed: true | resolves, no lease signal | refuse when lapsed |
Bare jack (no @) | throws (needs @) | treated as a handle | treated as a handle | require the @ in your UI |
@jack.x1 | throws ambiguous | 409 | 400 AMBIGUOUS | ask the user which one |
| X1NS name on testnet | not found | — | — | X1NS is mainnet-only |
Trust fields
| Field | Where | What it means | What it does not mean |
|---|---|---|---|
namespace | SDK, REST /resolve, /v1/resolve | which naming system answered (handle, x1, xnt, xen, or a customer TLD) | — always show it before a send |
verification: "verified" | SDK | control of the address is proved: it owns the registry account, or holds the handle's NFT in its ATA | that the name belongs to the person or brand it reads as (held names are verified to the treasury) |
verification: "unverified" | SDK | ownership not proved for that chain; X1NS results are always unverified | — show a warning |
source | /v1/resolve | the SDK's verification, renamed: on-chain-verified / on-chain-unverified | same caveats as above |
verification: "owner" | REST /resolve | a constant label on every result | not a trust grade |
verifications[] | REST /resolve, /v1/resolve, /v1/verify | "verified by x1id" badges: off-chain reviews signed by X1ID's attestor key, only while live for the current owner; REST forces verified: false (with holder_mismatch and a caveat) when a tokenized handle's NFT has moved | not a trustless proof, and not a reason to skip any pre-send check |
lease | REST /resolve only | { expires_at, lapsed } for a leased handle; absent for permanent handles | grace period isn't modelled; lapsed is the paid-through fact |
status | REST /quote | available, held (treasury-held), or taken | taken doesn't tell you who owns it — resolve for that |
Step 5 — the non-negotiable rule
Regardless of which outcome fires, one rule holds across the whole integration: never silently fill a recipient address into the send form. Always show the resolved namespace next to the address before the user can confirm — @jack and jack.x1 reading as the same "recipient" in your UI is exactly the ambiguity the SDK refuses to resolve on your behalf.
function recipientConfirmation(recipient: Recipient) {
return {
heading: `Sending to ${recipient.display}`, // always the name, never just the address
subheading: recipient.address,
warning: recipient.verified ? null : "This address hasn't been verified for this chain.",
sendEnabled: true,
};
}Step 6 — multi-chain sends
If your wallet sends on more than one chain, pass chain through and handle no-record-for-chain as its own outcome rather than falling back to an X1 address on the wrong chain:
async function resolveForChain(input: string, chain: 'X1' | 'SOL' | 'ETH' | 'BTC') {
try {
return { recipient: await x1id.resolve(input, { chain }) };
} catch (e) {
if (e instanceof ResolveError && e.code === 'no-record-for-chain') {
return { hint: `@${input.replace(/^@/, '')} has no address on ${chain} yet` };
}
throw e;
}
}For X1/SOL the SDK returns the handle's authority; for ETH/BTC it reads the handle's per-chain records, with the staleness rule enforced. A handle with no live record for the requested chain throws no-record-for-chain — never a wrong or coerced address.
Step 7 — show @name next to an address (reverse)
The other half of a naming system: anywhere your wallet prints an address — an activity feed, a contact list, the "from" line on an incoming transfer — you can print the @handle its owner declared as primary instead.
async function labelFor(address: string): Promise<string> {
const name = await x1id.reverse(address); // "nike" | "9pXw….x1" | null
if (name === null) return shorten(address); // no primary set — show the address
if (name.includes('.')) return shorten(address); // X1NS fallback carries no label; keep the address
return `@${name}`; // on-chain primary handle
}Two things to keep straight:
- A primary is set by the owner and validated at read time, not kept in sync by the program. If the handle is transferred, released, or its NFT sold,
reverse()simply starts returningnullfor the old address — it never returns a name that address no longer controls. - A reverse label is a display hint, not a recipient. If a user wants to send to
@nike, run it throughresolve()— don't reuse an address you previously reverse-labelled.
Full reference
The live reference for everything above is the X1ID app at app.x1id.io — its recipient handling, handle pages and "primary for …" badges are built on this SDK. For the method-by-method API this example builds on, see the SDK reference; for a copy-paste resolver singleton and a ~30-line React <AddressName>, the package README on npm.
