@x1id/resolve 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ answered, and a mixed shape is refused rather than guessed.
12
12
  import { createResolver, WasmResolver } from "@x1id/resolve";
13
13
 
14
14
  const wasm = await WasmResolver.fromBytes(/* the module bytes, see below */);
15
- const x1id = createResolver({ rpcUrl: "https://rpc.mainnet.x1.xyz", wasm });
15
+ const x1id = createResolver({ rpcUrl: "https://rpc.testnet.x1.xyz", wasm });
16
16
 
17
17
  const r = await x1id.resolve("@jack");
18
18
  // { name: "jack", namespace: "handle", address: "H8Fs…", chain: "X1",
@@ -20,28 +20,28 @@ const r = await x1id.resolve("@jack");
20
20
 
21
21
  await x1id.resolve("jack.x1"); // resolves the X1NS domain instead
22
22
  await x1id.resolve("@jack.x1"); // throws ResolveError { code: "ambiguous" }
23
+ await x1id.reverse("H8Fs…"); // "nike" — the address's primary @handle, or null
23
24
  ```
24
25
 
25
- ## Why a resolver SDK at all
26
+ > **Which RPC?** The `@handle` registry is deployed on **X1 testnet** today
27
+ > (`https://rpc.testnet.x1.xyz`). X1NS domains live on **mainnet**
28
+ > (`https://rpc.mainnet.x1.xyz`). Point the resolver at the chain that holds
29
+ > the names you want; on an RPC where the registry is not deployed a handle is
30
+ > an honest `not-found`, never a fabricated address.
26
31
 
27
- X1NS shipped a great naming service — but with resolution behind their own API.
28
- A wallet that resolves through a third party's uptime **breaks the day that
29
- party loses interest**, and every pending payment breaks with it. This SDK never
30
- calls `api.x1ns.xyz` or any of ours: it **derives accounts and reads them
31
- straight off chain over RPC**. Point it at any X1 RPC and it works, ours up or
32
- not.
32
+ ## Where this code lives
33
33
 
34
- Address derivation uses a small Rust→WASM module (a real ed25519 on-curve check
35
- — the kind of thing that is subtly wrong for months when hand-rolled in JS, and
36
- this is the kind of code that loses money when it is wrong). It is verified
37
- against live mainnet accounts.
34
+ This directory (`sdk/` in the `x1-handles` monorepo) **is the source of
35
+ truth**, published to npm as **`@x1id/resolve`** (MIT). The X1ID app vendors a
36
+ byte-identical copy at `app/src/lib/x1/x1sdk/` (plus the WASM at
37
+ `app/public/x1_resolve_wasm.wasm`) — that copy is a **duplicate, not a
38
+ source**: any change here must be mirrored into `app/src/lib/x1/x1sdk`
39
+ (`test/sync.test.js` fails inside the monorepo when they drift).
38
40
 
39
41
  ## Install
40
42
 
41
43
  ```bash
42
44
  npm install @x1id/resolve
43
- # or straight from source:
44
- npm install github:fortiblox/x1id-sdk
45
45
  ```
46
46
 
47
47
  `@solana/web3.js` is an **optional** peer dependency — you only need it if you
@@ -50,9 +50,10 @@ pass web3.js types around; the SDK itself talks to RPC directly.
50
50
  ## Loading the WASM module
51
51
 
52
52
  Derivation needs the WASM module. The package ships it at
53
- `@x1id/resolve/wasm/x1_resolve_wasm.wasm`.
53
+ `@x1id/resolve/wasm/x1_resolve_wasm.wasm` (an exported subpath, so both
54
+ `require.resolve` and bundler asset imports find it).
54
55
 
55
- **Node**
56
+ **Node (ESM)**
56
57
 
57
58
  ```ts
58
59
  import { readFileSync } from "node:fs";
@@ -63,16 +64,30 @@ const require = createRequire(import.meta.url);
63
64
  const wasmPath = require.resolve("@x1id/resolve/wasm/x1_resolve_wasm.wasm");
64
65
  const wasm = await WasmResolver.fromBytes(readFileSync(wasmPath));
65
66
 
66
- const x1id = createResolver({ rpcUrl: "https://rpc.mainnet.x1.xyz", wasm });
67
+ const x1id = createResolver({ rpcUrl: "https://rpc.testnet.x1.xyz", wasm });
68
+ ```
69
+
70
+ Node ≥ 20.6 can use `import.meta.resolve` instead of `createRequire`:
71
+
72
+ ```ts
73
+ import { fileURLToPath } from "node:url";
74
+ const wasmPath = fileURLToPath(import.meta.resolve("@x1id/resolve/wasm/x1_resolve_wasm.wasm"));
67
75
  ```
68
76
 
69
77
  **Browser / bundler**
70
78
 
71
79
  ```ts
72
- import wasmUrl from "@x1id/resolve/wasm/x1_resolve_wasm.wasm?url"; // Vite
80
+ // Vite / Rollup / webpack 5 — the `?url` suffix hands you the asset URL
81
+ import wasmUrl from "@x1id/resolve/wasm/x1_resolve_wasm.wasm?url";
82
+ import { WasmResolver } from "@x1id/resolve";
83
+
73
84
  const wasm = await WasmResolver.fromBytes(await (await fetch(wasmUrl)).arrayBuffer());
74
85
  ```
75
86
 
