SDK Reference
TypeScript SDK for resolving @handles and X1NS names to addresses on X1.
SDK Reference
@x1id/resolve resolves @handles and X1NS names (.x1 / .xnt / .xen) to addresses on X1. It reads accounts directly off chain over RPC — it never calls a hosted resolution API, so it works against any X1 RPC and keeps working if x1id.io itself goes away.
@handle resolution is testnet only
@handle resolution works today on X1 testnet, where the handle registry program is deployed. Mainnet deployment is gated on an owner-custody decision and is not live yet — point rpcUrl at testnet, or expect not-found on mainnet.
X1NS domain resolution (.x1 / .xnt / .xen) is a separate, pre-existing system and is already live on X1 mainnet.
Installation
npm install @x1id/resolve@solana/web3.js is an optional peer dependency — you only need it if you pass web3.js types around; the SDK talks to RPC directly.
Loading the WASM module
Account derivation needs a real ed25519 on-curve check, which the SDK runs in a small Rust→WASM module rather than reimplementing in TypeScript — a hand-rolled JS version is the kind of thing that's subtly wrong for months, and this is code that loses money when it's wrong. The package ships the module at @x1id/resolve/wasm/x1_resolve_wasm.wasm.
Node
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { WasmResolver, createResolver } from "@x1id/resolve";
const require = createRequire(import.meta.url);
const wasmPath = require.resolve("@x1id/resolve/wasm/x1_resolve_wasm.wasm");
const wasm = await WasmResolver.fromBytes(readFileSync(wasmPath));
const x1id = createResolver({ rpcUrl: "https://rpc.testnet.x1.xyz", wasm });Browser / bundler
import wasmUrl from "@x1id/resolve/wasm/x1_resolve_wasm.wasm?url"; // Vite — adjust for your bundler
const wasm = await WasmResolver.fromBytes(
await (await fetch(wasmUrl)).arrayBuffer()
);
const x1id = createResolver({ rpcUrl: "https://rpc.testnet.x1.xyz", wasm });wasm is a required field on ResolverConfig — there is deliberately no JavaScript fallback for account derivation. A second implementation of on-curve arithmetic is how wrong addresses get derived silently.
createResolver
Create a resolver instance. Derivation and RPC reads happen lazily, on each call to resolve or reverse — nothing is fetched at construction time.
import { createResolver, WasmResolver } from "@x1id/resolve";
const wasm = await WasmResolver.fromBytes(/* module bytes, see above */);
const x1id = createResolver({
rpcUrl: "https://rpc.testnet.x1.xyz",
wasm,
cacheTtlMs: 30_000, // optional — this is the default
});Parameters (ResolverConfig):
| Field | Type | Required | Description |
|---|---|---|---|
rpcUrl | string | Yes | Any X1 RPC endpoint. |
wasm | WasmResolver | Yes | The loaded WASM module, from WasmResolver.fromBytes(). No JS fallback exists. |
fetchImpl | typeof fetch | No | Custom transport for tests or proxies. Defaults to the global fetch. |
cacheTtlMs | number | No | Resolution cache TTL in milliseconds. Default 30_000. Set 0 to disable caching. |
handleProgramId | string | No | @handle registry program ID. Defaults to the canonical X1 deployment; override to point at a different deployment (e.g. a local test validator). |
Returns: Resolver — an object exposing resolve, reverse, and clearCache.
resolve
Resolve a name to an address. resolve classifies its input by shape — a leading @ means a handle, a recognized TLD suffix (.x1/.xnt/.xen) means an X1NS domain — and never falls back from one namespace to the other. Input shaped like both throws ambiguous instead of silently picking one.
resolve(input: string, opts?: { chain?: "X1" | "SOL" | "ETH" | "BTC" }): Promise<Resolved>Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
input | string | Yes | The name to resolve, e.g. "@jack" or "jack.x1". |
opts.chain | "X1" | "SOL" | "ETH" | "BTC" | No | Which chain's address to return. Default "X1". |
Returns: Resolved
| Field | Type | Description |
|---|---|---|
input | string | Exactly what was passed in, for display. |
name | string | Canonical form of the name. |
namespace | "handle" | "x1" | "xnt" | "xen" | Which naming system answered. Always render this before letting a user send — see below. |
address | string | The resolved address: base58 for X1/SOL, 0x… for EVM, bech32 for BTC. |
chain | "X1" | "SOL" | "ETH" | "BTC" | Chain the address belongs to. |
verification | "verified" | "unverified" | Whether ownership of address was proved for this chain — see Verification. |
Resolving a handle
const res = await x1id.resolve("@jack");
// {
// input: "@jack",
// name: "jack",
// namespace: "handle",
// address: "H8FspgtSGtBnb4Yd6exXAF9pnGPPvSbtcVjmZ7WgY5B1",
// chain: "X1",
// verification: "verified"
// }A handle's verification is always "verified" when it resolves: the owner proved control by holding the on-chain registry account for that handle. That's a stronger claim than an address record — it's a proof of custody, not just a pointer.
Resolving an X1NS domain
const res = await x1id.resolve("jack.x1");
// {
// input: "jack.x1",
// name: "jack.x1",
// namespace: "x1",
// address: "9pXwzEKzYbwZKfvT7ivvT4L3XgLcnJyoEDdMy41rezDs",
// chain: "X1",
// verification: "unverified"
// }An X1NS domain's verification is "unverified" — the returned address is the domain's owner, which is a real on-chain fact, but it isn't a per-chain address proof the way a handle's registry-account ownership is. Show a warning in your UI when verification is "unverified".
The ambiguous case
This is the one behavior to get right
@jack and jack.x1 can be registered to different people. There is no fallback chain in this SDK from one namespace to the other — a fallback is exactly how those two identities get silently conflated, and the failure mode is a transfer that succeeds, to the wrong person. Input shaped like both throws instead.
try {
await x1id.resolve("@jack.x1");
} catch (e) {
console.log(e.code); // "ambiguous"
console.log(e.message); // '"@jack.x1" is both a handle and a domain shape'
}Never catch ambiguous and pick one namespace automatically. Ask the user which one they meant — @jack or jack.x1 are two different recipients.
reverse
Look up the primary name for an address.
reverse(address: string): Promise<string | null>Parameters:
| Field | Type | Description |
|---|---|---|
address | string | A base58 X1/SVM address. |
Returns: string | null — the address's primary name in label.tld form, or null if the address has no primary name set.
const name = await x1id.reverse("H8FspgtSGtBnb4Yd6exXAF9pnGPPvSbtcVjmZ7WgY5B1");
console.log(name); // "jack.x1" — or null if no primary name is setreverse currently resolves to an X1NS domain (label.tld), not an @handle — reverse resolution for handles is on the roadmap. A null result means "no primary name," not an error; don't treat it as a failure state in your UI.
clearCache
Clear the resolver's internal resolution cache.
x1id.clearCache();Results are cached per (namespace, name, chain) for cacheTtlMs (default 30 seconds). Call clearCache() after a registration, transfer, or primary-name change you know just happened on chain, so a stale result doesn't linger for the rest of the TTL window.
Multi-chain resolution
A name can carry addresses for more than one chain. Request the one you want with opts.chain:
await x1id.resolve("@jack", { chain: "X1" }); // default
await x1id.resolve("@jack", { chain: "SOL" }); // X1 shares Solana's ed25519 key format
await x1id.resolve("@jack", { chain: "ETH" }); // throws ResolveError { code: "no-record-for-chain" }Today the SDK reads the owner's X1/SVM address only — X1 and Solana share the same key format, so requesting either returns the same address. Per-chain records for ETH and BTC live in separate on-chain accounts the resolver doesn't read yet: requesting them throws no-record-for-chain rather than guessing or returning the SVM address on the wrong chain.
Verification
verification | Meaning |
|---|---|
verified | Control of the address was proved — the @handle owner holds the registry account. |
unverified | A record exists but ownership wasn't proved for that chain. Show a warning before letting a user send. |
Should I even try to resolve this?
Not every recipient-field input is a name — most of the time it's a pasted address. Use looksLikeName() to decide whether to attempt resolution at all, so a pasted base58 address never surfaces a spurious "invalid handle" error:
import { looksLikeName } from "@x1id/resolve";
function handleInputChange(value: string) {
if (looksLikeName(value)) {
// "@jack", "jack.x1", "jack.xnt", "jack.xen" — attempt resolution
resolveAndShowRecipient(value);
} else {
// Treat as a raw address instead — validate/checksum it directly
validateRawAddress(value);
}
}looksLikeName() is a pure, synchronous shape check — it does no RPC calls and never throws. It returns true for anything starting with @, or ending in a recognized TLD (.x1, .xnt, .xen), regardless of whether that name is actually registered.
Errors
resolve and reverse throw ResolveError, never a bare string or an untyped exception:
import { ResolveError } from "@x1id/resolve";
try {
const res = await x1id.resolve(userInput);
} catch (e) {
if (e instanceof ResolveError) {
console.log(e.code); // e.g. "not-found", "ambiguous", "invalid-handle"
console.log(e.message);
console.log(e.input); // the original input that caused the error
}
}See the Errors reference for the full list of ResolveErrorCode values and what triggers each one.