@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 +258 -8
- package/dist/accounts.d.ts +93 -0
- package/dist/accounts.js +190 -0
- package/dist/agent.d.ts +186 -0
- package/dist/agent.js +213 -0
- package/dist/attestation.d.ts +226 -0
- package/dist/attestation.js +290 -0
- package/dist/clearRecords.d.ts +60 -0
- package/dist/clearRecords.js +69 -0
- package/dist/control.d.ts +201 -0
- package/dist/control.js +316 -0
- package/dist/delegate.d.ts +153 -0
- package/dist/delegate.js +166 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +66 -179
- package/dist/integrator.d.ts +117 -0
- package/dist/integrator.js +166 -0
- package/dist/lock.d.ts +180 -0
- package/dist/lock.js +211 -0
- package/dist/recordCount.d.ts +98 -0
- package/dist/recordCount.js +114 -0
- package/dist/recordWrite.d.ts +136 -0
- package/dist/recordWrite.js +228 -0
- package/dist/records.d.ts +130 -0
- package/dist/records.js +195 -0
- package/dist/register.d.ts +119 -0
- package/dist/register.js +183 -0
- package/dist/subname.d.ts +282 -0
- package/dist/subname.js +371 -0
- package/dist/textRecords.d.ts +259 -0
- package/dist/textRecords.js +368 -0
- package/dist/voucher.d.ts +130 -0
- package/dist/voucher.js +185 -0
- package/dist/x402.d.ts +107 -0
- package/dist/x402.js +76 -0
- package/package.json +9 -1
- package/schema/agent-manifest.json +91 -0
- package/wasm/x1_resolve_wasm.wasm +0 -0
|
@@ -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
|
+
}
|