87
+ Or copy `node_modules/@x1id/resolve/wasm/x1_resolve_wasm.wasm` into your static
88
+ directory and `fetch("/x1_resolve_wasm.wasm")` it — that is what
89
+ [x1id.io](https://x1id.io) does.
90
+
76
91
  ## The one rule: show the namespace before you send
77
92
 
78
93
  `@jack` and `jack.x1` can resolve to **different owners**. There is no API in
@@ -96,15 +111,53 @@ try {
96
111
  }
97
112
  ```
98
113
 
99
- See [`examples/recipient-field.ts`](examples/recipient-field.ts) for a fuller
100
- wallet integration.
114
+ Dispatch is on shape (`@` prefix vs known TLD suffix), never "try one, fall back
115
+ to the other". `@jack.x1` throws `ambiguous` rather than guessing. Use
116
+ `looksLikeName(input)` to decide whether to attempt resolution at all, so
117
+ pasting base58 does not surface a validation error.
118
+
119
+ ## Reads chain state, not an API
120
+
121
+ X1NS names resolve by deriving accounts and reading them over RPC. This library
122
+ never calls `api.x1ns.xyz` or any API of ours — a wallet that resolves through
123
+ a third party's uptime breaks when they lose interest. `@handle` resolution
124
+ derives the PDA (`["handle", canonical]` under `handleProgramId`) and reads the
125
+ account directly: an untokenized handle resolves to its `owner`; a tokenized
126
+ one (NFT extension present) resolves to whoever currently holds the handle's
127
+ NFT — the `owner` field is informational once a name is tokenized, and the SDK
128
+ never falls back to it. Reverse lookup reads the registry's on-chain `Primary`
129
+ pointer (`["primary", owner]`). All reads use `confirmed` commitment.
130
+
131
+ Address derivation uses a small Rust→WASM module (a real ed25519 on-curve check
132
+ — the kind of thing that is subtly wrong for months when hand-rolled in JS, and
133
+ this is the kind of code that loses money when it is wrong). It is verified
134
+ against live accounts in `test/wasm.test.js`.
101
135
 
102
136
  ## Reverse lookup
103
137
 
104
138
  ```ts
105
- const name = await x1id.reverse("H8Fs…"); // primary name for an address, or null
139
+ const name = await x1id.reverse("H8Fs…");
140
+ // "nike" → the address's primary @handle (canonical, no "@")
141
+ // "5WL7…dMKK.x1" → no @handle primary; X1NS primary-domain record
142
+ // (account + TLD — X1NS stores no label on chain)
143
+ // null → no primary anywhere
106
144
  ```
107
145
 
146
+ A `@handle` primary is only returned when it is still trustworthy — the same
147
+ rule the X1ID API and app apply:
148
+
149
+ 1. the `Primary` pointer and the handle it names both exist and are owned by
150
+ the registry program;
151
+ 2. `set_at >= handle.registered_at` — a pointer set before the name's current
152
+ registration belongs to a previous owner of that name;
153
+ 3. the address still holds authority: an untokenized handle's `owner` is the
154
+ address, or — when the handle is tokenized — the address's associated token
155
+ account holds exactly 1 of the handle's NFT.
156
+
157
+ A pointer that fails any rule is treated as absent (falls through to X1NS, then
158
+ `null`), never as an error. Only a malformed *address* throws
159
+ (`ResolveError { code: "unrecognized" }`).
160
+
108
161
  ## Multi-chain
109
162
 
110
163
  A handle can carry addresses for several chains. Pass the one you want:
@@ -124,22 +177,52 @@ a wrong address on the wrong chain.
124
177
 
125
178
  | `verification` | meaning |
126
179
  |---|---|
127
- | `verified` | control of the address was proved (the `@handle` owner holds the registry account). |
128
- | `unverified` | a record exists but ownership was not proved for that chain. **Show a warning.** |
180
+ | `verified` | control of the address was proved: the address owns the `@handle` registry account, or — for a tokenized handle — holds its NFT **in its associated token account** (the only place the registry program recognises as the name's authority, same rule as `reverse()`). |
181
+ | `unverified` | ownership was not proved for that chain (X1NS), or — for a tokenized handle — the NFT is held outside the holder's ATA (an auxiliary account or an escrow): that wallet controls the token but cannot act for the name until it is back in its ATA. **Show a warning.** |
182
+
183
+ A tokenized handle whose NFT has been **burned** (mint supply 0) resolves to no
184
+ one: `ResolveError { code: "not-found", reason: "nft-burned" }`. The registry
185
+ account's stale `owner` field is never returned for a tokenized handle, on any
186
+ path.
129
187
 
130
188
  ## What works today
131
189
 
132
190
  | | Status |
133
191
  |---|---|
134
192
  | X1NS resolution (`.x1/.xnt/.xen`) | ✅ mainnet |
135
- | `@handle` resolution | ✅ on any RPC where the registry is deployed (testnet today; mainnet on deploy) |
136
- | Reverse lookup | ✅ |
193
+ | `@handle` resolution | ✅ on any RPC where the registry is deployed — **X1 testnet today**; mainnet on deploy |
194
+ | Reverse lookup | ✅ on-chain `@handle` primary (testnet today) with X1NS primary-domain fallback (mainnet); X1NS results are `<domainAccount>.<tld>`, not a label |
137
195
  | Per-chain ETH/BTC records | 🔜 roadmap |
138
- | Registration / transfer (write) | 🔜 roadmap |
196
+ | Registration / transfer / `set_primary` (write) | 🔜 roadmap — use [x1id.io](https://x1id.io) |
139
197
 
140
198
  The `@handle` registry program id is configurable (`handleProgramId`) and
141
- defaults to the canonical X1 deployment. On an RPC where it is not deployed you
142
- get an honest `not-found`, never a fabricated address.
199
+ defaults to the canonical X1 deployment
200
+ (`8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P`).
201
+
202
+ ## Errors
203
+
204
+ `ResolveError` carries a `code` a UI can branch on:
205
+
206
+ | code | meaning |
207
+ |---|---|
208
+ | `unrecognized` | not a handle or known domain — probably a raw address |
209
+ | `ambiguous` | both shapes at once, e.g. `@jack.x1` |
210
+ | `invalid-handle` / `invalid-domain` | right shape, invalid content |
211
+ | `not-found` | valid name, not registered — or `reason: "nft-burned"`: tokenized and its NFT was burned, so no one holds it |
212
+ | `no-record-for-chain` | registered, but no address for the requested chain |
213
+ | `rpc-error` | transport failure |
214
+
215
+ ## Normalization
216
+
217
+ `normalizeHandle` mirrors the Rust `handle-normalize` crate, which is the source
218
+ of truth — the on-chain registry derives PDA seeds from it. A conformance suite
219
+ runs both against generated fixtures in CI; divergence fails the build, because
220
+ a divergence would make handles registered under one normalization unreachable
221
+ under the other.
222
+
223
+ ASCII only, no Unicode or emoji. X1NS permits both; for a payment identifier
224
+ that is a homograph vector (`@аlice` with a Cyrillic `а` renders identically to
225
+ `@alice`).
143
226
 
144
227
  ## API
145
228
 
@@ -147,10 +230,10 @@ get an honest `not-found`, never a fabricated address.
147
230
  createResolver(config: ResolverConfig): Resolver
148
231
 
149
232
  interface ResolverConfig {
150
- rpcUrl: string; // any X1 RPC
233
+ rpcUrl: string; // any X1 RPC (testnet for @handles today)
151
234
  wasm: WasmResolver; // required — derivation runs in WASM, no JS fallback
152
235
  fetchImpl?: typeof fetch; // custom transport (tests, proxies)
153
- cacheTtlMs?: number; // default 30_000; 0 disables
236
+ cacheTtlMs?: number; // default 30_000; 0 disables (resolve() only)
154
237
  handleProgramId?: string; // default: canonical X1 deployment
155
238
  }
156
239
 
@@ -159,15 +242,55 @@ interface Resolver {
159
242
  reverse(address: string): Promise<string | null>;
160
243
  clearCache(): void;
161
244
  }
245
+
246
+ class WasmResolver {
247
+ static fromBytes(bytes: BufferSource): Promise<WasmResolver>;
248
+ normalizeHandle(raw: string): string | null;
249
+ deriveX1nsAccount(label: string, tld: "x1" | "xnt" | "xen"): Uint8Array | null;
250
+ derivePrimaryAccount(owner: Uint8Array): Uint8Array | null; // X1NS primary-domain record
251
+ deriveHandleAccount(canonical: string, programId: Uint8Array): Uint8Array | null; // ["handle", name]
252
+ deriveHandlePrimaryAccount(owner: Uint8Array, programId: Uint8Array): Uint8Array | null; // ["primary", owner]
253
+ deriveAssociatedTokenAccount(owner: Uint8Array, mint: Uint8Array): Uint8Array | null;
254
+ }
255
+ ```
256
+
257
+ Also exported: `parseName`, `looksLikeName`, `normalizeHandle`, `namespaceLabel`,
258
+ `encodeBase58`, `decodeBase58_32`, `ResolveError`, `CHAIN_COIN_TYPE` and the
259
+ types `Resolved`, `Namespace`, `Chain`, `Verification`, `ResolveErrorCode`.
260
+
261
+ ## Developing
262
+
263
+ ```bash
264
+ npm run build:wasm # cargo build → wasm/x1_resolve_wasm.wasm (needs the wasm32-unknown-unknown target)
265
+ npm run build # tsc → dist/
266
+ npm test # node --test; X1_LIVE=1 also runs the testnet reverse() case
162
267
  ```
163
268
 
164
- `ResolveError.code` is one of: `unrecognized`, `ambiguous`, `invalid-handle`,
165
- `invalid-domain`, `not-found`, `no-record-for-chain`, `rpc-error`.
269
+ `wasm/x1_resolve_wasm.wasm` is committed and shipped. Rebuild it whenever
270
+ `crates/x1-resolve-wasm` (or its dependencies) change. A rebuild is **not**
271
+ guaranteed byte-identical: the module published in 0.2.0 (48,765 bytes) differs
272
+ from the monorepo/app copy (48,742 bytes) by 23 bytes — same 13 exports,
273
+ functionally equivalent on every derivation tested (handle PDA, X1NS account,
274
+ primary PDA, ATA, normalize; `test/wasm.test.js` checks them against live
275
+ accounts). Treat the committed binary as the artefact of record and verify by
276
+ running the tests, not by comparing hashes.
277
+
278
+ ## Publishing
279
+
280
+ Published from the (private) release mirror, not from the monorepo: sync this
281
+ directory (plus `crates/`) there, then push a `v<version>` tag that matches
282
+ `package.json` — the release workflow builds the WASM, runs the tests and
283
+ `publint`, and runs `npm publish`. There is **no provenance attestation** on
284
+ the published package: npm only accepts provenance from public source
285
+ repositories, and the mirror is private. (The README bundled inside 0.2.0
286
+ mentions `--provenance`; that was aspirational — no attestation was ever
287
+ attached, and this file is the accurate statement.)
166
288
 
167
289
  ## Links
168
290
 
169
291
  - Home & docs: **[x1id.io](https://x1id.io)**
170
- - Issues: <https://github.com/fortiblox/x1id-sdk/issues>
292
+ - Developer docs: **[docs.fortiblox.com/docs/x1id](https://docs.fortiblox.com/docs/x1id)**
293
+ - Questions & issues: [x1id.io](https://x1id.io) — contact links in the footer
171
294
 
172
295
  ## License
173
296
 
package/dist/index.d.ts CHANGED
@@ -2,9 +2,12 @@
2
2
  * Resolve `@handles` and X1NS names on X1.
3
3
  *
4
4
  * ```ts
5
- * import { createResolver } from "@x1id/resolve";
5
+ * import { createResolver, WasmResolver } from "@x1id/resolve";
6
6
  *
7
- * const r = createResolver({ rpcUrl: "https://rpc.mainnet.x1.xyz" });
7
+ * // bytes of @x1id/resolve/wasm/x1_resolve_wasm.wasm — see the README for loading
8
+ * const wasm = await WasmResolver.fromBytes(wasmBytes);
9
+ * // The @handle registry is on X1 testnet today; X1NS domains are on mainnet.
10
+ * const r = createResolver({ rpcUrl: "https://rpc.testnet.x1.xyz", wasm });
8
11
  * const res = await r.resolve("@jack", { chain: "X1" });
9
12
  * // { name: "jack", namespace: "handle", address: "...", verification: "verified" }
10
13
  * ```
package/dist/index.js CHANGED
@@ -2,9 +2,12 @@
2
2
  * Resolve `@handles` and X1NS names on X1.
3
3
  *
4
4
  * ```ts
5
- * import { createResolver } from "@x1id/resolve";
5
+ * import { createResolver, WasmResolver } from "@x1id/resolve";
6
6
  *
7
- * const r = createResolver({ rpcUrl: "https://rpc.mainnet.x1.xyz" });
7
+ * // bytes of @x1id/resolve/wasm/x1_resolve_wasm.wasm — see the README for loading
8
+ * const wasm = await WasmResolver.fromBytes(wasmBytes);
9
+ * // The @handle registry is on X1 testnet today; X1NS domains are on mainnet.
10
+ * const r = createResolver({ rpcUrl: "https://rpc.testnet.x1.xyz", wasm });
8
11
  * const res = await r.resolve("@jack", { chain: "X1" });
9
12
  * // { name: "jack", namespace: "handle", address: "...", verification: "verified" }
10
13
  * ```
@@ -43,9 +46,80 @@ const SPL_NAME_HEADER_LEN = 96;
43
46
  const DEFAULT_HANDLE_PROGRAM = "8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P";
44
47
  // Handle account layout, mirrored from the on-chain program:
45
48
  // discriminator(8) name(32) name_len(1) owner(32) ...
46
- // The owner is the address the handle resolves to on X1.
49
+ // `owner` is the address an UNTOKENIZED handle resolves to. Once the handle
50
+ // is tokenized (NFT extension present, see `parseHandleAccount`) the program
51
+ // treats `owner` as informational only — authority is whoever holds the NFT —
52
+ // and so must every reader.
47
53
  const HANDLE_OWNER_OFFSET = 41;
48
- const HANDLE_MIN_LEN = HANDLE_OWNER_OFFSET + 32;
54
+ // `Handle`'s fixed base allocation (`space = 8 + INIT_SPACE`). The NFT
55
+ // extension, when present, is appended at this boundary regardless of the
56
+ // compact Borsh length of the (variable, `Option`-bearing) struct content —
57
+ // mirrors `Handle::NFT_EXT_OFFSET` in the program and `HANDLE_BASE_LEN` in
58
+ // tools/api.
59
+ const HANDLE_BASE_LEN = 8 + 149;
60
+ // `Primary` pointer account (`["primary", owner]` under the registry):
61
+ // disc(8) | owner(32) | handle(32) | set_at(i64 LE, 8) | bump(1) = 81 bytes
62
+ const PRIMARY_LEN = 81;
63
+ const PRIMARY_HANDLE_OFFSET = 40;
64
+ const PRIMARY_SET_AT_OFFSET = 72;
65
+ // SPL Token account: mint(32) | owner(32) | amount(u64 LE, 8) | ...
66
+ const TOKEN_ACCOUNT_MIN_LEN = 72;
67
+ // SPL Token mint: mint_authority COption(4+32) | supply(u64 LE, 8) @36 | ...
68
+ const MINT_SUPPLY_OFFSET = 36;
69
+ const MINT_MIN_LEN = MINT_SUPPLY_OFFSET + 8;
70
+ // The registry mints handle NFTs with the legacy SPL Token program, so every
71
+ // token account for a handle mint is owned by it.
72
+ const SPL_TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
73
+ function readI64(data, offset) {
74
+ return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigInt64(offset, true);
75
+ }
76
+ function readU64(data, offset) {
77
+ return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigUint64(offset, true);
78
+ }
79
+ function bytesEqual(a, b) {
80
+ if (a.length !== b.length)
81
+ return false;
82
+ for (let i = 0; i < a.length; i++)
83
+ if (a[i] !== b[i])
84
+ return false;
85
+ return true;
86
+ }
87
+ /**
88
+ * Parse the `Handle` fields the reverse rule needs. Port of `parse_handle` in
89
+ * tools/api — sequential, because `recovery` / `recovery_target` are
90
+ * `Option<Pubkey>` (Borsh: tag byte, then 32 bytes when `Some`) and shift
91
+ * `registered_at`. The NFT mint is read at the fixed base boundary, not the
92
+ * sequential position.
93
+ *
94
+ * disc(8) name(32) name_len(1) owner(32) handle_type(1)
95
+ * recovery: Option<Pubkey> recovery_initiated_at: i64
96
+ * recovery_target: Option<Pubkey> registered_at: i64 bump(1)
97
+ * [at 8+149: nft tag(1) mint(32)]
98
+ */
99
+ function parseHandleAccount(data) {
100
+ if (data.length < HANDLE_BASE_LEN)
101
+ return null;
102
+ const nameLen = Math.min(data[40], 32);
103
+ const name = new TextDecoder().decode(data.slice(8, 8 + nameLen));
104
+ const owner = data.slice(HANDLE_OWNER_OFFSET, HANDLE_OWNER_OFFSET + 32);
105
+ let pos = 73 + 1; // owner end + handle_type(1)
106
+ // recovery: Option<Pubkey>
107
+ if (pos >= data.length)
108
+ return null;
109
+ pos += 1 + (data[pos] === 1 ? 32 : 0);
110
+ pos += 8; // recovery_initiated_at
111
+ // recovery_target: Option<Pubkey>
112
+ if (pos >= data.length)
113
+ return null;
114
+ pos += 1 + (data[pos] === 1 ? 32 : 0);
115
+ if (pos + 8 > data.length)
116
+ return null;
117
+ const registeredAt = readI64(data, pos);
118
+ const nftMint = data.length >= HANDLE_BASE_LEN + 33 && data[HANDLE_BASE_LEN] === 1
119
+ ? data.slice(HANDLE_BASE_LEN + 1, HANDLE_BASE_LEN + 33)
120
+ : null;
121
+ return { name, owner, registeredAt, nftMint };
122
+ }
49
123
  export function createResolver(config) {
50
124
  const ttl = config.cacheTtlMs ?? 30_000;
51
125
  const cache = new Map();
@@ -60,6 +134,9 @@ export function createResolver(config) {
60
134
  // Explicit type so the non-null narrowing survives into the resolveHandle
61
135
  // closure below — TS widens a captured `const` back to its declared type.
62
136
  const handleProgram = decodedProgram;
137
+ // Re-encoded (not the caller's string) so a non-canonical base58 spelling of
138
+ // the same key still compares equal to the RPC's `owner` field.
139
+ const programBase58 = encodeBase58(handleProgram);
63
140
  async function rpc(method, params) {
64
141
  let res;
65
142
  try {
@@ -81,11 +158,12 @@ export function createResolver(config) {
81
158
  }
82
159
  return body.result;
83
160
  }
84
- /** Fetch raw account data, or null when the account does not exist. */
85
- async function accountData(address) {
161
+ /** Fetch raw account data plus the owning program, or null when the account
162
+ * does not exist. */
163
+ async function accountInfo(address) {
86
164
  const result = (await rpc("getAccountInfo", [
87
165
  address,
88
- { encoding: "base64" },
166
+ { encoding: "base64", commitment: "confirmed" },
89
167
  ]));
90
168
  const value = result?.value;
91
169
  if (!value)
@@ -95,7 +173,11 @@ export function createResolver(config) {
95
173
  const out = new Uint8Array(bin.length);
96
174
  for (let i = 0; i < bin.length; i++)
97
175
  out[i] = bin.charCodeAt(i);
98
- return out;
176
+ return { data: out, owner: value.owner };
177
+ }
178
+ /** Fetch raw account data, or null when the account does not exist. */
179
+ async function accountData(address) {
180
+ return (await accountInfo(address))?.data ?? null;
99
181
  }
100
182
  async function resolveX1ns(canonical, label, tld, chain, input) {
101
183
  const account = config.wasm.deriveX1nsAccount(label, tld);
@@ -133,35 +215,85 @@ export function createResolver(config) {
133
215
  verification: "unverified",
134
216
  };
135
217
  }
218
+ /**
219
+ * Current holder of a tokenized handle: the owner of the single token
220
+ * account holding the handle's NFT (supply 1, decimals 0). Never falls back
221
+ * to `Handle.owner` — after the NFT changes hands that field names the
222
+ * previous owner, and paying it is the exact failure this SDK exists to
223
+ * prevent.
224
+ *
225
+ * Parity with the program's `require_current_authority` (and this
226
+ * resolver's `reverse()`): the program recognises the holder as the name's
227
+ * authority only when the NFT sits in the holder's **associated token
228
+ * account** for the mint. A holder whose NFT is parked elsewhere (an
229
+ * auxiliary account, a program escrow) still controls the token, so the
230
+ * address is returned — but as `unverified`, because the registry will not
231
+ * let that address act for the name until the NFT is back in its ATA.
232
+ *
233
+ * A burned NFT (mint supply 0) is `not-found` with reason `nft-burned`: the
234
+ * name has no holder, and no one — least of all the stale `owner` — may be
235
+ * paid for it. Any other failure to find the holder is an `rpc-error`.
236
+ */
237
+ async function nftHolder(canonical, mint, input) {
238
+ const mintKey = encodeBase58(mint);
239
+ const largest = (await rpc("getTokenLargestAccounts", [
240
+ mintKey,
241
+ { commitment: "confirmed" },
242
+ ]));
243
+ const holders = (largest?.value ?? []).filter((a) => a.amount === "1");
244
+ if (holders.length !== 1) {
245
+ const m = await accountInfo(mintKey);
246
+ if (m &&
247
+ m.owner === SPL_TOKEN_PROGRAM &&
248
+ m.data.length >= MINT_MIN_LEN &&
249
+ readU64(m.data, MINT_SUPPLY_OFFSET) === 0n) {
250
+ throw new ResolveError("not-found", `@${canonical}'s NFT has been burned — the name has no holder`, input, "nft-burned");
251
+ }
252
+ throw new ResolveError("rpc-error", `@${canonical} is tokenized but its NFT has no single holder`, input);
253
+ }
254
+ const holdingAccount = holders[0].address;
255
+ const t = await accountInfo(holdingAccount);
256
+ if (!t ||
257
+ t.owner !== SPL_TOKEN_PROGRAM ||
258
+ t.data.length < TOKEN_ACCOUNT_MIN_LEN ||
259
+ !bytesEqual(t.data.slice(0, 32), mint) ||
260
+ readU64(t.data, 64) !== 1n) {
261
+ throw new ResolveError("rpc-error", `@${canonical} is tokenized but its NFT holder account is malformed`, input);
262
+ }
263
+ const holder = t.data.slice(32, 64);
264
+ const ata = config.wasm.deriveAssociatedTokenAccount(holder, mint);
265
+ const inAta = ata !== null && encodeBase58(ata) === holdingAccount;
266
+ return { address: encodeBase58(holder), verification: inAta ? "verified" : "unverified" };
267
+ }
136
268
  async function resolveHandle(canonical, chain, input) {
137
269
  const account = config.wasm.deriveHandleAccount(canonical, handleProgram);
138
270
  if (!account) {
139
271
  throw new ResolveError("invalid-handle", `"${input}" is not a valid handle`, input);
140
272
  }
141
- const data = await accountData(encodeBase58(account));
142
- if (!data) {
273
+ const h = await accountInfo(encodeBase58(account));
274
+ // An account at the PDA that the registry does not own is not a handle
275
+ // (anyone can fund an address into existence) — the name is unregistered.
276
+ if (!h || h.owner !== programBase58) {
143
277
  throw new ResolveError("not-found", `@${canonical} is not registered`, input);
144
278
  }
145
- if (data.length < HANDLE_MIN_LEN) {
279
+ const handle = parseHandleAccount(h.data);
280
+ if (!handle) {
146
281
  throw new ResolveError("rpc-error", `@${canonical} returned a malformed account`, input);
147
282
  }
148
- const owner = encodeBase58(data.slice(HANDLE_OWNER_OFFSET, HANDLE_OWNER_OFFSET + 32));
149
- // The owner is an X1/SVM address. Per-chain records (ETH/BTC) live in
283
+ // The address is an X1/SVM address. Per-chain records (ETH/BTC) live in
150
284
  // separate record accounts the resolver does not read yet, so a request for
151
285
  // another chain is an explicit "no record" rather than a wrong address.
152
286
  if (chain !== "X1" && chain !== "SOL") {
153
287
  throw new ResolveError("no-record-for-chain", `@${canonical} has no ${chain} record`, input);
154
288
  }
155
- return {
156
- input,
157
- name: canonical,
158
- namespace: "handle",
159
- address: owner,
160
- chain,
161
- // The owner holds the registry account for this handle — a proved control
162
- // relationship, not an unverified record.
163
- verification: "verified",
164
- };
289
+ // Same authority rule as the program's `require_current_authority` and
290
+ // this resolver's `reverse()`: untokenized → `Handle.owner` (verified: it
291
+ // holds the registry account); tokenized → whoever holds the NFT right
292
+ // now, verified only when it sits in that wallet's ATA (see `nftHolder`).
293
+ const { address, verification } = handle.nftMint === null
294
+ ? { address: encodeBase58(handle.owner), verification: "verified" }
295
+ : await nftHolder(canonical, handle.nftMint, input);
296
+ return { input, name: canonical, namespace: "handle", address, chain, verification };
165
297
  }
166
298
  async function resolve(input, opts) {
167
299
  const chain = opts?.chain ?? "X1";
@@ -183,12 +315,77 @@ export function createResolver(config) {
183
315
  cache.set(key, { value, expires: Date.now() + ttl });
184
316
  return value;
185
317
  }
186
- /** Address to its primary name, or null when none is set. */
318
+ /**
319
+ * Reverse resolution through the `@handle` registry's on-chain `Primary`
320
+ * pointer. Returns the canonical handle name (no `@`), or null when the
321
+ * address has no pointer or the pointer is no longer trustworthy.
322
+ *
323
+ * The read-side rule — identical to `tools/api` `fn reverse`, which is the
324
+ * reference — trusts a pointer only if all three hold:
325
+ * 1. the handle account it names exists and is owned by the registry;
326
+ * 2. `set_at >= handle.registered_at` — a pointer set before the handle's
327
+ * current registration belongs to a previous owner of that name;
328
+ * 3. the address still holds authority: untokenized → `handle.owner ==
329
+ * address`; tokenized → ATA(address, mint) exists, its mint/owner match
330
+ * and its amount is exactly 1.
331
+ * A pointer that fails any rule is treated as absent, never as an error: it
332
+ * is a stale artefact the owner can `clear_primary`, not a malformed chain.
333
+ */
334
+ async function reverseHandle(owner) {
335
+ const pointer = config.wasm.deriveHandlePrimaryAccount(owner, handleProgram);
336
+ if (!pointer)
337
+ return null;
338
+ const p = await accountInfo(encodeBase58(pointer));
339
+ if (!p || p.data.length < PRIMARY_LEN || p.owner !== programBase58)
340
+ return null;
341
+ const handleKey = encodeBase58(p.data.slice(PRIMARY_HANDLE_OFFSET, PRIMARY_HANDLE_OFFSET + 32));
342
+ const setAt = readI64(p.data, PRIMARY_SET_AT_OFFSET);
343
+ // rule 1: the handle is live and a registry account.
344
+ const h = await accountInfo(handleKey);
345
+ if (!h || h.owner !== programBase58)
346
+ return null;
347
+ const handle = parseHandleAccount(h.data);
348
+ if (!handle)
349
+ return null;
350
+ // rule 2: staleness — the pointer must be at least as new as the handle.
351
+ if (setAt < handle.registeredAt)
352
+ return null;
353
+ // rule 3: authority still matches.
354
+ if (handle.nftMint === null) {
355
+ if (!bytesEqual(handle.owner, owner))
356
+ return null;
357
+ }
358
+ else {
359
+ const ata = config.wasm.deriveAssociatedTokenAccount(owner, handle.nftMint);
360
+ if (!ata)
361
+ return null;
362
+ const t = await accountInfo(encodeBase58(ata));
363
+ if (!t || t.data.length < TOKEN_ACCOUNT_MIN_LEN)
364
+ return null;
365
+ const mintOk = bytesEqual(t.data.slice(0, 32), handle.nftMint);
366
+ const ownerOk = bytesEqual(t.data.slice(32, 64), owner);
367
+ const amount = readU64(t.data, 64);
368
+ if (!mintOk || !ownerOk || amount !== 1n)
369
+ return null;
370
+ }
371
+ return handle.name;
372
+ }
373
+ /**
374
+ * Address to its primary name, or null when none is set.
375
+ *
376
+ * Two sources, in order: the `@handle` registry's on-chain `Primary` pointer
377
+ * (returned as the plain canonical handle, e.g. `"nike"`), then — only when
378
+ * there is no valid handle primary — X1NS's primary-domain record, returned
379
+ * as `"<domainAccount>.<tld>"` exactly as before. The handle path never
380
+ * throws for a stale or dangling pointer; it falls through to X1NS instead.
381
+ */
187
382
  async function reverse(address) {
188
- const { decodeBase58_32 } = await import("./base58.js");
189
383
  const owner = decodeBase58_32(address);
190
384
  if (!owner)
191
385
  throw new ResolveError("unrecognized", `"${address}" is not an address`, address);
386
+ const handleName = await reverseHandle(owner);
387
+ if (handleName !== null)
388
+ return handleName;
192
389
  const record = config.wasm.derivePrimaryAccount(owner);
193
390
  if (!record)
194
391
  throw new ResolveError("unrecognized", "could not derive record", address);
package/dist/types.d.ts CHANGED
@@ -36,8 +36,19 @@ export interface Resolved {
36
36
  export type Chain = "X1" | "SOL" | "ETH" | "BTC";
37
37
  export declare const CHAIN_COIN_TYPE: Readonly<Record<Chain, number>>;
38
38
  export type ResolveErrorCode = "unrecognized" | "ambiguous" | "invalid-handle" | "invalid-domain" | "not-found" | "no-record-for-chain" | "rpc-error";
39
+ /**
40
+ * Finer-grained cause for a `ResolveError`, when the code alone is not
41
+ * enough for a UI to explain what happened.
42
+ *
43
+ * - `nft-burned`: the handle is tokenized and its NFT has been burned (mint
44
+ * supply 0), so nobody holds authority over the name any more. Surfaces
45
+ * as `not-found` — the name resolves to no one — never as the registry
46
+ * account's stale `owner` field.
47
+ */
48
+ export type ResolveErrorReason = "nft-burned";
39
49
  export declare class ResolveError extends Error {
40
50
  readonly code: ResolveErrorCode;
41
51
  readonly input?: string | undefined;
42
- constructor(code: ResolveErrorCode, message: string, input?: string | undefined);
52
+ readonly reason?: ResolveErrorReason | undefined;
53
+ constructor(code: ResolveErrorCode, message: string, input?: string | undefined, reason?: ResolveErrorReason | undefined);
43
54
  }
package/dist/types.js CHANGED
@@ -16,10 +16,12 @@ export const CHAIN_COIN_TYPE = Object.freeze({
16
16
  export class ResolveError extends Error {
17
17
  code;
18
18
  input;
19
- constructor(code, message, input) {
19
+ reason;
20
+ constructor(code, message, input, reason) {
20
21
  super(message);
21
22
  this.code = code;
22
23
  this.input = input;
24
+ this.reason = reason;
23
25
  this.name = "ResolveError";
24
26
  }
25
27
  }
package/dist/wasm.d.ts CHANGED
@@ -39,5 +39,20 @@ export declare class WasmResolver {
39
39
  * program the caller names — never a program baked into this module.
40
40
  */
41
41
  deriveHandleAccount(canonical: string, programId: Uint8Array): Uint8Array | null;
42
+ /**
43
+ * 32-byte `@handle` registry `Primary` pointer account for a 32-byte owner
44
+ * address under a given registry program id — seeds `["primary", owner]`.
45
+ * This is the on-chain `set_primary` / `clear_primary` PDA, NOT X1NS's
46
+ * primary-domain record (`derivePrimaryAccount`), which lives under the
47
+ * X1NS registrar and has a different layout.
48
+ */
49
+ deriveHandlePrimaryAccount(owner: Uint8Array, programId: Uint8Array): Uint8Array | null;
50
+ /**
51
+ * 32-byte SPL associated token account for `(owner, mint)` under the
52
+ * canonical SPL Token + Associated Token programs (fixed inside the module —
53
+ * they are the same on every SVM chain). Used to check that an address still
54
+ * holds a tokenized handle's NFT.
55
+ */
56
+ deriveAssociatedTokenAccount(owner: Uint8Array, mint: Uint8Array): Uint8Array | null;
42
57
  }
43
58
  export {};
package/dist/wasm.js CHANGED
@@ -72,4 +72,33 @@ export class WasmResolver {
72
72
  const n = this.#write(this.#enc.encode(canonical));
73
73
  return this.#x.derive_handle_account_staged(n) === 1 ? this.#read() : null;
74
74
  }
75
+ /**
76
+ * 32-byte `@handle` registry `Primary` pointer account for a 32-byte owner
77
+ * address under a given registry program id — seeds `["primary", owner]`.
78
+ * This is the on-chain `set_primary` / `clear_primary` PDA, NOT X1NS's
79
+ * primary-domain record (`derivePrimaryAccount`), which lives under the
80
+ * X1NS registrar and has a different layout.
81
+ */
82
+ deriveHandlePrimaryAccount(owner, programId) {
83
+ if (owner.length !== 32 || programId.length !== 32)
84
+ return null;
85
+ new Uint8Array(this.#x.memory.buffer, this.#x.program_ptr(), 32).set(programId);
86
+ const n = this.#write(owner);
87
+ return this.#x.derive_handle_primary_account_staged(n) === 1 ? this.#read() : null;
88
+ }
89
+ /**
90
+ * 32-byte SPL associated token account for `(owner, mint)` under the
91
+ * canonical SPL Token + Associated Token programs (fixed inside the module —
92
+ * they are the same on every SVM chain). Used to check that an address still
93
+ * holds a tokenized handle's NFT.
94
+ */
95
+ deriveAssociatedTokenAccount(owner, mint) {
96
+ if (owner.length !== 32 || mint.length !== 32)
97
+ return null;
98
+ const joined = new Uint8Array(64);
99
+ joined.set(owner, 0);
100
+ joined.set(mint, 32);
101
+ const n = this.#write(joined);
102
+ return this.#x.derive_associated_token_account(n) === 1 ? this.#read() : null;
103
+ }
75
104
  }
package/package.json CHANGED
@@ -1,12 +1,24 @@
1
1
  {
2
2
  "name": "@x1id/resolve",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Resolve @handles and X1NS names on X1. Never silently picks between namespaces.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
- "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
9
- "files": ["dist", "wasm"],
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./wasm/x1_resolve_wasm.wasm": "./wasm/x1_resolve_wasm.wasm",
14
+ "./wasm/*": "./wasm/*",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "wasm",
20
+ "README.md"
21
+ ],
10
22
  "scripts": {
11
23
  "build:wasm": "cargo build --release -p x1-resolve-wasm --target wasm32-unknown-unknown && cp target/wasm32-unknown-unknown/release/x1_resolve_wasm.wasm wasm/",
12
24
  "build": "tsc -p tsconfig.json",
@@ -14,18 +26,37 @@
14
26
  "lint:package": "publint",
15
27
  "prepublishOnly": "npm run build"
16
28
  },
17
- "keywords": ["x1", "x1id", "solana", "svm", "naming", "x1ns", "handles", "wallet", "resolver", "web3"],
29
+ "keywords": [
30
+ "x1",
31
+ "x1id",
32
+ "solana",
33
+ "svm",
34
+ "naming",
35
+ "x1ns",
36
+ "handles",
37
+ "wallet",
38
+ "resolver",
39
+ "web3"
40
+ ],
18
41
  "homepage": "https://x1id.io",
19
- "repository": { "type": "git", "url": "git+https://github.com/fortiblox/x1id-sdk.git" },
20
- "bugs": { "url": "https://github.com/fortiblox/x1id-sdk/issues" },
21
42
  "license": "MIT",
22
- "publishConfig": { "access": "public", "provenance": true },
23
- "engines": { "node": ">=18" },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "engines": {
47
+ "node": ">=18"
48
+ },
24
49
  "devDependencies": {
25
50
  "typescript": "^5.6.0",
26
51
  "@types/node": "^22.0.0",
27
52
  "publint": "^0.2.0"
28
53
  },
29
- "peerDependencies": { "@solana/web3.js": "^1.95.0" },
30
- "peerDependenciesMeta": { "@solana/web3.js": { "optional": true } }
54
+ "peerDependencies": {
55
+ "@solana/web3.js": "^1.95.0"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "@solana/web3.js": {
59
+ "optional": true
60
+ }
61
+ }
31
62
  }
Binary file