FortiBlox LogoFortiBlox Docs
x1id

Examples

Add @handle and X1NS support to a wallet's send flow, end to end.

Add @handle support to a wallet's send flow

This walks through wiring @x1id/resolve into a wallet's recipient field, using the same shape as the SDK's own examples/recipient-field.ts. It covers the part that's easy to get wrong: what to show the user for each of the four things resolve() can do, so a mistyped or ambiguous name never turns into a transfer to the wrong recipient.

@handle resolution is testnet only right now

The examples below point rpcUrl at X1 testnet. @handle resolution isn't live on mainnet yet (X1NS .x1/.xnt/.xen domain resolution is — it's a separate, older system). Swap the RPC endpoint when handle resolution ships on mainnet, but don't ship a mainnet build that implies @handles work there today.

The setup

Most wallets already have a "send" screen with a recipient input. The only new piece is: 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 { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { createResolver, WasmResolver, ResolveError, looksLikeName } 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 });

(For a browser build, swap the WASM-loading block for the fetch-based one in the SDK reference.)

Step 1 — decide whether to attempt resolution 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 address validation/checksum path
    setRecipient(validateRawAddress(value));
  }
}

Step 2 — resolve and shape the result for the UI

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 string-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 four outcomes

Running resolveRecipient against a few inputs makes the branches concrete:

for (const input of ["@jack", "jack.x1", "@jack.x1", "not a name"]) {
  console.log(input, "→", await resolveRecipient(input));
}
// "@jack"      → { recipient: { address: "H8Fs…", display: "@jack",   verified: true  } }
// "jack.x1"    → { recipient: { address: "9pXw…", display: "jack.x1", verified: false } }
// "@jack.x1"   → { hint: "Type either @jack or jack.x1 — not both" }
// "not a name" → never reaches resolveRecipient — looksLikeName() returns false first

1. Resolved, verified — the happy path

A handle resolved successfully and the owner proved control of the address. Show the resolved recipient and let the user proceed normally.

UI copy: Sending to @jack — H8Fs…Y5B1

2. Resolved, unverified — resolved but with a caveat

An X1NS domain resolved to its owner's address, but that's ownership-of-the-name, not a proved per-chain address record. Show the recipient, but surface the distinction — don't hide it behind the same confident UI as a verified result.

UI copy: Sending to jack.x1 — 9pXw…zDs2. This address is the name's owner and hasn't been separately verified for this chain.

3. Not found / invalid — nothing to show yet

not-found (well-formed but unregistered), invalid-handle, and invalid-domain all mean there's no recipient to render. Show an inline hint and leave the send button disabled — don't clear what the user typed.

UI copy: No name found / Handles allow letters, digits and hyphens only

4. 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 trying the handle first, or the domain first. Ask the user to disambiguate.

UI copy: That could mean either @jack or jack.x1 — enter just one.

Step 4 — the non-negotiable UX 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.

A minimal recipient confirmation component follows this shape:

function RecipientConfirmation({ recipient }: { recipient: Recipient }) {
  return {
    // Always the name, never just the address
    heading: `Sending to ${recipient.display}`,
    subheading: recipient.address,
    warning: recipient.verified
      ? null
      : "This address hasn't been separately verified for this chain.",
    sendEnabled: true,
  };
}

Step 5 — multi-chain sends

If your wallet supports sending on more than one chain, pass chain through to resolve() 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;
  }
}

ETH and BTC records are on the SDK's roadmap — today, requesting them for a name that only has an X1/SVM address returns no-record-for-chain, never a wrong or coerced address.

Full example

The SDK repository ships a complete, runnable version of this walkthrough at examples/recipient-field.ts in github.com/fortiblox/x1id-sdk — clone it and run it against testnet to see all four outcomes end to end.

For the full method-by-method API this example builds on, see the SDK reference.