@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 +25 -8
- package/dist/index.d.ts +5 -2
- package/dist/index.js +80 -19
- package/dist/types.d.ts +12 -1
- package/dist/types.js +3 -1
- package/package.json +36 -8
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
|
-
|
|
126
|
-
(
|
|
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
|
|
178
|
-
| `unverified` |
|
|
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
|
|
263
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
".": {
|
|
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": [
|
|
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": [
|
|
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": {
|
|
26
|
-
|
|
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": {
|
|
33
|
-
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@solana/web3.js": "^1.95.0"
|
|
56
|
+
},
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"@solana/web3.js": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
34
62
|
}
|