@x1id/resolve 0.2.1 → 0.6.0

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
@@ -163,15 +163,34 @@ A pointer that fails any rule is treated as absent (falls through to X1NS, then
163
163
  A handle can carry addresses for several chains. Pass the one you want:
164
164
 
165
165
  ```ts
166
- await x1id.resolve("@jack", { chain: "X1" }); // default
166
+ await x1id.resolve("@jack", { chain: "X1" }); // default — the handle's authority
167
167
  await x1id.resolve("@jack", { chain: "SOL" }); // X1 shares Solana's key format
168
- await x1id.resolve("@jack", { chain: "ETH" }); // → no-record-for-chain (see below)
168
+ await x1id.resolve("@jack", { chain: "ETH" }); // the handle's ETH record, if one is set
169
+ await x1id.resolve("@jack", { chain: "BTC" }); // the handle's BTC record, if one is set
169
170
  ```
170
171
 
171
- Today the SDK reads the owner's **X1/SVM** address. Per-chain records for ETH and
172
- BTC live in separate on-chain accounts the resolver does not read yet, so a
173
- request for those returns `no-record-for-chain` — an explicit "no record", never
174
- a wrong address on the wrong chain.
172
+ For **X1/SOL** the SDK returns the handle's current authority. For **ETH/BTC**
173
+ it reads the handle's per-chain `Record` accounts; a handle without a live
174
+ record for the requested chain is `no-record-for-chain` — an explicit "no
175
+ record", never a wrong address on the wrong chain.
176
+
177
+ Every record is judged against the **universal staleness rule**
178
+ (`docs/record-trust.md` in the x1-handles repo): a `Record` whose
179
+ `updated_at` predates the handle's current `registered_at` was left behind by
180
+ a **previous owner of the same name** (the Handle PDA survives a release +
181
+ re-register, and records hang off it) and is treated as unset — its address
182
+ is never resolved, and its stored `verified: true` is never surfaced. The
183
+ rule is structural in this SDK: `decodeRecord`/`fetchRecords` require the
184
+ handle's `registeredAt`, so there is no code path that returns an unjudged
185
+ record.
186
+
187
+ ```ts
188
+ // All records of a handle, stale ones flagged (verified forced false):
189
+ const all = await x1id.records("@jack");
190
+ // The ones that actually belong to the current owner:
191
+ import { liveRecords } from "@x1id/resolve";
192
+ const live = liveRecords(all);
193
+ ```
175
194
 
176
195
  ## Verification
177
196
 
@@ -192,13 +211,210 @@ path.
192
211
  | X1NS resolution (`.x1/.xnt/.xen`) | ✅ mainnet |
193
212
  | `@handle` resolution | ✅ on any RPC where the registry is deployed — **X1 testnet today**; mainnet on deploy |
194
213
  | Reverse lookup | ✅ on-chain `@handle` primary (testnet today) with X1NS primary-domain fallback (mainnet); X1NS results are `<domainAccount>.<tld>`, not a label |
195
- | Per-chain ETH/BTC records | 🔜 roadmap |
214
+ | Per-chain ETH/BTC records | ✅ read with the staleness rule enforced structurally |
215
+ | Control proofs (prove you control a `@handle`) | ✅ `createControlChallenge` / `verifyControlProof` — spec: `docs/control-proof.md` |
216
+ | Records read (per-chain, staleness-enforced) | ✅ `fetchRecords` |
217
+ | Subnames · gifts · integrator rev-share · name-lock · record-delegate · text records · attestations (write) | ✅ 0.3.0 — instruction builders; you sign & relay |
196
218
  | Registration / transfer / `set_primary` (write) | 🔜 roadmap — use [x1id.io](https://x1id.io) |
197
219
 
198
220
  The `@handle` registry program id is configurable (`handleProgramId`) and
199
221
  defaults to the canonical X1 deployment
200
222
  (`8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P`).
201
223
 
224
+ ## Beyond resolve: the registry write surface (0.3.0)
225
+
226
+ `resolve()` / `reverse()` / `fetchRecords()` are read-only and need only an RPC.
227
+ 0.3.0 also ships **instruction builders** for the on-chain registry. Each
228
+ returns an unsigned `BuiltInstruction` (`{ programId, keys, data }`) — you
229
+ assemble it into a transaction, **sign, and relay** yourself; the SDK never
230
+ holds keys. Every discriminator and account order matches the deployed program.
231
+
232
+ **Read a handle's cross-chain records** (no wallet):
233
+
234
+ ```ts
235
+ import { fetchRecords, liveRecords, valueToAddress } from "@x1id/resolve";
236
+
237
+ // handleAccount = the ["handle", name] PDA; registeredAt from the handle account
238
+ const records = await fetchRecords(rpc, programId, handleAccount, registeredAt);
239
+ for (const r of liveRecords(records)) {
240
+ console.log(r.chain, valueToAddress(r.chain, r.value));
241
+ }
242
+ ```
243
+
244
+ **Issue a subname** (`team.you`), signed by the parent's current authority:
245
+
246
+ ```ts
247
+ import { buildCreateSubnameIx } from "@x1id/resolve";
248
+
249
+ const ix = buildCreateSubnameIx({
250
+ programId,
251
+ payer, // signer — funds the subname account's rent
252
+ owner, // signer — the parent handle's current authority (owner or NFT holder)
253
+ parent, // the parent ["handle", name] PDA
254
+ subname, // the ["subname", parent, label] PDA
255
+ label: "team",
256
+ });
257
+ // add `ix` to a transaction, sign with payer + owner, send.
258
+ ```
259
+
260
+ **Register a handle** (pays whatever the on-chain ramp charges as of the
261
+ landing slot — there is no client-supplied price):
262
+
263
+ ```ts
264
+ import { buildRegisterIx, fetchConfigTreasury, HandleType } from "@x1id/resolve";
265
+
266
+ const treasury = await fetchConfigTreasury(rpc, configAccount); // Config.treasury, read fresh
267
+ const ix = buildRegisterIx({
268
+ programId, payer, owner,
269
+ config: configAccount, // the ["config"] PDA
270
+ treasury,
271
+ handle, // the ["handle", name] PDA — must be unregistered
272
+ name: "payday-bot",
273
+ handleType: HandleType.Agent,
274
+ });
275
+ ```
276
+
277
+ **Gift a handle** (prepaid voucher; the recipient claims it later):
278
+
279
+ ```ts
280
+ import { buildCreateVoucherIx, HandleType } from "@x1id/resolve";
281
+
282
+ const ix = buildCreateVoucherIx({
283
+ programId, payer, config, // config = the ["config"] PDA
284
+ voucher, // the ["voucher", name] PDA
285
+ handle, // the ["handle", name] PDA — must be unregistered
286
+ name: "gift1",
287
+ handleType: HandleType.Human,
288
+ recipient, // bind to a wallet, or null for an open claim code
289
+ expiresAt: Math.floor(Date.now() / 1000) + 30 * 86400,
290
+ });
291
+ ```
292
+
293
+ Also shipped, same build → sign → relay shape:
294
+
295
+ | Capability | Builders |
296
+ |---|---|
297
+ | Integrator rev-share | `buildAddIntegratorIx`, `buildSetIntegratorRateIx`, `buildRemoveIntegratorIx` |
298
+ | Name lock / timelocked unlock | `buildLockHandleIx`, `buildInitiateUnlockIx`, `buildCompleteUnlockIx`, `buildCancelUnlockIx` |
299
+ | Record-write delegation | `buildSetRecordDelegateIx`, `buildRevokeRecordDelegateIx` |
300
+ | Typed text records (website / avatar / socials) | `buildCreateTextRecordIx`, `buildUpdateTextRecordIx`, `buildCloseTextRecordIx` |
301
+ | Register a handle | `buildRegisterIx` (+ `fetchConfigTreasury`) |
302
+ | Verify a non-SVM address record | `buildCreateRecordIx`, `buildVerifyRecordEthIx`, `buildVerifyRecordSvmIx`, `buildVerifyRecordBtcIx` (+ `buildRecordChallenge`, `splitEthSignature`, `splitBtcSignature`) — see [Verifying a non-SVM address record](#verifying-a-non-svm-address-record) |
303
+ | Domain / social attestations | `buildCreateAttestationIx`, `buildCloseAttestationIx` (+ `fetchAttestations`, `isHandleVerified`) |
304
+ | Agent records (identity, manifest, x402 hints) | `validateManifest`, `agentVerificationLevel`, `x402AcceptsFromManifest`, `agentTextRecords` — see [Agent records](#agent-records) below |
305
+ | Subname records / revoke | `buildRevokeSubnameIx`, `buildCreateSubnameRecordIx`, `buildUpdateSubnameRecordIx`, `buildCloseSubnameRecordIx` |
306
+ | Voucher claim / refund | `buildClaimVoucherIx`, `buildRefundVoucherIx` |
307
+
308
+ PDA seeds and account layouts are documented per module in the source and typed
309
+ end to end. Reads need only an RPC; writes need a signer.
310
+
311
+ ## Agent records
312
+
313
+ An `@handle` can identify, describe, and get paid as an AI agent —
314
+ conventions on top of the existing primitives above (`HandleType.Agent`,
315
+ text records, address records, attestations), **no new on-chain account
316
+ family**. Full design: [`docs/agent-records.md`](../docs/agent-records.md).
317
+
318
+ ```ts
319
+ import {
320
+ AGENT_RECORD_KEYS,
321
+ agentTextRecords,
322
+ validateManifest,
323
+ agentVerificationLevel,
324
+ x402AcceptsFromManifest,
325
+ } from "@x1id/resolve";
326
+
327
+ // after fetching + liveTextRecords(...) for the handle:
328
+ const agent = agentTextRecords(textRecords);
329
+ if (agent.manifest) {
330
+ const raw = await fetch(agent.manifest).then((r) => r.json());
331
+ const result = validateManifest(raw, {
332
+ expectedName: "payday-bot",
333
+ expectedHandlePda: handleAccount,
334
+ expectedNetwork: "testnet",
335
+ addressRecords: liveRecords(records), // "the chain wins" cross-check
336
+ });
337
+ if (result.ok) {
338
+ const level = agentVerificationLevel({
339
+ handleType,
340
+ agentRecords: agent,
341
+ liveAttestations: liveAttestations(attestations),
342
+ manifest: result.manifest,
343
+ }); // 0, 1, or null — L2 ("pay-to confirmed") is a live HTTP fact, not implemented here; see the MCP server
344
+ const hints = x402AcceptsFromManifest(result.manifest, { payTo: verifiedAddress });
345
+ }
346
+ }
347
+ ```
348
+
349
+ `validateManifest` never trusts a manifest's own claims over the chain: a
350
+ `payment.addresses[]` entry claiming `verified: true` is rejected outright if
351
+ the supplied on-chain records disagree. `agentVerificationLevel` reports only
352
+ the highest level it actually checked — L1 requires the manifest's claimed
353
+ attestation to match a *live* one, never assumed from presence alone.
354
+
355
+ ## Verifying a non-SVM address record
356
+
357
+ Prove control of an ETH, BTC, or another SVM address so its `Record` gets
358
+ `verified: true`. On-chain, the program itself recovers the address from
359
+ the signature — this SDK never derives or checks an address client-side,
360
+ it just builds the challenge and packages the wallet's signature.
361
+
362
+ ```ts
363
+ import {
364
+ buildRecordChallenge,
365
+ buildCreateRecordIx,
366
+ buildVerifyRecordEthIx,
367
+ splitEthSignature,
368
+ } from "@x1id/resolve";
369
+
370
+ // 1. create the (unverified) record
371
+ const createIx = buildCreateRecordIx({
372
+ programId, payer, owner, handle, record,
373
+ coinType: 60, // ETH, per SLIP-44
374
+ value: ethAddressBytes,
375
+ });
376
+
377
+ // 2. build the challenge and have the wallet sign it (personal_sign)
378
+ const challenge = buildRecordChallenge("alice", 60, registeredAt, "someNonce123");
379
+ const sig65 = await wallet.signMessage(challenge); // 65 raw bytes: r || s || v
380
+
381
+ // 3. verify
382
+ const { signature, recoveryId } = splitEthSignature(sig65);
383
+ const verifyIx = buildVerifyRecordEthIx({
384
+ programId, owner, handle, record,
385
+ nonce: "someNonce123", signature, recoveryId,
386
+ });
387
+ ```
388
+
389
+ `buildVerifyRecordSvmIx` (the claimed address just co-signs, no challenge
390
+ needed) and `buildVerifyRecordBtcIx` + `splitBtcSignature` (BIP-137, mainnet
391
+ compressed-P2PKH or bech32/P2WPKH only) follow the same shape.
392
+
393
+ ## x402 payments
394
+
395
+ An agent gets paid over HTTP via [x402](https://github.com/x402-foundation/x402)'s
396
+ `"exact"` SVM scheme. Full design (including how transactions actually get
397
+ built/verified — that needs real Solana parsing, so it lives in the
398
+ [x1id MCP server](../mcp), not here): [`docs/x402.md`](../docs/x402.md).
399
+
400
+ ```ts
401
+ import { buildX402Requirements, X1_TESTNET_NETWORK } from "@x1id/resolve";
402
+
403
+ // server-side: one accepts[] entry for a 402 response
404
+ const requirements = buildX402Requirements({
405
+ payTo: sellerAddress,
406
+ asset: usdcMint,
407
+ amount: "1000000", // smallest units, decimal string
408
+ buyer: buyerAddress, // x1id v1 is self-sponsored: buyer pays their own fee, no facilitator
409
+ memo: "invoice-42",
410
+ });
411
+ ```
412
+
413
+ This package only builds the requirements object — it does not build, sign,
414
+ or verify a transaction (that needs `@solana/web3.js`, deliberately kept out
415
+ of this zero-dependency package). Use the MCP server's `prepare_x402_payment`
416
+ / `verify_x402_payment` tools for that.
417
+
202
418
  ## Errors
203
419
 
204
420
  `ResolveError` carries a `code` a UI can branch on:
@@ -240,6 +456,7 @@ interface ResolverConfig {
240
456
  interface Resolver {
241
457
  resolve(input: string, opts?: { chain?: "X1" | "SOL" | "ETH" | "BTC" }): Promise<Resolved>;
242
458
  reverse(address: string): Promise<string | null>;
459
+ records(input: string): Promise<readonly HandleRecord[]>; // stale ones flagged — see docs/record-trust.md
243
460
  clearCache(): void;
244
461
  }
245
462
 
@@ -258,6 +475,39 @@ Also exported: `parseName`, `looksLikeName`, `normalizeHandle`, `namespaceLabel`
258
475
  `encodeBase58`, `decodeBase58_32`, `ResolveError`, `CHAIN_COIN_TYPE` and the
259
476
  types `Resolved`, `Namespace`, `Chain`, `Verification`, `ResolveErrorCode`.
260
477
 
478
+ Records: `decodeRecord(raw, account, registeredAt)`, `liveRecords`,
479
+ `chainForCoinType`, `valueToAddress`, `fetchRecords`, `RECORD_LEN`,
480
+ `RECORD_DISC`, type `HandleRecord`. The handle epoch (`registeredAt`) is a
481
+ required parameter everywhere by design.
482
+
483
+ Control proofs (spec: `docs/control-proof.md` in the x1-handles repo):
484
+
485
+ ```ts
486
+ // Verifier side — reads the handle's current ownership epoch from chain,
487
+ // generates a single-use nonce, returns the challenge to store and send:
488
+ const issued = await createControlChallenge({ rpcUrl, wasm }, "@jack");
489
+ // Prover signs the raw UTF-8 bytes of issued.challenge with the wallet key
490
+ // that controls the handle (64-byte ed25519, no wallet envelope), then:
491
+ const result = await verifyControlProof({ rpcUrl, wasm }, issued, { signer, signature });
492
+ // result.valid === true ⇔ signer controls @jack right now, in this epoch.
493
+ ```
494
+
495
+ Also exported for that flow: `buildControlChallenge`, `parseControlChallenge`,
496
+ `generateControlNonce`, `verifyEd25519Strict` (RFC-8032-strict WebCrypto
497
+ verifier; override via `ControlConfig.verifyEd25519` on runtimes without
498
+ Ed25519 WebCrypto), `CONTROL_CHALLENGE_PREFIX` and the types
499
+ `ControlChallenge`, `ControlConfig`, `ControlProof`, `ControlVerification`,
500
+ `ControlFailureReason`.
501
+
502
+ ## Integrating into a wallet or explorer
503
+
504
+ Dropping resolution into someone else's product — an explorer that shows
505
+ `@handle` instead of an address, a wallet that resolves a recipient before it
506
+ signs, an in-wallet (Snap-style) resolver — is a copy-paste job. See
507
+ **[`docs/resolution-integration.md`](../docs/resolution-integration.md)**: a
508
+ resolver singleton, a typed-error recipient flow, reverse lookup with the
509
+ staleness rule, and a ~30-line React `<AddressName>`, all against this package.
510
+
261
511
  ## Developing
262
512
 
263
513
  ```bash
@@ -289,7 +539,7 @@ attached, and this file is the accurate statement.)
289
539
  ## Links
290
540
 
291
541
  - Home & docs: **[x1id.io](https://x1id.io)**
292
- - Developer docs: **[docs.fortiblox.com/docs/x1id](https://docs.fortiblox.com/docs/x1id)**
542
+ - Developer docs: **[docs.x1id.io](https://docs.x1id.io)**
293
543
  - Questions & issues: [x1id.io](https://x1id.io) — contact links in the footer
294
544
 
295
545
  ## License
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `@handle` registry account access shared by the resolver (`index.ts`), the
3
+ * per-chain record reader (`records.ts`) and the control-proof helpers
4
+ * (`control.ts`). One implementation of the byte layouts and of the
5
+ * "who currently holds authority" rule, so the three surfaces cannot drift.
6
+ *
7
+ * Everything here was extracted verbatim from `index.ts` (where the resolver
8
+ * tests exercise it); nothing in this module is public API on its own.
9
+ */
10
+ import { type Verification } from "./types.js";
11
+ import type { WasmResolver } from "./wasm.js";
12
+ /** The @handle registry program on X1. Deployed on testnet today; the same id
13
+ * is used on mainnet once deployed. Override via `handleProgramId` to point at
14
+ * a different deployment. */
15
+ export declare const DEFAULT_HANDLE_PROGRAM = "8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P";
16
+ export declare const TOKEN_ACCOUNT_MIN_LEN = 72;
17
+ export declare const SPL_TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
18
+ /** The fields of a `Handle` account readers need. */
19
+ export interface ParsedHandle {
20
+ readonly name: string;
21
+ readonly owner: Uint8Array;
22
+ /**
23
+ * `Handle.registered_at` — the handle's CURRENT ownership epoch. Stamped
24
+ * fresh on every `register`/`register_reserved`, including a re-registration
25
+ * of a previously released name, and bumped by every native-market sale.
26
+ * Every per-handle record and proof must be judged against it — see
27
+ * docs/record-trust.md.
28
+ */
29
+ readonly registeredAt: bigint;
30
+ /**
31
+ * `Handle.records_cleared_at` (0 if never cleared) — set by `clear_records`
32
+ * (#8139). Every per-handle record/text-record/attestation must ALSO be
33
+ * judged stale when `updated_at < recordsClearedAt`, alongside (not instead
34
+ * of) the `registeredAt` epoch rule — see docs/record-trust.md.
35
+ */
36
+ readonly recordsClearedAt: bigint;
37
+ /** Set when the handle is tokenized (NFT extension present). */
38
+ readonly nftMint: Uint8Array | null;
39
+ }
40
+ export declare function readI64(data: Uint8Array, offset: number): bigint;
41
+ export declare function readU64(data: Uint8Array, offset: number): bigint;
42
+ export declare function bytesEqual(a: Uint8Array, b: Uint8Array): boolean;
43
+ /**
44
+ * Parse the `Handle` fields readers need. Port of `parse_handle` in
45
+ * tools/api — sequential, because `recovery` / `recovery_target` are
46
+ * `Option<Pubkey>` (Borsh: tag byte, then 32 bytes when `Some`) and shift
47
+ * `registered_at`. The NFT mint is read at the fixed base boundary, not the
48
+ * sequential position.
49
+ *
50
+ * disc(8) name(32) name_len(1) owner(32) handle_type(1)
51
+ * recovery: Option<Pubkey> recovery_initiated_at: i64
52
+ * recovery_target: Option<Pubkey> registered_at: i64 bump(1)
53
+ * [at 8+149: nft tag(1) mint(32)]
54
+ */
55
+ export declare function parseHandleAccount(data: Uint8Array): ParsedHandle | null;
56
+ /** A JSON-RPC call against the configured endpoint. */
57
+ export type RpcFn = (method: string, params: unknown[]) => Promise<unknown>;
58
+ export interface AccountReader {
59
+ readonly rpc: RpcFn;
60
+ /** Fetch raw account data plus the owning program, or null when the account
61
+ * does not exist. */
62
+ accountInfo(address: string): Promise<{
63
+ readonly data: Uint8Array;
64
+ readonly owner: string;
65
+ } | null>;
66
+ /** Fetch raw account data, or null when the account does not exist. */
67
+ accountData(address: string): Promise<Uint8Array | null>;
68
+ }
69
+ /** Build the RPC plumbing every reader in this package shares. */
70
+ export declare function makeAccountReader(rpcUrl: string, fetchImpl?: typeof fetch): AccountReader;
71
+ /**
72
+ * Current holder of a tokenized handle: the owner of the single token
73
+ * account holding the handle's NFT (supply 1, decimals 0). Never falls back
74
+ * to `Handle.owner` — after the NFT changes hands that field names the
75
+ * previous owner, and paying it is the exact failure this SDK exists to
76
+ * prevent.
77
+ *
78
+ * Parity with the program's `require_current_authority` (and the
79
+ * resolver's `reverse()`): the program recognises the holder as the name's
80
+ * authority only when the NFT sits in the holder's **associated token
81
+ * account** for the mint. A holder whose NFT is parked elsewhere (an
82
+ * auxiliary account, a program escrow) still controls the token, so the
83
+ * address is returned — but as `unverified`, because the registry will not
84
+ * let that address act for the name until the NFT is back in its ATA.
85
+ *
86
+ * A burned NFT (mint supply 0) is `not-found` with reason `nft-burned`: the
87
+ * name has no holder, and no one — least of all the stale `owner` — may be
88
+ * paid for it. Any other failure to find the holder is an `rpc-error`.
89
+ */
90
+ export declare function nftHolder(reader: AccountReader, wasm: WasmResolver, canonical: string, mint: Uint8Array, input: string): Promise<{
91
+ readonly address: string;
92
+ readonly verification: Verification;
93
+ }>;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * `@handle` registry account access shared by the resolver (`index.ts`), the
3
+ * per-chain record reader (`records.ts`) and the control-proof helpers
4
+ * (`control.ts`). One implementation of the byte layouts and of the
5
+ * "who currently holds authority" rule, so the three surfaces cannot drift.
6
+ *
7
+ * Everything here was extracted verbatim from `index.ts` (where the resolver
8
+ * tests exercise it); nothing in this module is public API on its own.
9
+ */
10
+ import { ResolveError } from "./types.js";
11
+ import { encodeBase58 } from "./base58.js";
12
+ /** The @handle registry program on X1. Deployed on testnet today; the same id
13
+ * is used on mainnet once deployed. Override via `handleProgramId` to point at
14
+ * a different deployment. */
15
+ export const DEFAULT_HANDLE_PROGRAM = "8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P";
16
+ // Handle account layout, mirrored from the on-chain program:
17
+ // discriminator(8) name(32) name_len(1) owner(32) ...
18
+ // `owner` is the address an UNTOKENIZED handle resolves to. Once the handle
19
+ // is tokenized (NFT extension present, see `parseHandleAccount`) the program
20
+ // treats `owner` as informational only — authority is whoever holds the NFT —
21
+ // and so must every reader.
22
+ const HANDLE_OWNER_OFFSET = 41;
23
+ // `Handle`'s fixed base allocation (`space = 8 + INIT_SPACE`). The NFT
24
+ // extension, when present, is appended at this boundary regardless of the
25
+ // compact Borsh length of the (variable, `Option`-bearing) struct content —
26
+ // mirrors `Handle::NFT_EXT_OFFSET` in the program and `HANDLE_BASE_LEN` in
27
+ // tools/api.
28
+ const HANDLE_BASE_LEN = 8 + 149;
29
+ // `records_cleared_at` extension (`Handle::RECORDS_CLEARED_EXT_OFFSET`, #8139):
30
+ // an i64 LE the owner bumps via `clear_records` to bulk-invalidate every
31
+ // record/text-record/attestation without transferring the handle. Read
32
+ // leniently — a short or zeroed account means "never cleared" (0), same
33
+ // convention as every other Handle extension region.
34
+ const RECORDS_CLEARED_EXT_OFFSET = 229;
35
+ // SPL Token account: mint(32) | owner(32) | amount(u64 LE, 8) | ...
36
+ export const TOKEN_ACCOUNT_MIN_LEN = 72;
37
+ // SPL Token mint: mint_authority COption(4+32) | supply(u64 LE, 8) @36 | ...
38
+ const MINT_SUPPLY_OFFSET = 36;
39
+ const MINT_MIN_LEN = MINT_SUPPLY_OFFSET + 8;
40
+ // The registry mints handle NFTs with the legacy SPL Token program, so every
41
+ // token account for a handle mint is owned by it.
42
+ export const SPL_TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
43
+ export function readI64(data, offset) {
44
+ return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigInt64(offset, true);
45
+ }
46
+ export function readU64(data, offset) {
47
+ return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigUint64(offset, true);
48
+ }
49
+ export function bytesEqual(a, b) {
50
+ if (a.length !== b.length)
51
+ return false;
52
+ for (let i = 0; i < a.length; i++)
53
+ if (a[i] !== b[i])
54
+ return false;
55
+ return true;
56
+ }
57
+ /**
58
+ * Parse the `Handle` fields readers need. Port of `parse_handle` in
59
+ * tools/api — sequential, because `recovery` / `recovery_target` are
60
+ * `Option<Pubkey>` (Borsh: tag byte, then 32 bytes when `Some`) and shift
61
+ * `registered_at`. The NFT mint is read at the fixed base boundary, not the
62
+ * sequential position.
63
+ *
64
+ * disc(8) name(32) name_len(1) owner(32) handle_type(1)
65
+ * recovery: Option<Pubkey> recovery_initiated_at: i64
66
+ * recovery_target: Option<Pubkey> registered_at: i64 bump(1)
67
+ * [at 8+149: nft tag(1) mint(32)]
68
+ */
69
+ export function parseHandleAccount(data) {
70
+ if (data.length < HANDLE_BASE_LEN)
71
+ return null;
72
+ const nameLen = Math.min(data[40], 32);
73
+ const name = new TextDecoder().decode(data.slice(8, 8 + nameLen));
74
+ const owner = data.slice(HANDLE_OWNER_OFFSET, HANDLE_OWNER_OFFSET + 32);
75
+ let pos = 73 + 1; // owner end + handle_type(1)
76
+ // recovery: Option<Pubkey>
77
+ if (pos >= data.length)
78
+ return null;
79
+ pos += 1 + (data[pos] === 1 ? 32 : 0);
80
+ pos += 8; // recovery_initiated_at
81
+ // recovery_target: Option<Pubkey>
82
+ if (pos >= data.length)
83
+ return null;
84
+ pos += 1 + (data[pos] === 1 ? 32 : 0);
85
+ if (pos + 8 > data.length)
86
+ return null;
87
+ const registeredAt = readI64(data, pos);
88
+ const nftMint = data.length >= HANDLE_BASE_LEN + 33 && data[HANDLE_BASE_LEN] === 1
89
+ ? data.slice(HANDLE_BASE_LEN + 1, HANDLE_BASE_LEN + 33)
90
+ : null;
91
+ const recordsClearedAt = data.length >= RECORDS_CLEARED_EXT_OFFSET + 8 ? readI64(data, RECORDS_CLEARED_EXT_OFFSET) : 0n;
92
+ return { name, owner, registeredAt, recordsClearedAt, nftMint };
93
+ }
94
+ /** Build the RPC plumbing every reader in this package shares. */
95
+ export function makeAccountReader(rpcUrl, fetchImpl) {
96
+ const doFetch = fetchImpl ?? globalThis.fetch;
97
+ if (typeof doFetch !== "function") {
98
+ throw new Error("No fetch available; pass fetchImpl in the config");
99
+ }
100
+ async function rpc(method, params) {
101
+ let res;
102
+ try {
103
+ res = await doFetch(rpcUrl, {
104
+ method: "POST",
105
+ headers: { "content-type": "application/json" },
106
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
107
+ });
108
+ }
109
+ catch (e) {
110
+ throw new ResolveError("rpc-error", `RPC request failed: ${String(e)}`);
111
+ }
112
+ if (!res.ok) {
113
+ throw new ResolveError("rpc-error", `RPC returned HTTP ${res.status}`);
114
+ }
115
+ const body = (await res.json());
116
+ if (body.error) {
117
+ throw new ResolveError("rpc-error", body.error.message ?? "RPC error");
118
+ }
119
+ return body.result;
120
+ }
121
+ async function accountInfo(address) {
122
+ const result = (await rpc("getAccountInfo", [
123
+ address,
124
+ { encoding: "base64", commitment: "confirmed" },
125
+ ]));
126
+ const value = result?.value;
127
+ if (!value)
128
+ return null;
129
+ const b64 = value.data[0];
130
+ const bin = atob(b64);
131
+ const out = new Uint8Array(bin.length);
132
+ for (let i = 0; i < bin.length; i++)
133
+ out[i] = bin.charCodeAt(i);
134
+ return { data: out, owner: value.owner };
135
+ }
136
+ async function accountData(address) {
137
+ return (await accountInfo(address))?.data ?? null;
138
+ }
139
+ return { rpc, accountInfo, accountData };
140
+ }
141
+ /**
142
+ * Current holder of a tokenized handle: the owner of the single token
143
+ * account holding the handle's NFT (supply 1, decimals 0). Never falls back
144
+ * to `Handle.owner` — after the NFT changes hands that field names the
145
+ * previous owner, and paying it is the exact failure this SDK exists to
146
+ * prevent.
147
+ *
148
+ * Parity with the program's `require_current_authority` (and the
149
+ * resolver's `reverse()`): the program recognises the holder as the name's
150
+ * authority only when the NFT sits in the holder's **associated token
151
+ * account** for the mint. A holder whose NFT is parked elsewhere (an
152
+ * auxiliary account, a program escrow) still controls the token, so the
153
+ * address is returned — but as `unverified`, because the registry will not
154
+ * let that address act for the name until the NFT is back in its ATA.
155
+ *
156
+ * A burned NFT (mint supply 0) is `not-found` with reason `nft-burned`: the
157
+ * name has no holder, and no one — least of all the stale `owner` — may be
158
+ * paid for it. Any other failure to find the holder is an `rpc-error`.
159
+ */
160
+ export async function nftHolder(reader, wasm, canonical, mint, input) {
161
+ const mintKey = encodeBase58(mint);
162
+ const largest = (await reader.rpc("getTokenLargestAccounts", [
163
+ mintKey,
164
+ { commitment: "confirmed" },
165
+ ]));
166
+ const holders = (largest?.value ?? []).filter((a) => a.amount === "1");
167
+ if (holders.length !== 1) {
168
+ const m = await reader.accountInfo(mintKey);
169
+ if (m &&
170
+ m.owner === SPL_TOKEN_PROGRAM &&
171
+ m.data.length >= MINT_MIN_LEN &&
172
+ readU64(m.data, MINT_SUPPLY_OFFSET) === 0n) {
173
+ throw new ResolveError("not-found", `@${canonical}'s NFT has been burned — the name has no holder`, input, "nft-burned");
174
+ }
175
+ throw new ResolveError("rpc-error", `@${canonical} is tokenized but its NFT has no single holder`, input);
176
+ }
177
+ const holdingAccount = holders[0].address;
178
+ const t = await reader.accountInfo(holdingAccount);
179
+ if (!t ||
180
+ t.owner !== SPL_TOKEN_PROGRAM ||
181
+ t.data.length < TOKEN_ACCOUNT_MIN_LEN ||
182
+ !bytesEqual(t.data.slice(0, 32), mint) ||
183
+ readU64(t.data, 64) !== 1n) {
184
+ throw new ResolveError("rpc-error", `@${canonical} is tokenized but its NFT holder account is malformed`, input);
185
+ }
186
+ const holder = t.data.slice(32, 64);
187
+ const ata = wasm.deriveAssociatedTokenAccount(holder, mint);
188
+ const inAta = ata !== null && encodeBase58(ata) === holdingAccount;
189
+ return { address: encodeBase58(holder), verification: inAta ? "verified" : "unverified" };
190
+ }