@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.
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Generic string-keyed metadata records (`TextRecord` accounts, #7376) —
3
+ * profile fields (`website`, `avatar`), social usernames (`com.discord`,
4
+ * `com.github`, `com.x`), and the future `contenthash` (#7382) — read WITH
5
+ * the universal staleness rule from docs/record-trust.md structurally
6
+ * enforced, plus instruction builders for `create_text_record` /
7
+ * `update_text_record` / `close_text_record`.
8
+ *
9
+ * Hand-rolled like the rest of this package — no Anchor client, no
10
+ * `@solana/web3.js` import (it stays an optional peer). Builders return the
11
+ * transport-neutral {@link BuiltInstruction} `delegate.ts` defines; PDAs are
12
+ * NOT derived here (see delegate.ts's module docs for why — derive
13
+ * `["text", handlePda, sha256(key)]` with your runtime's canonical
14
+ * `findProgramAddress`, hashing the key via {@link hashTextKey}).
15
+ *
16
+ * # The seed is `sha256(key)`; the plaintext key is ALSO stored
17
+ *
18
+ * A Solana PDA seed maxes out at 32 bytes; a text-record key is up to 64.
19
+ * The program hashes the key (sha256 — NOT keccak, so this module's
20
+ * {@link hashTextKey} is plain WebCrypto with no dependency) and uses the
21
+ * FULL 32-byte digest as the third seed. The account stores the key in
22
+ * plaintext too, so {@link fetchTextRecords} enumerates a handle's keys
23
+ * without preimage guessing — the hash is wire plumbing, the field is the
24
+ * truth, and the program guarantees they agree (the same instruction
25
+ * argument feeds both).
26
+ *
27
+ * # Values are RAW BYTES
28
+ *
29
+ * The program enforces only a length cap (1..=256), deliberately not UTF-8 —
30
+ * load-bearing for `contenthash`, whose value is a binary multicodec.
31
+ * Rendering is a reader decision: {@link textValueToString} decodes strictly
32
+ * and returns null for binary values, which callers hex-render or handle by
33
+ * key (`records.ts`'s `valueToAddress` convention).
34
+ *
35
+ * # Why every read function here demands `registeredAt`
36
+ *
37
+ * The identical PDA-reuse hazard `records.ts` documents: a `Handle`'s
38
+ * address is a pure function of the name, release + re-register lands at
39
+ * the SAME pubkey, and a previous owner's website/avatar/socials would
40
+ * otherwise be rendered as the new owner's. The mandatory read-side rule
41
+ * (docs/record-trust.md, the universal rule):
42
+ *
43
+ * text_record.updated_at >= handle.registered_at
44
+ *
45
+ * There is deliberately no way to decode or fetch a text record through
46
+ * this module without the handle's `registered_at` in hand.
47
+ *
48
+ * # `records_cleared_at` (#8139) — the SECOND, independent staleness rule
49
+ *
50
+ * `clear_records` lets an owner bulk-invalidate every record RIGHT NOW,
51
+ * without a transfer — text records too. The same functions therefore also
52
+ * demand the handle's `recordsClearedAt` (0 if never cleared, from
53
+ * `ParsedHandle`), and a text record is `stale` when EITHER
54
+ * `updated_at < registeredAt` OR `updated_at < recordsClearedAt`.
55
+ *
56
+ * # These records count (#7384)
57
+ *
58
+ * `create_text_record` increments — and `close_text_record` decrements —
59
+ * the SAME `record_count` Handle extension as the address-record family
60
+ * (`recordCount.ts`), so `release_handle` refuses (`HandleHasRecords`,
61
+ * 6044) while any counted record of EITHER kind remains. Close every text
62
+ * record before releasing a handle, and pass the HANDLE account writable to
63
+ * create/close (the builders here do).
64
+ */
65
+ import type { AddressLike, BuiltInstruction } from "./delegate.js";
66
+ import type { RpcFn } from "./accounts.js";
67
+ /** Seed prefix of a text-record PDA: `["text", handlePda, sha256(key)]`. */
68
+ export declare const TEXT_RECORD_SEED = "text";
69
+ /** Max key length, BYTES of UTF-8 (program error `BadTextKey`, 6047). */
70
+ export declare const TEXT_KEY_MAX_LEN = 64;
71
+ /** Max value length, bytes (program error `BadTextValue`, 6048). */
72
+ export declare const TEXT_VALUE_MAX_LEN = 256;
73
+ /** The handle's website URL. */
74
+ export declare const TEXT_KEY_WEBSITE = "website";
75
+ /** The handle's avatar image URL. */
76
+ export declare const TEXT_KEY_AVATAR = "avatar";
77
+ /** Discord username. */
78
+ export declare const TEXT_KEY_DISCORD = "com.discord";
79
+ /** GitHub username. */
80
+ export declare const TEXT_KEY_GITHUB = "com.github";
81
+ /** X (Twitter) username. */
82
+ export declare const TEXT_KEY_X = "com.x";
83
+ /** Decentralized-content hash (#7382) — value is BINARY (multicodec), not
84
+ * text; {@link textValueToString} correctly returns null for it. */
85
+ export declare const TEXT_KEY_CONTENTHASH = "contenthash";
86
+ /** Every well-known key this SDK names, frozen, for pickers/iteration. */
87
+ export declare const WELL_KNOWN_TEXT_KEYS: readonly string[];
88
+ /** Anchor account discriminator: `sha256("account:TextRecord")[0..8]`.
89
+ * Pinned (this SDK is zero-dependency and cannot assume WebCrypto SHA-256
90
+ * everywhere it runs); asserted against a re-derivation in the test suite
91
+ * so a typo can never silently pass. */
92
+ export declare const TEXT_RECORD_DISCRIMINATOR: Uint8Array;
93
+ /** Anchor instruction discriminator: `sha256("global:create_text_record")[0..8]`. */
94
+ export declare const CREATE_TEXT_RECORD_DISCRIMINATOR: Uint8Array;
95
+ /** Anchor instruction discriminator: `sha256("global:update_text_record")[0..8]`. */
96
+ export declare const UPDATE_TEXT_RECORD_DISCRIMINATOR: Uint8Array;
97
+ /** Anchor instruction discriminator: `sha256("global:close_text_record")[0..8]`. */
98
+ export declare const CLOSE_TEXT_RECORD_DISCRIMINATOR: Uint8Array;
99
+ /** `8 + TextRecord::INIT_SPACE` — the program allocates the full max
100
+ * capacity, so every TextRecord account is exactly this long, live fields
101
+ * packed at the front and the rest zero padding:
102
+ * 8 + 32 + (4 + 64) + (4 + 256) + 8 + 1. */
103
+ export declare const TEXT_RECORD_LEN = 377;
104
+ /**
105
+ * `sha256(utf8(key))` — the 32-byte third PDA seed for
106
+ * `["text", handlePda, hash]`. WebCrypto (`crypto.subtle`), so it is async
107
+ * and available in every modern browser, Node ≥ 18, and workers. The full
108
+ * digest is the seed, untruncated — mirrors the program's `text_key_hash`.
109
+ */
110
+ export declare function hashTextKey(key: string): Promise<Uint8Array>;
111
+ /** A decoded string-keyed metadata record, staleness already judged. */
112
+ export interface HandleTextRecord {
113
+ /** The TextRecord account's address, base58. */
114
+ readonly account: string;
115
+ /** The Handle account this record belongs to, base58. */
116
+ readonly handle: string;
117
+ /** The record's key, PLAINTEXT as stored on-chain (e.g. `"website"`). */
118
+ readonly key: string;
119
+ /** Raw value bytes as stored on-chain — see the module docs. */
120
+ readonly value: Uint8Array;
121
+ /** `value` as a UTF-8 string when it decodes strictly, else null (binary
122
+ * values — `contenthash` — and garbage alike). Never a lossy decode. */
123
+ readonly text: string | null;
124
+ /** `TextRecord.updated_at`, unix seconds. */
125
+ readonly updatedAt: bigint;
126
+ /**
127
+ * The rule docs/record-trust.md mandates: this record is only the current
128
+ * owner's if `updated_at >= handle.registered_at`. A stale record belongs
129
+ * to a previous, unrelated owner of the same name: it must never be
130
+ * rendered as this name's website/avatar/social, and only ever offered to
131
+ * the CURRENT owner as something to remove or overwrite.
132
+ */
133
+ readonly stale: boolean;
134
+ }
135
+ /**
136
+ * Render stored value bytes as text: a STRICT UTF-8 decode, or null when the
137
+ * bytes are not valid UTF-8 (binary values like `contenthash`). Callers
138
+ * needing to show binary values render hex themselves — never a replacement-
139
+ * character decode, which would corrupt silently.
140
+ */
141
+ export declare function textValueToString(value: Uint8Array): string | null;
142
+ /**
143
+ * Decode one TextRecord account.
144
+ *
145
+ * TextRecord account layout, after the 8-byte Anchor discriminator:
146
+ *
147
+ * handle: Pubkey(32) key: String (u32 len + bytes, max 64)
148
+ * value: Vec<u8> (u32 len + bytes, max 256) updated_at: i64(8) bump: u8(1)
149
+ *
150
+ * `registeredAt` is the owning Handle's `registered_at`, decoded by the
151
+ * caller from the Handle account — it decides `stale`. `recordsClearedAt` is
152
+ * that same Handle's `recordsClearedAt` (0 if never cleared, #8139), a
153
+ * SECOND independent staleness anchor. There is intentionally no overload
154
+ * without either (see the module docs).
155
+ *
156
+ * Returns null for anything that is not a TextRecord: wrong length, wrong
157
+ * discriminator, lengths that do not fit, or a key that is not valid UTF-8
158
+ * (the program's Borsh `String` guarantees it is, so that is not a
159
+ * TextRecord).
160
+ */
161
+ export declare function decodeTextRecord(raw: Uint8Array, account: string, registeredAt: bigint, recordsClearedAt: bigint): HandleTextRecord | null;
162
+ /** The text records the CURRENT owner actually has — `stale` ones excluded.
163
+ * This is the list to render on a profile and count. */
164
+ export declare function liveTextRecords(records: readonly HandleTextRecord[]): HandleTextRecord[];
165
+ /**
166
+ * Fetch every TextRecord account of a handle — one `getProgramAccounts`
167
+ * call, filtered by the RPC on size (377), the TextRecord discriminator at
168
+ * offset 0 and the handle pubkey at offset 8, then every byte re-checked
169
+ * locally (the node's filters are an optimisation, never the guarantee) —
170
+ * the exact shape of `fetchRecords`. Scoping the scan to the registry
171
+ * program id also IS the ownership check.
172
+ *
173
+ * `registeredAt` is `Handle.registered_at` as decoded from the Handle
174
+ * account the caller already has — the staleness rule needs it, and there
175
+ * is no variant of this function without it. `recordsClearedAt` is that same
176
+ * Handle's `recordsClearedAt` (0 if never cleared, #8139) — pass it through.
177
+ * Every record is returned, stale ones flagged, so an owner surface can show
178
+ * what a previous owner left behind; anything that renders a profile takes
179
+ * {@link liveTextRecords}.
180
+ */
181
+ export declare function fetchTextRecords(rpc: RpcFn, programId: string, handleAccount: string, registeredAt: bigint, recordsClearedAt: bigint): Promise<HandleTextRecord[]>;
182
+ /** The delegate-aware trailing accounts every text-record builder shares —
183
+ * the exact rules of `delegate.ts`'s module docs. */
184
+ interface RecordEditAuthorityParams {
185
+ /** A DELEGATE caller's `["delegate", handle]` PDA for the trailing
186
+ * named-optional slot. Omit as the owner/NFT holder. */
187
+ readonly recordDelegate?: AddressLike;
188
+ /** TOKENIZED handles, owner flow: the holder's ATA for the handle's NFT
189
+ * mint (the `remaining_accounts[0]` proof). When given WITHOUT
190
+ * `recordDelegate`, the "explicitly None" placeholder (the program id) is
191
+ * inserted ahead of it automatically. Omit for an untokenized owner. */
192
+ readonly holderTokenAccount?: AddressLike;
193
+ }
194
+ export interface CreateTextRecordParams extends RecordEditAuthorityParams {
195
+ /** The registry program id. */
196
+ readonly programId: AddressLike;
197
+ /** Pays the record's rent (and the handle's one-time record_count grow,
198
+ * when it is the first counted record). Signer. */
199
+ readonly payer: AddressLike;
200
+ /** The handle's current authority, or its record-editing delegate.
201
+ * Signer. */
202
+ readonly owner: AddressLike;
203
+ /** The `["handle", name]` PDA — passed WRITABLE (the record_count
204
+ * extension lives on it). */
205
+ readonly handle: AddressLike;
206
+ /** The `["text", handle, sha256(key)]` PDA — derive per the module docs,
207
+ * hashing the SAME key passed below with {@link hashTextKey}. */
208
+ readonly textRecord: AddressLike;
209
+ /** The record's key, plaintext (1..=64 bytes of UTF-8). */
210
+ readonly key: string;
211
+ /** The record's value, raw bytes (1..=256). */
212
+ readonly value: Uint8Array;
213
+ }
214
+ /**
215
+ * Build `create_text_record` — create the (handle, key) record, stamping
216
+ * `updated_at` from the Clock and incrementing the handle's record_count.
217
+ */
218
+ export declare function buildCreateTextRecordIx(p: CreateTextRecordParams): BuiltInstruction;
219
+ export interface UpdateTextRecordParams extends RecordEditAuthorityParams {
220
+ /** The registry program id. */
221
+ readonly programId: AddressLike;
222
+ /** The handle's current authority, or its record-editing delegate.
223
+ * Signer. */
224
+ readonly owner: AddressLike;
225
+ /** The `["handle", name]` PDA. */
226
+ readonly handle: AddressLike;
227
+ /** The `["text", handle, sha256(key)]` PDA of the record being updated. */
228
+ readonly textRecord: AddressLike;
229
+ /** The new value, raw bytes (1..=256). The key is immutable — changing a
230
+ * key is close + create. */
231
+ readonly value: Uint8Array;
232
+ }
233
+ /**
234
+ * Build `update_text_record` — replace the value and re-stamp `updated_at`
235
+ * (also how a record is re-adopted into a new ownership epoch).
236
+ */
237
+ export declare function buildUpdateTextRecordIx(p: UpdateTextRecordParams): BuiltInstruction;
238
+ export interface CloseTextRecordParams extends RecordEditAuthorityParams {
239
+ /** The registry program id. */
240
+ readonly programId: AddressLike;
241
+ /** The handle's current authority, or its record-editing delegate.
242
+ * Signer. */
243
+ readonly owner: AddressLike;
244
+ /** The `["handle", name]` PDA — passed WRITABLE (the record_count
245
+ * decrement lives on it). */
246
+ readonly handle: AddressLike;
247
+ /** The `["text", handle, sha256(key)]` PDA being closed. */
248
+ readonly textRecord: AddressLike;
249
+ /** Receives the closed account's rent — any account the caller chooses. */
250
+ readonly recipient: AddressLike;
251
+ }
252
+ /**
253
+ * Build `close_text_record` — close the record, rent to `recipient`,
254
+ * decrementing the handle's record_count. Close every text record before
255
+ * `release_handle` — for a counted handle that is enforced
256
+ * (`HandleHasRecords`), not advised.
257
+ */
258
+ export declare function buildCloseTextRecordIx(p: CloseTextRecordParams): BuiltInstruction;
259
+ export {};
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Generic string-keyed metadata records (`TextRecord` accounts, #7376) —
3
+ * profile fields (`website`, `avatar`), social usernames (`com.discord`,
4
+ * `com.github`, `com.x`), and the future `contenthash` (#7382) — read WITH
5
+ * the universal staleness rule from docs/record-trust.md structurally
6
+ * enforced, plus instruction builders for `create_text_record` /
7
+ * `update_text_record` / `close_text_record`.
8
+ *
9
+ * Hand-rolled like the rest of this package — no Anchor client, no
10
+ * `@solana/web3.js` import (it stays an optional peer). Builders return the
11
+ * transport-neutral {@link BuiltInstruction} `delegate.ts` defines; PDAs are
12
+ * NOT derived here (see delegate.ts's module docs for why — derive
13
+ * `["text", handlePda, sha256(key)]` with your runtime's canonical
14
+ * `findProgramAddress`, hashing the key via {@link hashTextKey}).
15
+ *
16
+ * # The seed is `sha256(key)`; the plaintext key is ALSO stored
17
+ *
18
+ * A Solana PDA seed maxes out at 32 bytes; a text-record key is up to 64.
19
+ * The program hashes the key (sha256 — NOT keccak, so this module's
20
+ * {@link hashTextKey} is plain WebCrypto with no dependency) and uses the
21
+ * FULL 32-byte digest as the third seed. The account stores the key in
22
+ * plaintext too, so {@link fetchTextRecords} enumerates a handle's keys
23
+ * without preimage guessing — the hash is wire plumbing, the field is the
24
+ * truth, and the program guarantees they agree (the same instruction
25
+ * argument feeds both).
26
+ *
27
+ * # Values are RAW BYTES
28
+ *
29
+ * The program enforces only a length cap (1..=256), deliberately not UTF-8 —
30
+ * load-bearing for `contenthash`, whose value is a binary multicodec.
31
+ * Rendering is a reader decision: {@link textValueToString} decodes strictly
32
+ * and returns null for binary values, which callers hex-render or handle by
33
+ * key (`records.ts`'s `valueToAddress` convention).
34
+ *
35
+ * # Why every read function here demands `registeredAt`
36
+ *
37
+ * The identical PDA-reuse hazard `records.ts` documents: a `Handle`'s
38
+ * address is a pure function of the name, release + re-register lands at
39
+ * the SAME pubkey, and a previous owner's website/avatar/socials would
40
+ * otherwise be rendered as the new owner's. The mandatory read-side rule
41
+ * (docs/record-trust.md, the universal rule):
42
+ *
43
+ * text_record.updated_at >= handle.registered_at
44
+ *
45
+ * There is deliberately no way to decode or fetch a text record through
46
+ * this module without the handle's `registered_at` in hand.
47
+ *
48
+ * # `records_cleared_at` (#8139) — the SECOND, independent staleness rule
49
+ *
50
+ * `clear_records` lets an owner bulk-invalidate every record RIGHT NOW,
51
+ * without a transfer — text records too. The same functions therefore also
52
+ * demand the handle's `recordsClearedAt` (0 if never cleared, from
53
+ * `ParsedHandle`), and a text record is `stale` when EITHER
54
+ * `updated_at < registeredAt` OR `updated_at < recordsClearedAt`.
55
+ *
56
+ * # These records count (#7384)
57
+ *
58
+ * `create_text_record` increments — and `close_text_record` decrements —
59
+ * the SAME `record_count` Handle extension as the address-record family
60
+ * (`recordCount.ts`), so `release_handle` refuses (`HandleHasRecords`,
61
+ * 6044) while any counted record of EITHER kind remains. Close every text
62
+ * record before releasing a handle, and pass the HANDLE account writable to
63
+ * create/close (the builders here do).
64
+ */
65
+ import { encodeBase58, decodeBase58_32 } from "./base58.js";
66
+ import { recordDelegateNonePlaceholder, recordDelegateSomeSlot } from "./delegate.js";
67
+ /** Seed prefix of a text-record PDA: `["text", handlePda, sha256(key)]`. */
68
+ export const TEXT_RECORD_SEED = "text";
69
+ /** Max key length, BYTES of UTF-8 (program error `BadTextKey`, 6047). */
70
+ export const TEXT_KEY_MAX_LEN = 64;
71
+ /** Max value length, bytes (program error `BadTextValue`, 6048). */
72
+ export const TEXT_VALUE_MAX_LEN = 256;
73
+ // Well-known keys (ENS-style: bare names for generic fields, reverse-DNS for
74
+ // service-specific ones). Any key up to 64 bytes is valid on-chain; these
75
+ // constants exist so every surface spells the common ones identically.
76
+ /** The handle's website URL. */
77
+ export const TEXT_KEY_WEBSITE = "website";
78
+ /** The handle's avatar image URL. */
79
+ export const TEXT_KEY_AVATAR = "avatar";
80
+ /** Discord username. */
81
+ export const TEXT_KEY_DISCORD = "com.discord";
82
+ /** GitHub username. */
83
+ export const TEXT_KEY_GITHUB = "com.github";
84
+ /** X (Twitter) username. */
85
+ export const TEXT_KEY_X = "com.x";
86
+ /** Decentralized-content hash (#7382) — value is BINARY (multicodec), not
87
+ * text; {@link textValueToString} correctly returns null for it. */
88
+ export const TEXT_KEY_CONTENTHASH = "contenthash";
89
+ /** Every well-known key this SDK names, frozen, for pickers/iteration. */
90
+ export const WELL_KNOWN_TEXT_KEYS = Object.freeze([
91
+ TEXT_KEY_WEBSITE,
92
+ TEXT_KEY_AVATAR,
93
+ TEXT_KEY_DISCORD,
94
+ TEXT_KEY_GITHUB,
95
+ TEXT_KEY_X,
96
+ TEXT_KEY_CONTENTHASH,
97
+ ]);
98
+ /** Anchor account discriminator: `sha256("account:TextRecord")[0..8]`.
99
+ * Pinned (this SDK is zero-dependency and cannot assume WebCrypto SHA-256
100
+ * everywhere it runs); asserted against a re-derivation in the test suite
101
+ * so a typo can never silently pass. */
102
+ export const TEXT_RECORD_DISCRIMINATOR = Uint8Array.from([
103
+ 177, 94, 37, 181, 209, 75, 179, 30,
104
+ ]);
105
+ /** Anchor instruction discriminator: `sha256("global:create_text_record")[0..8]`. */
106
+ export const CREATE_TEXT_RECORD_DISCRIMINATOR = Uint8Array.from([
107
+ 6, 129, 8, 35, 121, 55, 67, 149,
108
+ ]);
109
+ /** Anchor instruction discriminator: `sha256("global:update_text_record")[0..8]`. */
110
+ export const UPDATE_TEXT_RECORD_DISCRIMINATOR = Uint8Array.from([
111
+ 233, 174, 2, 216, 24, 80, 99, 192,
112
+ ]);
113
+ /** Anchor instruction discriminator: `sha256("global:close_text_record")[0..8]`. */
114
+ export const CLOSE_TEXT_RECORD_DISCRIMINATOR = Uint8Array.from([
115
+ 185, 179, 63, 132, 23, 60, 195, 219,
116
+ ]);
117
+ /** `8 + TextRecord::INIT_SPACE` — the program allocates the full max
118
+ * capacity, so every TextRecord account is exactly this long, live fields
119
+ * packed at the front and the rest zero padding:
120
+ * 8 + 32 + (4 + 64) + (4 + 256) + 8 + 1. */
121
+ export const TEXT_RECORD_LEN = 377;
122
+ /** Byte offset of `handle` within a TextRecord account: the 8-byte
123
+ * discriminator. */
124
+ const TEXT_RECORD_HANDLE_OFFSET = 8;
125
+ const SYSTEM_PROGRAM = "11111111111111111111111111111111";
126
+ function toBytes32(v, what) {
127
+ if (typeof v === "string") {
128
+ const b = decodeBase58_32(v);
129
+ if (!b)
130
+ throw new Error(`${what} is not a valid base58 address`);
131
+ return b;
132
+ }
133
+ if (v.length !== 32)
134
+ throw new Error(`${what} must be exactly 32 bytes`);
135
+ return v;
136
+ }
137
+ function toBase58(v, what) {
138
+ // Round-trip through bytes so a non-canonical base58 spelling and a byte
139
+ // input both come out identically.
140
+ return encodeBase58(toBytes32(v, what));
141
+ }
142
+ /**
143
+ * `sha256(utf8(key))` — the 32-byte third PDA seed for
144
+ * `["text", handlePda, hash]`. WebCrypto (`crypto.subtle`), so it is async
145
+ * and available in every modern browser, Node ≥ 18, and workers. The full
146
+ * digest is the seed, untruncated — mirrors the program's `text_key_hash`.
147
+ */
148
+ export async function hashTextKey(key) {
149
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
150
+ return new Uint8Array(digest);
151
+ }
152
+ /**
153
+ * Render stored value bytes as text: a STRICT UTF-8 decode, or null when the
154
+ * bytes are not valid UTF-8 (binary values like `contenthash`). Callers
155
+ * needing to show binary values render hex themselves — never a replacement-
156
+ * character decode, which would corrupt silently.
157
+ */
158
+ export function textValueToString(value) {
159
+ try {
160
+ return new TextDecoder("utf-8", { fatal: true }).decode(value);
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
166
+ /**
167
+ * Decode one TextRecord account.
168
+ *
169
+ * TextRecord account layout, after the 8-byte Anchor discriminator:
170
+ *
171
+ * handle: Pubkey(32) key: String (u32 len + bytes, max 64)
172
+ * value: Vec<u8> (u32 len + bytes, max 256) updated_at: i64(8) bump: u8(1)
173
+ *
174
+ * `registeredAt` is the owning Handle's `registered_at`, decoded by the
175
+ * caller from the Handle account — it decides `stale`. `recordsClearedAt` is
176
+ * that same Handle's `recordsClearedAt` (0 if never cleared, #8139), a
177
+ * SECOND independent staleness anchor. There is intentionally no overload
178
+ * without either (see the module docs).
179
+ *
180
+ * Returns null for anything that is not a TextRecord: wrong length, wrong
181
+ * discriminator, lengths that do not fit, or a key that is not valid UTF-8
182
+ * (the program's Borsh `String` guarantees it is, so that is not a
183
+ * TextRecord).
184
+ */
185
+ export function decodeTextRecord(raw, account, registeredAt, recordsClearedAt) {
186
+ if (raw.length !== TEXT_RECORD_LEN)
187
+ return null;
188
+ for (let i = 0; i < 8; i++) {
189
+ if (raw[i] !== TEXT_RECORD_DISCRIMINATOR[i])
190
+ return null;
191
+ }
192
+ const dv = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
193
+ let o = 8;
194
+ const handle = encodeBase58(raw.slice(o, o + 32));
195
+ o += 32;
196
+ const keyLen = dv.getUint32(o, true);
197
+ o += 4;
198
+ // `#[max_len(64)]` — and every fixed field after must still fit.
199
+ if (keyLen > TEXT_KEY_MAX_LEN || o + keyLen + 4 > raw.length)
200
+ return null;
201
+ const keyBytes = raw.slice(o, o + keyLen);
202
+ o += keyLen;
203
+ const valueLen = dv.getUint32(o, true);
204
+ o += 4;
205
+ if (valueLen > TEXT_VALUE_MAX_LEN || o + valueLen + 8 + 1 > raw.length)
206
+ return null;
207
+ const value = raw.slice(o, o + valueLen);
208
+ o += valueLen;
209
+ const updatedAt = dv.getBigInt64(o, true);
210
+ const key = textValueToString(keyBytes);
211
+ if (key === null)
212
+ return null; // Borsh Strings are always valid UTF-8
213
+ return {
214
+ account,
215
+ handle,
216
+ key,
217
+ value,
218
+ text: textValueToString(value),
219
+ updatedAt,
220
+ stale: updatedAt < registeredAt || updatedAt < recordsClearedAt,
221
+ };
222
+ }
223
+ /** The text records the CURRENT owner actually has — `stale` ones excluded.
224
+ * This is the list to render on a profile and count. */
225
+ export function liveTextRecords(records) {
226
+ return records.filter((r) => !r.stale);
227
+ }
228
+ /**
229
+ * Fetch every TextRecord account of a handle — one `getProgramAccounts`
230
+ * call, filtered by the RPC on size (377), the TextRecord discriminator at
231
+ * offset 0 and the handle pubkey at offset 8, then every byte re-checked
232
+ * locally (the node's filters are an optimisation, never the guarantee) —
233
+ * the exact shape of `fetchRecords`. Scoping the scan to the registry
234
+ * program id also IS the ownership check.
235
+ *
236
+ * `registeredAt` is `Handle.registered_at` as decoded from the Handle
237
+ * account the caller already has — the staleness rule needs it, and there
238
+ * is no variant of this function without it. `recordsClearedAt` is that same
239
+ * Handle's `recordsClearedAt` (0 if never cleared, #8139) — pass it through.
240
+ * Every record is returned, stale ones flagged, so an owner surface can show
241
+ * what a previous owner left behind; anything that renders a profile takes
242
+ * {@link liveTextRecords}.
243
+ */
244
+ export async function fetchTextRecords(rpc, programId, handleAccount, registeredAt, recordsClearedAt) {
245
+ const res = (await rpc("getProgramAccounts", [
246
+ programId,
247
+ {
248
+ encoding: "base64",
249
+ commitment: "confirmed",
250
+ filters: [
251
+ { dataSize: TEXT_RECORD_LEN },
252
+ { memcmp: { offset: 0, bytes: encodeBase58(TEXT_RECORD_DISCRIMINATOR) } },
253
+ { memcmp: { offset: TEXT_RECORD_HANDLE_OFFSET, bytes: handleAccount } },
254
+ ],
255
+ },
256
+ ]));
257
+ const out = [];
258
+ for (const a of res ?? []) {
259
+ const bin = atob(a.account.data[0]);
260
+ const raw = new Uint8Array(bin.length);
261
+ for (let i = 0; i < bin.length; i++)
262
+ raw[i] = bin.charCodeAt(i);
263
+ const decoded = decodeTextRecord(raw, a.pubkey, registeredAt, recordsClearedAt);
264
+ // Defence in depth: the memcmp filter should guarantee the handle
265
+ // match, but a wrong offset would silently attribute someone else's
266
+ // record to this handle. A malformed account is skipped, not fatal.
267
+ if (decoded && decoded.handle === handleAccount)
268
+ out.push(decoded);
269
+ }
270
+ out.sort((x, y) => x.key.localeCompare(y.key));
271
+ return out;
272
+ }
273
+ function pushAuthorityTail(keys, programId, p) {
274
+ if (p.recordDelegate !== undefined) {
275
+ keys.push(recordDelegateSomeSlot(p.recordDelegate));
276
+ }
277
+ else if (p.holderTokenAccount !== undefined) {
278
+ keys.push(recordDelegateNonePlaceholder(programId));
279
+ }
280
+ if (p.holderTokenAccount !== undefined) {
281
+ keys.push({
282
+ pubkey: toBase58(p.holderTokenAccount, "holderTokenAccount"),
283
+ isSigner: false,
284
+ isWritable: false,
285
+ });
286
+ }
287
+ }
288
+ /** Borsh `String`/`Vec<u8>`: u32 LE length + bytes. */
289
+ function encVec(bytes) {
290
+ const out = new Uint8Array(4 + bytes.length);
291
+ new DataView(out.buffer).setUint32(0, bytes.length, true);
292
+ out.set(bytes, 4);
293
+ return out;
294
+ }
295
+ function checkKey(key) {
296
+ const bytes = new TextEncoder().encode(key);
297
+ if (bytes.length === 0 || bytes.length > TEXT_KEY_MAX_LEN) {
298
+ throw new Error(`key must be 1..=${TEXT_KEY_MAX_LEN} bytes of UTF-8`);
299
+ }
300
+ return bytes;
301
+ }
302
+ function checkValue(value) {
303
+ if (value.length === 0 || value.length > TEXT_VALUE_MAX_LEN) {
304
+ throw new Error(`value must be 1..=${TEXT_VALUE_MAX_LEN} bytes`);
305
+ }
306
+ return value;
307
+ }
308
+ /**
309
+ * Build `create_text_record` — create the (handle, key) record, stamping
310
+ * `updated_at` from the Clock and incrementing the handle's record_count.
311
+ */
312
+ export function buildCreateTextRecordIx(p) {
313
+ const keyBytes = checkKey(p.key);
314
+ const value = checkValue(p.value);
315
+ const encKey = encVec(keyBytes);
316
+ const encValue = encVec(value);
317
+ const data = new Uint8Array(8 + encKey.length + encValue.length);
318
+ data.set(CREATE_TEXT_RECORD_DISCRIMINATOR, 0);
319
+ data.set(encKey, 8);
320
+ data.set(encValue, 8 + encKey.length);
321
+ const keys = [
322
+ { pubkey: toBase58(p.payer, "payer"), isSigner: true, isWritable: true },
323
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: false },
324
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: true },
325
+ { pubkey: toBase58(p.textRecord, "textRecord"), isSigner: false, isWritable: true },
326
+ { pubkey: SYSTEM_PROGRAM, isSigner: false, isWritable: false },
327
+ ];
328
+ pushAuthorityTail(keys, p.programId, p);
329
+ return { programId: toBase58(p.programId, "programId"), keys, data };
330
+ }
331
+ /**
332
+ * Build `update_text_record` — replace the value and re-stamp `updated_at`
333
+ * (also how a record is re-adopted into a new ownership epoch).
334
+ */
335
+ export function buildUpdateTextRecordIx(p) {
336
+ const value = checkValue(p.value);
337
+ const encValue = encVec(value);
338
+ const data = new Uint8Array(8 + encValue.length);
339
+ data.set(UPDATE_TEXT_RECORD_DISCRIMINATOR, 0);
340
+ data.set(encValue, 8);
341
+ const keys = [
342
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: false },
343
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: false },
344
+ { pubkey: toBase58(p.textRecord, "textRecord"), isSigner: false, isWritable: true },
345
+ ];
346
+ pushAuthorityTail(keys, p.programId, p);
347
+ return { programId: toBase58(p.programId, "programId"), keys, data };
348
+ }
349
+ /**
350
+ * Build `close_text_record` — close the record, rent to `recipient`,
351
+ * decrementing the handle's record_count. Close every text record before
352
+ * `release_handle` — for a counted handle that is enforced
353
+ * (`HandleHasRecords`), not advised.
354
+ */
355
+ export function buildCloseTextRecordIx(p) {
356
+ const keys = [
357
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: false },
358
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: true },
359
+ { pubkey: toBase58(p.textRecord, "textRecord"), isSigner: false, isWritable: true },
360
+ { pubkey: toBase58(p.recipient, "recipient"), isSigner: false, isWritable: true },
361
+ ];
362
+ pushAuthorityTail(keys, p.programId, p);
363
+ return {
364
+ programId: toBase58(p.programId, "programId"),
365
+ keys,
366
+ data: CLOSE_TEXT_RECORD_DISCRIMINATOR.slice(),
367
+ };
368
+ }