FortiBlox LogoFortiBlox Docs
x1id

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):

FieldTypeRequiredDescription
rpcUrlstringYesAny X1 RPC endpoint.
wasmWasmResolverYesThe loaded WASM module, from WasmResolver.fromBytes(). No JS fallback exists.
fetchImpltypeof fetchNoCustom transport for tests or proxies. Defaults to the global fetch.
cacheTtlMsnumberNoResolution cache TTL in milliseconds. Default 30_000. Set 0 to disable caching.
handleProgramIdstringNo@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:

FieldTypeRequiredDescription
inputstringYesThe name to resolve, e.g. "@jack" or "jack.x1".
opts.chain"X1" | "SOL" | "ETH" | "BTC"NoWhich chain's address to return. Default "X1".

Returns: Resolved

FieldTypeDescription
inputstringExactly what was passed in, for display.
namestringCanonical form of the name.
namespace"handle" | "x1" | "xnt" | "xen"Which naming system answered. Always render this before letting a user send — see below.
addressstringThe 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:

FieldTypeDescription
addressstringA 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 set

reverse 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

verificationMeaning
verifiedControl of the address was proved — the @handle owner holds the registry account.
unverifiedA 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.