@x1id/resolve 0.2.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
@@ -122,8 +122,11 @@ X1NS names resolve by deriving accounts and reading them over RPC. This library
122
122
  never calls `api.x1ns.xyz` or any API of ours — a wallet that resolves through
123
123
  a third party's uptime breaks when they lose interest. `@handle` resolution
124
124
  derives the PDA (`["handle", canonical]` under `handleProgramId`) and reads the
125
- owner directly; reverse lookup reads the registry's on-chain `Primary` pointer
126
- (`["primary", owner]`). All reads use `confirmed` commitment.
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.
127
130
 
128
131
  Address derivation uses a small Rust→WASM module (a real ed25519 on-curve check
129
132
  — the kind of thing that is subtly wrong for months when hand-rolled in JS, and
@@ -174,8 +177,13 @@ a wrong address on the wrong chain.
174
177
 
175
178
  | `verification` | meaning |
176
179
  |---|---|
177
- | `verified` | control of the address was proved (the `@handle` owner holds the registry account). |
178
- | `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.
179
187
 
180
188
  ## What works today
181
189
 
@@ -200,7 +208,7 @@ defaults to the canonical X1 deployment
200
208
  | `unrecognized` | not a handle or known domain — probably a raw address |
201
209
  | `ambiguous` | both shapes at once, e.g. `@jack.x1` |
202
210
  | `invalid-handle` / `invalid-domain` | right shape, invalid content |
203
- | `not-found` | valid name, not registered |
211
+ | `not-found` | valid name, not registered — or `reason: "nft-burned"`: tokenized and its NFT was burned, so no one holds it |
204
212
  | `no-record-for-chain` | registered, but no address for the requested chain |
205
213
  | `rpc-error` | transport failure |
206
214
 
@@ -259,15 +267,24 @@ npm test # node --test; X1_LIVE=1 also runs the testnet reverse() ca
259
267
  ```
260
268
 
261
269
  `wasm/x1_resolve_wasm.wasm` is committed and shipped. Rebuild it whenever
262
- `crates/x1-resolve-wasm` (or its dependencies) change; the release profile is
263
- stripped, so a rebuild from the same source is byte-identical.
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.
264
277
 
265
278
  ## Publishing
266
279
 
267
280
  Published from the (private) release mirror, not from the monorepo: sync this
268
281
  directory (plus `crates/`) there, then push a `v<version>` tag that matches
269
282
  `package.json` — the release workflow builds the WASM, runs the tests and
270
- `publint`, and runs `npm publish --provenance`.
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.)
271
288
 
272
289
  ## Links
273
290
 
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,11 @@ 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;
49
54
  // `Handle`'s fixed base allocation (`space = 8 + INIT_SPACE`). The NFT
50
55
  // extension, when present, is appended at this boundary regardless of the
51
56
  // compact Borsh length of the (variable, `Option`-bearing) struct content —
@@ -59,6 +64,12 @@ const PRIMARY_HANDLE_OFFSET = 40;
59
64
  const PRIMARY_SET_AT_OFFSET = 72;
60
65
  // SPL Token account: mint(32) | owner(32) | amount(u64 LE, 8) | ...
61
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";
62
73
  function readI64(data, offset) {
63
74
  return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigInt64(offset, true);
64
75
  }
@@ -204,35 +215,85 @@ export function createResolver(config) {
204
215
  verification: "unverified",
205
216
  };
206
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
+ }
207
268
  async function resolveHandle(canonical, chain, input) {
208
269
  const account = config.wasm.deriveHandleAccount(canonical, handleProgram);
209
270
  if (!account) {
210
271
  throw new ResolveError("invalid-handle", `"${input}" is not a valid handle`, input);
211
272
  }
212
- const data = await accountData(encodeBase58(account));
213
- 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) {
214
277
  throw new ResolveError("not-found", `@${canonical} is not registered`, input);
215
278
  }
216
- if (data.length < HANDLE_MIN_LEN) {
279
+ const handle = parseHandleAccount(h.data);
280
+ if (!handle) {
217
281
  throw new ResolveError("rpc-error", `@${canonical} returned a malformed account`, input);
218
282
  }
219
- const owner = encodeBase58(data.slice(HANDLE_OWNER_OFFSET, HANDLE_OWNER_OFFSET + 32));
220
- // 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
221
284
  // separate record accounts the resolver does not read yet, so a request for
222
285
  // another chain is an explicit "no record" rather than a wrong address.
223
286
  if (chain !== "X1" && chain !== "SOL") {
224
287
  throw new ResolveError("no-record-for-chain", `@${canonical} has no ${chain} record`, input);
225
288
  }
226
- return {
227
- input,
228
- name: canonical,
229
- namespace: "handle",
230
- address: owner,
231
- chain,
232
- // The owner holds the registry account for this handle — a proved control
233
- // relationship, not an unverified record.
234
- verification: "verified",
235
- };
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 };
236
297
  }
237
298
  async function resolve(input, opts) {
238
299
  const chain = opts?.chain ?? "X1";
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/package.json CHANGED
@@ -1,17 +1,24 @@
1
1
  {
2
2
  "name": "@x1id/resolve",
3
- "version": "0.2.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
8
  "exports": {
9
- ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
10
13
  "./wasm/x1_resolve_wasm.wasm": "./wasm/x1_resolve_wasm.wasm",
11
14
  "./wasm/*": "./wasm/*",
12
15
  "./package.json": "./package.json"
13
16
  },
14
- "files": ["dist", "wasm", "README.md"],
17
+ "files": [
18
+ "dist",
19
+ "wasm",
20
+ "README.md"
21
+ ],
15
22
  "scripts": {
16
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/",
17
24
  "build": "tsc -p tsconfig.json",
@@ -19,16 +26,37 @@
19
26
  "lint:package": "publint",
20
27
  "prepublishOnly": "npm run build"
21
28
  },
22
- "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
+ ],
23
41
  "homepage": "https://x1id.io",
24
42
  "license": "MIT",
25
- "publishConfig": { "access": "public" },
26
- "engines": { "node": ">=18" },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "engines": {
47
+ "node": ">=18"
48
+ },
27
49
  "devDependencies": {
28
50
  "typescript": "^5.6.0",
29
51
  "@types/node": "^22.0.0",
30
52
  "publint": "^0.2.0"
31
53
  },
32
- "peerDependencies": { "@solana/web3.js": "^1.95.0" },
33
- "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
+ }
34
62
  }