@microslop/ping-directory-sdk 0.1.5

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.

Potentially problematic release.


This version of @microslop/ping-directory-sdk might be problematic. Click here for more details.

@@ -0,0 +1,1110 @@
1
+ // PingDirectory — high-level facade.
2
+ //
3
+ // Most callers want "submit this op" rather than "build me an
4
+ // instruction". This wraps each ix builder into a method that:
5
+ // - resolves any required on-chain reads (config.next_user_id,
6
+ // username_user_id_map, bound ed25519 pubkey for freeze checks, etc.)
7
+ // - assembles + signs + sends a Transaction
8
+ // - returns the tx signature
9
+
10
+ import {
11
+ Transaction, sendAndConfirmTransaction, Connection, PublicKey,
12
+ } from '@solana/web3.js';
13
+ import * as ix from './ix/index.js';
14
+ import {
15
+ findConfigPDA, findUsernamePDA, findUidMapPDA, findEd25519AccountPDA,
16
+ findSaleListingPDA, findProfilePhotoPDA, findInventoryPDA,
17
+ findReferralBalancePDA, findShopItemPDA,
18
+ findModerationPDA, findTreasuryWalletPDA,
19
+ } from './pda.js';
20
+ import {
21
+ deserializeConfig, deserializeUsernameAccount, deserializeUsernameUserIdMap,
22
+ deserializeSaleListing, deserializeProfilePhoto, deserializeProfilePhotoMeta,
23
+ PROFILE_PHOTO_META_LEN, deserializeInventory,
24
+ deserializeReferralBalance, deserializeShopItem, deserializeEd25519Account,
25
+ deserializeModeration, deserializeTreasuryWallet,
26
+ } from './deserialize.js';
27
+ import { rentExempt } from './fees.js';
28
+ import { PROGRAM_ID, Cluster, ClusterWs, DEFAULT_RPC } from './constants.js';
29
+ import { sha256, accDisc } from './disc.js';
30
+ import { b58encode } from './format.js';
31
+
32
+ // Local helper: hex string -> Uint8Array (typically 32 bytes for ed25519).
33
+ function hexToBytes(hex) {
34
+ if (typeof hex !== 'string') return hex;
35
+ const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
36
+ if (clean.length % 2 !== 0) throw new Error('hex string has odd length');
37
+ const out = new Uint8Array(clean.length / 2);
38
+ for (let i = 0; i < out.length; i++) {
39
+ out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ // Cross-env base64 (node Buffer || browser btoa/atob) — mirrors the
45
+ // pattern in `parseEvent`. Used by the detached-transaction helpers.
46
+ function toBase64(bytes) {
47
+ if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64');
48
+ let s = '';
49
+ for (const b of bytes) s += String.fromCharCode(b);
50
+ return btoa(s);
51
+ }
52
+ function fromBase64(b64) {
53
+ if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(b64, 'base64'));
54
+ return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
55
+ }
56
+
57
+ export class PingDirectory {
58
+ /**
59
+ * Accepts either:
60
+ * - a `Connection` instance (existing behavior — preserved for tests
61
+ * and SDK consumers that already manage their own Connection), or
62
+ * - a config bag `{ rpcUrl, fetch?, beforeRequest?, commitment? }` —
63
+ * the SDK builds a Connection internally and (when `fetch` or
64
+ * `beforeRequest` is supplied) wires a custom fetch chain so the
65
+ * host app can route RPC through Tauri / a privacy proxy and
66
+ * attach per-request `X-Ping-*` auth headers.
67
+ *
68
+ * The two shapes are distinguished by duck-typing on common Connection
69
+ * methods (`getAccountInfo` / `sendRawTransaction`) rather than
70
+ * `instanceof`, since the host app may pass a Connection from a
71
+ * different copy of `@solana/web3.js` (npm dedupe / monorepo
72
+ * hoisting), in which case `instanceof Connection` would be false
73
+ * even though the object is functionally a Connection.
74
+ */
75
+ constructor(arg) {
76
+ if (arg && typeof arg === 'object'
77
+ && typeof arg.getAccountInfo === 'function'
78
+ && typeof arg.sendRawTransaction === 'function') {
79
+ // Connection-arg shape (original)
80
+ this.connection = arg;
81
+ return;
82
+ }
83
+
84
+ // Config-bag shape. Resolve cluster shorthand → rpc/ws. Explicit rpcUrl /
85
+ // wsEndpoint always win; with neither rpcUrl nor cluster we default to the
86
+ // LOCALNET cluster (dev default — flip DEFAULT_CLUSTER in constants.js at
87
+ // launch). `cluster` accepts 'localnet' | 'mainnet' (case-insensitive).
88
+ const config = arg || {};
89
+ const clusterKey = config.cluster ? String(config.cluster).toUpperCase() : null;
90
+ const rpcUrl = config.rpcUrl || (clusterKey && Cluster[clusterKey]) || DEFAULT_RPC;
91
+ // WebSocket: explicit → the chosen cluster's → (if rpcUrl is a known cluster
92
+ // URL) that cluster's → undefined (web3.js derives wss://<host>/). The
93
+ // localnet's PubSub is reverse-proxied at /ws, not the derived root, so it
94
+ // needs this override; mainnet / 127.0.0.1 derive correctly on their own.
95
+ const wsEndpoint = config.wsEndpoint
96
+ || (clusterKey && ClusterWs[clusterKey])
97
+ || (rpcUrl === Cluster.LOCALNET ? ClusterWs.LOCALNET : undefined);
98
+ const commitment = config.commitment || 'confirmed';
99
+
100
+ // If the caller supplied a custom `fetch` or a `beforeRequest` hook,
101
+ // wrap them into a single fetch function and pass it to Connection
102
+ // via its `fetch` option (web3.js threads this through to all RPC
103
+ // calls). `beforeRequest(headers, body)` is invoked synchronously
104
+ // before each request so the host app can mutate the headers
105
+ // object (e.g. inject `X-Ping-Pubkey`, signature, timestamp).
106
+ let connectionConfig = { commitment };
107
+ if (wsEndpoint) connectionConfig.wsEndpoint = wsEndpoint;
108
+ const userFetch = config.fetch
109
+ || (typeof fetch !== 'undefined' ? fetch.bind(globalThis) : null);
110
+
111
+ if (config.beforeRequest || config.fetch) {
112
+ if (!userFetch) {
113
+ throw new Error('PingDirectory: no fetch implementation available — pass `fetch` in config');
114
+ }
115
+ const beforeRequest = config.beforeRequest;
116
+ const wrappedFetch = (input, init = {}) => {
117
+ // Normalize headers to a plain object the host can mutate.
118
+ const headers = Object.assign({}, init.headers || {});
119
+ const body = typeof init.body === 'string' ? init.body : '';
120
+ if (beforeRequest) {
121
+ try { beforeRequest(headers, body); }
122
+ catch (e) {
123
+ return Promise.reject(new Error('beforeRequest hook threw: ' + (e?.message ?? e)));
124
+ }
125
+ }
126
+ return userFetch(input, { ...init, headers });
127
+ };
128
+ connectionConfig = { commitment, fetch: wrappedFetch };
129
+ if (wsEndpoint) connectionConfig.wsEndpoint = wsEndpoint;
130
+ }
131
+
132
+ this.connection = new Connection(rpcUrl, connectionConfig);
133
+ }
134
+
135
+ // ── State reads ───────────────────────────────────────────────────
136
+ async fetchConfig() {
137
+ const [pda] = findConfigPDA();
138
+ const acc = await this.connection.getAccountInfo(pda);
139
+ if (!acc) return null;
140
+ return deserializeConfig(acc.data);
141
+ }
142
+ async fetchUsername(username) {
143
+ const [pda] = findUsernamePDA(username);
144
+ const acc = await this.connection.getAccountInfo(pda);
145
+ if (!acc) return null;
146
+ return deserializeUsernameAccount(acc.data);
147
+ }
148
+ // Forward lookup name → full username + bound-key freeze state in two RPCs.
149
+ // Necessarily two-phase: the ed25519 PDA isn't known until UsernameAccount
150
+ // is fetched, so it can't be batched into one getMultipleAccountsInfo.
151
+ // Returns null if the username doesn't exist; otherwise the
152
+ // UsernameAccount struct extended with `{ compromised, compromisedAt }`
153
+ // (those two flags live on the bound Ed25519Account; everything else is
154
+ // already on UsernameAccount itself).
155
+ async fetchUsernameWithKeyState(username) {
156
+ const u = await this.fetchUsername(username);
157
+ if (!u) return null;
158
+ const k = await this.lookupKey(u.ed25519Pubkey);
159
+ return {
160
+ ...u,
161
+ compromised: k.compromised,
162
+ compromisedAt: k.compromisedAt,
163
+ };
164
+ }
165
+ async fetchUserIdMap(username) {
166
+ const [pda] = findUidMapPDA(username);
167
+ const acc = await this.connection.getAccountInfo(pda);
168
+ if (!acc) return null;
169
+ return deserializeUsernameUserIdMap(acc.data);
170
+ }
171
+ async fetchSaleListing(username) {
172
+ const [pda] = findSaleListingPDA(username);
173
+ const acc = await this.connection.getAccountInfo(pda);
174
+ if (!acc) return null;
175
+ return deserializeSaleListing(acc.data);
176
+ }
177
+
178
+ /**
179
+ * Discover every active SaleListing on chain and join it with the
180
+ * matching UsernameAccount so callers get `{ username, state, price,
181
+ * receiverWallet, sellerKind, listNonce, ed25519Pubkey, wallet, ... }`.
182
+ *
183
+ * Implementation: two `getProgramAccounts` calls — one filtered by
184
+ * the SaleListing account discriminator (small fixed-size rows) and
185
+ * one filtered by UsernameAccount. Each row carries its account
186
+ * pubkey, so we re-derive the SaleListing PDA from each username
187
+ * and intersect the two sets in JS. No per-listing follow-up RPCs.
188
+ *
189
+ * NOTE: `getProgramAccounts` is rate-limited / disabled on some
190
+ * mainnet RPCs. Fine for devnet + small deployments; an indexer is
191
+ * the right answer for production scale.
192
+ */
193
+ async fetchAllSaleListings() {
194
+ const SALE_DISC = accDisc('SaleListing');
195
+ const USERNAME_DISC = accDisc('UsernameAccount');
196
+
197
+ const [saleAccs, usernameAccs] = await Promise.all([
198
+ this.connection.getProgramAccounts(PROGRAM_ID, {
199
+ filters: [
200
+ { memcmp: { offset: 0, bytes: b58encode(SALE_DISC) } },
201
+ ],
202
+ }),
203
+ this.connection.getProgramAccounts(PROGRAM_ID, {
204
+ filters: [
205
+ { memcmp: { offset: 0, bytes: b58encode(USERNAME_DISC) } },
206
+ ],
207
+ }),
208
+ ]);
209
+
210
+ // Index listings by their account pubkey (= SaleListing PDA).
211
+ const listingByPda = new Map();
212
+ for (const { pubkey, account } of saleAccs) {
213
+ try {
214
+ listingByPda.set(pubkey.toBase58(), deserializeSaleListing(account.data));
215
+ } catch { /* skip corrupt rows */ }
216
+ }
217
+
218
+ // Walk usernames; for each, derive its SaleListing PDA and emit
219
+ // a joined row when one exists in the index above.
220
+ const rows = [];
221
+ for (const { account } of usernameAccs) {
222
+ let u;
223
+ try { u = deserializeUsernameAccount(account.data); }
224
+ catch { continue; }
225
+ const [salePda] = findSaleListingPDA(u.username);
226
+ const listing = listingByPda.get(salePda.toBase58());
227
+ if (!listing) continue;
228
+ rows.push({
229
+ username: u.username,
230
+ state: u.state,
231
+ usernameAccount: u,
232
+ price: listing.price,
233
+ receiverWallet: listing.receiverWallet,
234
+ sellerKind: listing.sellerKind,
235
+ listNonce: listing.listNonce,
236
+ });
237
+ }
238
+ return rows;
239
+ }
240
+
241
+ /**
242
+ * All UsernameAccounts whose `wallet` equals `walletPubkey` (base58 string
243
+ * or PublicKey). This is the only way to enumerate the names a *wallet*
244
+ * holds — the directory has no on-chain wallet→name index, so we scan every
245
+ * UsernameAccount and filter in JS (same pattern as `fetchAllSaleListings`;
246
+ * fine for devnet / small deployments — use an indexer at scale).
247
+ *
248
+ * Note: ACTIVE names permanently zero their `wallet`, so in practice this
249
+ * returns a wallet's RESERVED holdings.
250
+ *
251
+ * @returns {Promise<Array<{username: string, state: number, usernameAccount: object}>>}
252
+ */
253
+ async fetchUsernamesByWallet(walletPubkey) {
254
+ const walletB58 = typeof walletPubkey === 'string'
255
+ ? walletPubkey : walletPubkey.toBase58();
256
+ const USERNAME_DISC = accDisc('UsernameAccount');
257
+ const accs = await this.connection.getProgramAccounts(PROGRAM_ID, {
258
+ filters: [{ memcmp: { offset: 0, bytes: b58encode(USERNAME_DISC) } }],
259
+ });
260
+ const rows = [];
261
+ for (const { account } of accs) {
262
+ let u;
263
+ try { u = deserializeUsernameAccount(account.data); }
264
+ catch { continue; }
265
+ if (!u.wallet || u.wallet.toBase58() !== walletB58) continue;
266
+ rows.push({ username: u.username, state: u.state, usernameAccount: u });
267
+ }
268
+ return rows;
269
+ }
270
+ // ProfilePhoto and Inventory are keyed by username (cosmetics + photo
271
+ // travel with the name on transfer / buy_active).
272
+ async fetchProfilePhoto(username) {
273
+ const [pda] = findProfilePhotoPDA(username);
274
+ const acc = await this.connection.getAccountInfo(pda);
275
+ if (!acc) return null;
276
+ return deserializeProfilePhoto(acc.data);
277
+ }
278
+ // Header-only fetch (mime / dims / setAt) via dataSlice — skips the
279
+ // image blob entirely. Use `setAt` as a cheap "did the photo change?"
280
+ // probe before paying for the full `fetchProfilePhoto`. Returns null
281
+ // if no photo PDA exists.
282
+ async fetchProfilePhotoMeta(username) {
283
+ const [pda] = findProfilePhotoPDA(username);
284
+ const acc = await this.connection.getAccountInfo(pda, {
285
+ commitment: 'confirmed',
286
+ dataSlice: { offset: 0, length: PROFILE_PHOTO_META_LEN },
287
+ });
288
+ if (!acc) return null;
289
+ return deserializeProfilePhotoMeta(acc.data);
290
+ }
291
+
292
+ /**
293
+ * Batched resolve of usernames WITH their profile photos in one shot.
294
+ * For each username we read two PDAs — the UsernameAccount and the
295
+ * ProfilePhoto — so N usernames = 2N accounts, fetched via
296
+ * `getMultipleAccountsInfo` in chunks of 100 (Solana's hard per-call
297
+ * cap). Returns, per input username:
298
+ * { username, account, pfp }
299
+ * where `pfp` is `{ mime, width, height, setAt, size, bytes? } | null`
300
+ * (null when there's no finalized photo).
301
+ *
302
+ * Options:
303
+ * withBytes (default true) — include the raw image bytes. Set false
304
+ * to get photo METADATA only (via a 0..META_LEN dataSlice), which
305
+ * keeps the response tiny (~60B/photo) so you can resolve a large
306
+ * roster cheaply and lazy-load the heavy blobs on demand.
307
+ *
308
+ * ⚠️ Response size: with `withBytes` and ~10KB photos, a single 100-PDA
309
+ * chunk that lands 50 photos pulls ~500KB+ (base64-inflated in the JSON
310
+ * response). The 100-ACCOUNT cap is the hard limit; the practical limit
311
+ * is the RPC's max response size. Prefer `withBytes:false` for big
312
+ * rosters + a follow-up `fetchProfilePhoto` for the few you render.
313
+ */
314
+ async resolveUsernamesWithPfp(usernames, { withBytes = true } = {}) {
315
+ const uPdas = usernames.map((u) => findUsernamePDA(u)[0]);
316
+ const pPdas = usernames.map((u) => findProfilePhotoPDA(u)[0]);
317
+
318
+ // getMultipleAccountsInfo, chunked at the 100-account hard cap.
319
+ const fetchChunked = async (pdas, opts) => {
320
+ const out = new Array(pdas.length);
321
+ const CHUNK = 100;
322
+ for (let off = 0; off < pdas.length; off += CHUNK) {
323
+ const accs = await this.connection.getMultipleAccountsInfo(pdas.slice(off, off + CHUNK), opts);
324
+ for (let i = 0; i < accs.length; i++) out[off + i] = accs[i];
325
+ }
326
+ return out;
327
+ };
328
+
329
+ // Two passes (username vs photo) so the photo pass can `dataSlice` to
330
+ // the fixed header in metadata-only mode — a shared interleaved call
331
+ // can't slice one type without truncating the other. In withBytes
332
+ // mode the photo pass lands up to 100 FULL photos per call (~1MB at
333
+ // 10KB each) — that's the real response-size stressor.
334
+ const uAccs = await fetchChunked(uPdas, { commitment: 'confirmed' });
335
+ const pAccs = await fetchChunked(
336
+ pPdas,
337
+ withBytes
338
+ ? { commitment: 'confirmed' }
339
+ : { commitment: 'confirmed', dataSlice: { offset: 0, length: PROFILE_PHOTO_META_LEN } },
340
+ );
341
+
342
+ return usernames.map((u, idx) => {
343
+ const uAcc = uAccs[idx];
344
+ const pAcc = pAccs[idx];
345
+ let account = null;
346
+ if (uAcc) {
347
+ try { account = deserializeUsernameAccount(uAcc.data); } catch { /* skip */ }
348
+ }
349
+ let pfp = null;
350
+ if (pAcc) {
351
+ try {
352
+ const d = withBytes
353
+ ? deserializeProfilePhoto(pAcc.data)
354
+ : deserializeProfilePhotoMeta(pAcc.data);
355
+ if (d.finalized) {
356
+ pfp = {
357
+ mime: d.mime, width: d.width, height: d.height, setAt: d.setAt,
358
+ ...(withBytes ? { size: d.data.length, bytes: d.data } : {}),
359
+ };
360
+ }
361
+ } catch { /* unfinalized / undecodable — leave null */ }
362
+ }
363
+ return { username: u, account, pfp };
364
+ });
365
+ }
366
+
367
+ async fetchInventory(username) {
368
+ const [pda] = findInventoryPDA(username);
369
+ const acc = await this.connection.getAccountInfo(pda);
370
+ if (!acc) return null;
371
+ return deserializeInventory(acc.data);
372
+ }
373
+ async fetchReferralBalance(referrer) {
374
+ const [pda] = findReferralBalancePDA(referrer);
375
+ const acc = await this.connection.getAccountInfo(pda);
376
+ if (!acc) return null;
377
+ return deserializeReferralBalance(acc.data);
378
+ }
379
+ async fetchShopItem(itemId) {
380
+ const [pda] = findShopItemPDA(itemId);
381
+ const acc = await this.connection.getAccountInfo(pda);
382
+ if (!acc) return null;
383
+ return deserializeShopItem(acc.data);
384
+ }
385
+
386
+ /** Every ShopItem on chain (getProgramAccounts scan, like
387
+ * fetchAllSaleListings — fine for devnet / small catalogs, use an
388
+ * indexer at scale). */
389
+ async fetchAllShopItems() {
390
+ const SHOP_DISC = accDisc('ShopItem');
391
+ const accs = await this.connection.getProgramAccounts(PROGRAM_ID, {
392
+ filters: [{ memcmp: { offset: 0, bytes: b58encode(SHOP_DISC) } }],
393
+ });
394
+ const items = [];
395
+ for (const { account } of accs) {
396
+ try { items.push(deserializeShopItem(account.data)); }
397
+ catch { /* skip corrupt rows */ }
398
+ }
399
+ return items;
400
+ }
401
+
402
+ // ── ed25519 → bound-username + compromised flag (single PDA fetch) ──
403
+ async fetchEd25519(ed25519Pubkey) {
404
+ const [pda] = findEd25519AccountPDA(ed25519Pubkey);
405
+ const acc = await this.connection.getAccountInfo(pda);
406
+ if (!acc) return null;
407
+ return deserializeEd25519Account(acc.data);
408
+ }
409
+
410
+ // Treasury-wallet role lookup — null if the address isn't a treasury wallet.
411
+ async getTreasuryWallet(walletPubkey) {
412
+ const [pda] = findTreasuryWalletPDA(walletPubkey);
413
+ const acc = await this.connection.getAccountInfo(pda);
414
+ if (!acc) return null;
415
+ return deserializeTreasuryWallet(acc.data);
416
+ }
417
+ async isTreasuryWallet(walletPubkey) {
418
+ return (await this.getTreasuryWallet(walletPubkey)) != null;
419
+ }
420
+ // Raw companion Moderation PDA (or null if it was never created).
421
+ async fetchModeration(username) {
422
+ const [pda] = findModerationPDA(username);
423
+ const acc = await this.connection.getAccountInfo(pda);
424
+ if (!acc) return null;
425
+ return deserializeModeration(acc.data);
426
+ }
427
+ // Resolved visibility for a username. ABSENCE of the Moderation PDA
428
+ // means fully visible (migration-safe default). Returns the inverse of
429
+ // the stored `*_hidden` flags as `{ usernameVisible, pfpVisible,
430
+ // updatedAt, updatedBy }`.
431
+ async fetchVisibility(username) {
432
+ const m = await this.fetchModeration(username);
433
+ if (!m) {
434
+ return { usernameVisible: true, pfpVisible: true, updatedAt: null, updatedBy: null };
435
+ }
436
+ return {
437
+ usernameVisible: !m.usernameHidden,
438
+ pfpVisible: !m.pfpHidden,
439
+ updatedAt: m.updatedAt,
440
+ updatedBy: m.updatedBy,
441
+ };
442
+ }
443
+ // Default record returned for keys with no on-chain Ed25519Account PDA.
444
+ static #emptyKeyRecord() {
445
+ return {
446
+ username: null,
447
+ compromised: false,
448
+ compromisedAt: null,
449
+ };
450
+ }
451
+ static #recordFromAccount(d) {
452
+ return {
453
+ username: d.boundUsername,
454
+ compromised: d.compromisedAt > 0n,
455
+ compromisedAt: d.compromisedAt > 0n ? d.compromisedAt : null,
456
+ };
457
+ }
458
+ // Convenience: returns `{ username, compromised, compromisedAt }`.
459
+ // Returns sensible defaults if the PDA doesn't exist (key never seen
460
+ // on-chain). For richer per-username personal data (premium / equipped /
461
+ // referrer / account_type), call `fetchUsername` — that data lives on
462
+ // UsernameAccount, not Ed25519Account.
463
+ async lookupKey(ed25519Pubkey) {
464
+ const acc = await this.fetchEd25519(ed25519Pubkey);
465
+ if (!acc) return PingDirectory.#emptyKeyRecord();
466
+ return PingDirectory.#recordFromAccount(acc);
467
+ }
468
+ // Batched lookup. One getMultipleAccountsInfo call per chunk of 100.
469
+ // Each record carries `{ pubkey, username, compromised, compromisedAt }`.
470
+ async lookupKeys(pubkeys) {
471
+ const pdas = pubkeys.map((p) => findEd25519AccountPDA(p)[0]);
472
+ const out = new Array(pubkeys.length);
473
+ const CHUNK = 100;
474
+ for (let off = 0; off < pdas.length; off += CHUNK) {
475
+ const slice = pdas.slice(off, off + CHUNK);
476
+ const accs = await this.connection.getMultipleAccountsInfo(slice);
477
+ for (let i = 0; i < accs.length; i++) {
478
+ const idx = off + i;
479
+ const acc = accs[i];
480
+ if (!acc) {
481
+ out[idx] = { pubkey: pubkeys[idx], ...PingDirectory.#emptyKeyRecord() };
482
+ } else {
483
+ const d = deserializeEd25519Account(acc.data);
484
+ out[idx] = { pubkey: pubkeys[idx], ...PingDirectory.#recordFromAccount(d) };
485
+ }
486
+ }
487
+ }
488
+ return out;
489
+ }
490
+ // Convenience flag-only check.
491
+ async isCompromised(ed25519Pubkey) {
492
+ const a = await this.fetchEd25519(ed25519Pubkey);
493
+ return !!a && a.compromisedAt > 0n;
494
+ }
495
+
496
+ // ── Legacy-compat reverse-lookup helpers (pubkey hex → username) ────
497
+ //
498
+ // These mirror the legacy SDK API the client still calls. They are
499
+ // thin shims over `lookupKey` / `lookupKeys` / `fetchUsername`, which
500
+ // already do the right on-chain reads against the current schema (the
501
+ // ed25519 → bound-username mapping moved from the old ReverseLookup
502
+ // PDA onto Ed25519Account.boundUsername).
503
+
504
+ /**
505
+ * Look up the username currently bound to a single ed25519 pubkey
506
+ * (32-byte hex). Returns the username string, or `null` if the key
507
+ * has never been bound on chain.
508
+ */
509
+ async lookupUsername(pubkeyHex) {
510
+ if (!pubkeyHex) return null;
511
+ try {
512
+ const rec = await this.lookupKey(hexToBytes(pubkeyHex));
513
+ return rec?.username ?? null;
514
+ } catch {
515
+ return null;
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Batched reverse lookup: ed25519 hex pubkeys → `{ pubkeyHex: username }`.
521
+ * One `getMultipleAccountsInfo` round-trip per chunk of 100 (the same
522
+ * limit `lookupKeys` enforces). Pubkeys with no bound username (or
523
+ * bad input) are simply omitted from the returned map.
524
+ */
525
+ async fetchUsernamesBatch(pubkeysHex) {
526
+ if (!Array.isArray(pubkeysHex) || pubkeysHex.length === 0) return {};
527
+ // Convert + filter; preserve original hex strings as map keys.
528
+ const decoded = [];
529
+ const validHex = [];
530
+ for (const h of pubkeysHex) {
531
+ try {
532
+ decoded.push(hexToBytes(h));
533
+ validHex.push(h);
534
+ } catch { /* skip malformed */ }
535
+ }
536
+ if (decoded.length === 0) return {};
537
+ const records = await this.lookupKeys(decoded);
538
+ const out = {};
539
+ for (let i = 0; i < records.length; i++) {
540
+ const u = records[i]?.username;
541
+ if (u) out[validHex[i]] = u;
542
+ }
543
+ return out;
544
+ }
545
+
546
+ /** Alias kept for legacy callers — same contract as `fetchUsernamesBatch`. */
547
+ async lookupUsernames(pubkeysHex) {
548
+ return this.fetchUsernamesBatch(pubkeysHex);
549
+ }
550
+
551
+ /**
552
+ * Combined initial-state load: for an ed25519 hex pubkey, return the
553
+ * bound username (if any) plus its full UsernameAccount.
554
+ * Either field is `null` when the key isn't bound. Two RPCs in the
555
+ * happy path (one for the Ed25519Account PDA, one for UsernameAccount).
556
+ */
557
+ async fetchInitialState(pubkeyHex) {
558
+ if (!pubkeyHex) return { username: null, account: null };
559
+ let username = null;
560
+ try {
561
+ const rec = await this.lookupKey(hexToBytes(pubkeyHex));
562
+ username = rec?.username ?? null;
563
+ } catch { /* malformed hex → unbound */ }
564
+ if (!username) return { username: null, account: null };
565
+ const account = await this.fetchUsername(username).catch(() => null);
566
+ return { username, account };
567
+ }
568
+
569
+ /**
570
+ * SOL balance (lamports) for any address. Accepts either a `PublicKey`
571
+ * or a base58 string for parity with the legacy `getBalance(addressB58)`.
572
+ */
573
+ async getBalance(pubkey) {
574
+ const key = pubkey instanceof PublicKey ? pubkey : new PublicKey(pubkey);
575
+ return this.connection.getBalance(key);
576
+ }
577
+
578
+ /**
579
+ * Withdrawable treasury balance, in lamports (`bigint`).
580
+ *
581
+ * Subtracts the rent-exempt minimum AND `total_referral_pending`
582
+ * (referrers' yet-to-be-claimed cuts) from the Config PDA's lamports
583
+ * — this matches `withdraw_treasury`'s on-chain ceiling. Returns `0n`
584
+ * if the Config PDA doesn't exist or the math goes negative.
585
+ */
586
+ async fetchTreasuryBalance() {
587
+ const [pda] = findConfigPDA();
588
+ const acc = await this.connection.getAccountInfo(pda);
589
+ if (!acc) return 0n;
590
+ const totalLamports = BigInt(acc.lamports);
591
+ const rentMin = BigInt(rentExempt(acc.data.length));
592
+ let referralPending = 0n;
593
+ try {
594
+ const cfg = deserializeConfig(acc.data);
595
+ // `totalReferralPending` is a BigInt per `Reader.u64`.
596
+ referralPending = BigInt(cfg.totalReferralPending ?? 0n);
597
+ } catch { /* config parse failure → assume 0 reservation */ }
598
+ const w = totalLamports - rentMin - referralPending;
599
+ return w > 0n ? w : 0n;
600
+ }
601
+
602
+ /**
603
+ * Resolve a username to its on-chain referrer pubkey (or `null` if
604
+ * the username doesn't exist or registered without a referrer).
605
+ *
606
+ * Pure read against UsernameAccount; cheap.
607
+ */
608
+ async resolveReferrer(username) {
609
+ const u = await this.fetchUsername(username);
610
+ return u?.referrer ?? null;
611
+ }
612
+
613
+ // ── Program-log subscription (raw, low-level) ─────────────────────
614
+ //
615
+ // Anchor `#[event]`s are emitted into transaction logs as
616
+ // `Program data: <base64>`
617
+ // where the base64 payload is `[8-byte event disc][borsh fields]`.
618
+ // (Plain `msg!()` calls produce `Program log:` lines instead — those
619
+ // carry no structured event payload.)
620
+ //
621
+ // Full Anchor-style event decoding requires an event registry that
622
+ // maps each 8-byte discriminator to a typed Borsh schema. To stay
623
+ // dependency-free, this SDK exposes the raw subscription + a
624
+ // `parseEvent(line)` helper that recognises and base64-decodes the
625
+ // `Program data:` lines. Callers that need typed events can match
626
+ // on `event.disc` (first 8 bytes of `event.data`) and Borsh-decode
627
+ // the tail themselves using the `deserialize.js` Reader pattern.
628
+ /**
629
+ * Subscribe to all program logs. `handler` is called once per log
630
+ * line that originated from the Ping Directory program. The handler
631
+ * receives `(eventName, payload)`:
632
+ *
633
+ * - For Anchor `#[event]` emissions, `eventName === 'data'` and
634
+ * `payload === { disc: Uint8Array(8), data: Uint8Array(...),
635
+ * raw: '<base64>', signature, slot, err }`.
636
+ * - For plain `msg!()` lines, `eventName === 'log'` and
637
+ * `payload === { line: '<text>', signature, slot, err }`.
638
+ *
639
+ * Returns `{ unsubscribe(): Promise<void> }`.
640
+ */
641
+ onEvent(handler) {
642
+ if (typeof handler !== 'function') {
643
+ throw new TypeError('PingDirectory.onEvent: handler must be a function');
644
+ }
645
+ const subId = this.connection.onLogs(
646
+ PROGRAM_ID,
647
+ (logsResp, ctx) => {
648
+ const { signature, err, logs } = logsResp;
649
+ const slot = ctx?.slot;
650
+ for (const line of logs ?? []) {
651
+ const parsed = PingDirectory.parseEvent(line);
652
+ if (parsed.kind === 'data') {
653
+ try {
654
+ handler('data', {
655
+ disc: parsed.disc,
656
+ data: parsed.data,
657
+ raw: parsed.raw,
658
+ signature,
659
+ slot,
660
+ err,
661
+ });
662
+ } catch { /* handler errors must not kill the subscription */ }
663
+ } else if (parsed.kind === 'log') {
664
+ try {
665
+ handler('log', { line: parsed.text, signature, slot, err });
666
+ } catch { /* swallow */ }
667
+ }
668
+ }
669
+ },
670
+ 'confirmed',
671
+ );
672
+
673
+ return {
674
+ unsubscribe: async () => {
675
+ try {
676
+ const id = await Promise.resolve(subId);
677
+ await this.connection.removeOnLogsListener(id);
678
+ } catch { /* swallow — already torn down */ }
679
+ },
680
+ };
681
+ }
682
+
683
+ /**
684
+ * Pure helper: classify a single program log line.
685
+ * - `Program data: <b64>` → `{ kind: 'data', disc, data, raw }`
686
+ * - `Program log: <text>` → `{ kind: 'log', text }`
687
+ * - anything else → `{ kind: 'other', line }`
688
+ *
689
+ * Exposed as a static so callers can replay historical tx logs
690
+ * (e.g. from `getTransaction(...).meta.logMessages`) through the
691
+ * same parser used by the live subscription.
692
+ */
693
+ static parseEvent(line) {
694
+ if (typeof line !== 'string') return { kind: 'other', line };
695
+ const DATA_PREFIX = 'Program data: ';
696
+ const LOG_PREFIX = 'Program log: ';
697
+ if (line.startsWith(DATA_PREFIX)) {
698
+ const raw = line.slice(DATA_PREFIX.length);
699
+ try {
700
+ const bin = (typeof Buffer !== 'undefined')
701
+ ? new Uint8Array(Buffer.from(raw, 'base64'))
702
+ : Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
703
+ if (bin.length < 8) return { kind: 'other', line };
704
+ return {
705
+ kind: 'data',
706
+ disc: bin.slice(0, 8),
707
+ data: bin.slice(8),
708
+ raw,
709
+ };
710
+ } catch {
711
+ return { kind: 'other', line };
712
+ }
713
+ }
714
+ if (line.startsWith(LOG_PREFIX)) {
715
+ return { kind: 'log', text: line.slice(LOG_PREFIX.length) };
716
+ }
717
+ return { kind: 'other', line };
718
+ }
719
+
720
+ // ── Submit helper ────────────────────────────────────────────────
721
+ async submit(built, opts = {}) {
722
+ const { instructions, signers } = built;
723
+ const tx = new Transaction().add(...instructions);
724
+ const sig = await sendAndConfirmTransaction(
725
+ this.connection, tx, signers, { commitment: 'confirmed', ...opts },
726
+ );
727
+ return sig;
728
+ }
729
+
730
+ // ── Detached transaction objects ──────────────────────────────────
731
+ //
732
+ // `submit()` builds + signs + sends in one shot with local Keypairs.
733
+ // The methods below instead produce a *serialized, payer-unsigned*
734
+ // transaction so a different wallet / relay / sponsor can pay gas and
735
+ // submit it out-of-band. The ed25519 identity signature is already
736
+ // baked into the precompile instruction at build time, so no private
737
+ // key is needed at submit — only the fee payer's signature.
738
+ //
739
+ // Short-lived by nature: the embedded blockhash expires (~60-90s), the
740
+ // nonce PDA is single-use, and register/reserve bake `expectedUserId`
741
+ // which goes stale if another registration lands first. Build → submit
742
+ // promptly; don't treat the object as a durable voucher.
743
+
744
+ /**
745
+ * Serialize any ix-builder output into a base64 transaction whose fee
746
+ * payer is `feePayer` (a PublicKey) but which is NOT yet payer-signed.
747
+ * Partial-signs with any local Keypairs in `built.signers` that aren't
748
+ * the fee payer (the ed25519 precompile needs no tx signature). Pass
749
+ * `recentBlockhash` to avoid an RPC round trip (otherwise fetched).
750
+ */
751
+ async buildUnsignedTx(built, { feePayer, recentBlockhash } = {}) {
752
+ if (!feePayer) throw new Error('buildUnsignedTx: feePayer (PublicKey) required');
753
+ const fp = feePayer instanceof PublicKey ? feePayer : new PublicKey(feePayer);
754
+ const { instructions, signers = [] } = built;
755
+ const tx = new Transaction().add(...instructions);
756
+ tx.feePayer = fp;
757
+ tx.recentBlockhash = recentBlockhash
758
+ ?? (await this.connection.getLatestBlockhash('confirmed')).blockhash;
759
+ // Real, non-fee-payer Keypairs partial-sign now; their signatures
760
+ // survive serialization so the eventual payer only adds their own.
761
+ const local = signers.filter(
762
+ (s) => s && s.secretKey && s.publicKey && !s.publicKey.equals(fp),
763
+ );
764
+ if (local.length) tx.partialSign(...local);
765
+ const bytes = tx.serialize({ requireAllSignatures: false, verifySignatures: false });
766
+ return toBase64(bytes);
767
+ }
768
+
769
+ /**
770
+ * Build a ready-to-pay registration object. The username binds to
771
+ * `ed25519Keypair.publicKey` (the identity); `feePayer` only funds it —
772
+ * so a sponsor can pay for someone else's name without gaining any
773
+ * authority over it. Returns a base64 payer-unsigned transaction.
774
+ */
775
+ async buildRegisterTx({ username, ed25519Keypair, accountType = 0, referrer = null, feePayer, nonce, admin = null }) {
776
+ if (!feePayer) throw new Error('buildRegisterTx: feePayer required');
777
+ const cfg = await this.fetchConfig();
778
+ if (!cfg) throw new Error('Config not initialized');
779
+ const fp = feePayer instanceof PublicKey ? feePayer : new PublicKey(feePayer);
780
+ const built = ix.register({
781
+ username, ed25519Keypair, accountType, referrer,
782
+ expectedUserId: cfg.nextUserId, payer: { publicKey: fp }, nonce, admin,
783
+ });
784
+ return this.buildUnsignedTx(built, { feePayer: fp });
785
+ }
786
+
787
+ /**
788
+ * Relay/sponsor counterpart: sign a serialized (payer-unsigned) tx with
789
+ * a local fee-payer Keypair and send it. Wallet-adapter consumers
790
+ * instead `Transaction.from(...)` the bytes and sign via their adapter.
791
+ */
792
+ async submitSerializedTx(base64Tx, feePayerKeypair, opts = {}) {
793
+ const tx = Transaction.from(fromBase64(base64Tx));
794
+ tx.partialSign(feePayerKeypair);
795
+ const sig = await this.connection.sendRawTransaction(tx.serialize(), {
796
+ preflightCommitment: 'confirmed',
797
+ ...opts,
798
+ });
799
+ await this.connection.confirmTransaction(sig, 'confirmed');
800
+ return sig;
801
+ }
802
+
803
+ // ── High-level methods (resolve dynamic args, build, submit) ─────
804
+ async register({ username, ed25519Keypair, accountType = 0, referrer = null, payer, nonce, admin = null }) {
805
+ const cfg = await this.fetchConfig();
806
+ if (!cfg) throw new Error('Config not initialized');
807
+ const expectedUserId = cfg.nextUserId;
808
+ return this.submit(ix.register({
809
+ username, ed25519Keypair, accountType, referrer, expectedUserId, payer, nonce, admin,
810
+ }));
811
+ }
812
+
813
+ async reserveUsername({ username, accountType = 0, referrer = null, payer }) {
814
+ const cfg = await this.fetchConfig();
815
+ if (!cfg) throw new Error('Config not initialized');
816
+ const expectedUserId = cfg.nextUserId;
817
+ return this.submit(ix.reserveUsername({
818
+ username, accountType, referrer, expectedUserId, payer,
819
+ }));
820
+ }
821
+
822
+ // attachPubkey just binds the pubkey — referrer + accountType already
823
+ // live on UsernameAccount from reserve time.
824
+ async attachPubkey({ username, ed25519Keypair, currentWallet, nonce }) {
825
+ const map = await this.fetchUserIdMap(username);
826
+ if (!map) throw new Error(`UidMap missing for ${username}`);
827
+ return this.submit(ix.attachPubkey({
828
+ username, ed25519Keypair, currentWallet, userId: map.userId, nonce,
829
+ }));
830
+ }
831
+
832
+ async transferReserved(args) {
833
+ return this.submit(ix.transferReserved(args));
834
+ }
835
+ async unregisterReserved({ username, currentWallet }) {
836
+ const map = await this.fetchUserIdMap(username);
837
+ if (!map) throw new Error(`UidMap missing for ${username}`);
838
+ return this.submit(ix.unregisterReserved({ username, currentWallet, userId: map.userId }));
839
+ }
840
+
841
+ async updatePubkey(args) { return this.submit(ix.updatePubkey(args)); }
842
+ async transferUsername(args) { return this.submit(ix.transferUsername(args)); }
843
+ async unregisterActive({ username, ...rest }) {
844
+ const map = await this.fetchUserIdMap(username);
845
+ if (!map) throw new Error(`UidMap missing for ${username}`);
846
+ return this.submit(ix.unregisterActive({ username, userId: map.userId, ...rest }));
847
+ }
848
+ async setAccountType(args) { return this.submit(ix.setAccountType(args)); }
849
+
850
+ async listReserved(args) { return this.submit(ix.listReserved(args)); }
851
+ async listActive(args) { return this.submit(ix.listActive(args)); }
852
+ async cancelSale(args) { return this.submit(ix.cancelSale(args)); }
853
+ /** @deprecated 2026-05 — use cancelSale (LOW-25 rename). */
854
+ async cancelSaleReserved(args) { return this.submit(ix.cancelSale(args)); }
855
+
856
+ // Buy auto-resolves the slot's referrer so the right `ReferralBalance`
857
+ // PDA is threaded through. If the caller already supplied
858
+ // `referrerForUsername` (raw-API use) we skip the fetch.
859
+ async buyReserved(args) {
860
+ let referrerForUsername = args.referrerForUsername;
861
+ if (referrerForUsername === undefined) {
862
+ const u = await this.fetchUsername(args.username);
863
+ referrerForUsername = u?.referrer ?? null;
864
+ }
865
+ return this.submit(ix.buyReserved({ ...args, referrerForUsername }));
866
+ }
867
+ // Post-demote-on-list, every buy/cancel routes through the
868
+ // Reserved-state path. `buy` / `cancelSale` are state-agnostic
869
+ // aliases.
870
+ async buy(args) { return this.buyReserved(args); }
871
+
872
+ async requestUnlock(args) { return this.submit(ix.requestUnlock(args)); }
873
+ async lock(args) { return this.submit(ix.lock(args)); }
874
+
875
+ async initProfilePhoto(args) { return this.submit(ix.initProfilePhoto(args)); }
876
+ // writePhotoChunk needs the bound ed25519 (for the freeze-check PDA in
877
+ // its account list). Caller may pass it directly; otherwise we fetch.
878
+ async writePhotoChunk({ username, boundEd25519Pubkey, ...rest }) {
879
+ let bound = boundEd25519Pubkey;
880
+ if (bound == null) {
881
+ const u = await this.fetchUsername(username);
882
+ if (!u) throw new Error(`Username ${username} not registered`);
883
+ bound = u.ed25519Pubkey;
884
+ }
885
+ return this.submit(ix.writePhotoChunk({
886
+ username, boundEd25519Pubkey: bound, ...rest,
887
+ }));
888
+ }
889
+ async finalizeProfilePhoto(args) { return this.submit(ix.finalizeProfilePhoto(args)); }
890
+ // High-level: caller usually doesn't know `init_payer` (the wallet that
891
+ // originally allocated the photo PDA — rent on close goes back to it).
892
+ // Auto-resolve from the on-chain photo PDA when not provided.
893
+ async clearProfilePhoto({ username, initPayer, ...rest }) {
894
+ let resolvedInitPayer = initPayer;
895
+ if (resolvedInitPayer == null) {
896
+ const photo = await this.fetchProfilePhoto(username);
897
+ if (!photo) throw new Error(`No profile photo to clear for ${username}`);
898
+ resolvedInitPayer = photo.initPayer;
899
+ }
900
+ return this.submit(ix.clearProfilePhoto({
901
+ username, initPayer: resolvedInitPayer, ...rest,
902
+ }));
903
+ }
904
+
905
+ /**
906
+ * High-level: upload a profile photo end-to-end (init → chunks → finalize).
907
+ *
908
+ * The on-chain flow is 3-step (init allocates, chunks fill, finalize locks
909
+ * with a hash). Most callers want a single async call that handles all of
910
+ * it. This wraps the three primitives.
911
+ *
912
+ * Args:
913
+ * - `username` — Active username binding the photo
914
+ * - `imageBytes` — Uint8Array, ≤10_181 bytes (Solana realloc cap)
915
+ * - `mime` — 0=jpeg, 1=png, 2=webp, 3=gif (see PhotoMime)
916
+ * - `width`, `height` — px dimensions, 1..=8192 each
917
+ * - `ed25519Keypair` — bound ed25519 sign keypair (for init + finalize sigs)
918
+ * - `payer` — Solana Keypair that pays the per-mime fee + chunk rent
919
+ * - `chunkSize?` — bytes per write_photo_chunk tx, default 900
920
+ * (tx data cap is ~1232 bytes; 900 leaves room for
921
+ * ix discriminator + account list + base64 tx framing)
922
+ * - `onProgress?` — `(written: number, total: number) => void`
923
+ * Called after init (0/total), each successful chunk,
924
+ * and once more pre-finalize.
925
+ *
926
+ * Returns: `{ initSig, chunkSigs, finalizeSig }` for callers that want
927
+ * the individual signatures (e.g. for tx-history display).
928
+ *
929
+ * Idempotency: if `init` succeeds but a later chunk or finalize fails, the
930
+ * partial PDA persists. Caller should `clearProfilePhoto` to reset, then retry.
931
+ */
932
+ async setProfilePhoto({
933
+ username, imageBytes, mime, width, height,
934
+ ed25519Keypair, payer, chunkSize = 900, onProgress,
935
+ }) {
936
+ if (!imageBytes || imageBytes.length === 0) {
937
+ throw new Error('setProfilePhoto: imageBytes is empty');
938
+ }
939
+ const total = imageBytes.length;
940
+ onProgress?.(0, total);
941
+
942
+ // 0. If a previous upload partially completed (init succeeded but
943
+ // a later step failed), the photo PDA exists with `finalized: false`.
944
+ // Re-attempting `init` would collide with "already in use" (custom 0x0
945
+ // from the system program). Detect + auto-clear so the retry works.
946
+ // A FINALIZED photo we leave alone — caller must explicitly clear
947
+ // before re-uploading (otherwise an accidental double-click overwrites
948
+ // a healthy on-chain photo).
949
+ const existing = await this.fetchProfilePhoto(username);
950
+ if (existing) {
951
+ if (existing.finalized) {
952
+ throw new Error(
953
+ 'setProfilePhoto: a finalized photo already exists for this username. ' +
954
+ 'Call clearProfilePhoto first to replace it.',
955
+ );
956
+ }
957
+ // Stale partial upload — clear to reset. `clearProfilePhoto` routes
958
+ // rent back to the original `init_payer` (whoever opened the upload
959
+ // slot), which we read from the existing PDA.
960
+ await this.clearProfilePhoto({
961
+ username, ed25519Keypair, payer,
962
+ initPayer: existing.initPayer,
963
+ });
964
+ }
965
+
966
+ // 1. Allocate buffer + pay per-mime fee.
967
+ const initSig = await this.initProfilePhoto({
968
+ username, totalSize: total, mime, width, height,
969
+ ed25519Keypair, payer,
970
+ });
971
+
972
+ // 2. Stream chunks sequentially. Solana orders chunks within the
973
+ // photo PDA by `offset` so parallel writes would also work, but
974
+ // sequential is simpler and the per-tx round-trip is fast.
975
+ const chunkSigs = [];
976
+ const boundEd25519Pubkey = ed25519Keypair.publicKey;
977
+ for (let off = 0; off < total; off += chunkSize) {
978
+ const slice = imageBytes.subarray(off, Math.min(off + chunkSize, total));
979
+ const sig = await this.writePhotoChunk({
980
+ username, offset: off, chunk: slice, payer, boundEd25519Pubkey,
981
+ });
982
+ chunkSigs.push(sig);
983
+ onProgress?.(off + slice.length, total);
984
+ }
985
+
986
+ // 3. Finalize with SHA-256(imageBytes). Pure-JS sha256 (sync, no
987
+ // `crypto.subtle` dep — that's missing in non-secure-context
988
+ // browsers and some Tauri WebView configs).
989
+ const imageHash = sha256(imageBytes);
990
+ const finalizeSig = await this.finalizeProfilePhoto({
991
+ username, imageHash, mime, width, height,
992
+ ed25519Keypair, payer,
993
+ });
994
+ onProgress?.(total, total);
995
+
996
+ return { initSig, chunkSigs, finalizeSig };
997
+ }
998
+
999
+ async shopAddItem(args) { return this.submit(ix.shopAddItem(args)); }
1000
+ async shopUpdateItem(args) { return this.submit(ix.shopUpdateItem(args)); }
1001
+ async shopSetProOnly(args) { return this.submit(ix.shopSetProOnly(args)); }
1002
+ async shopSetActive(args) { return this.submit(ix.shopSetActive(args)); }
1003
+ // Ergonomic delist / relist over shop_set_active (active flag only;
1004
+ // price is untouched, unlike shop_update_item).
1005
+ async delistItem({ id, signer, admin = null }) { return this.submit(ix.shopSetActive({ id, active: false, signer, admin })); }
1006
+ async relistItem({ id, signer, admin = null }) { return this.submit(ix.shopSetActive({ id, active: true, signer, admin })); }
1007
+ // High-level: resolves bound key + UsernameAccount.referrer before
1008
+ // building the ix.
1009
+ async purchaseItem({ username, itemId, expectedPrice, payer }) {
1010
+ const u = await this.fetchUsername(username);
1011
+ if (!u) throw new Error(`Username ${username} not registered`);
1012
+ return this.submit(ix.purchaseItem({
1013
+ username, itemId, expectedPrice, payer,
1014
+ boundEd25519Pubkey: u.ed25519Pubkey,
1015
+ referrerForUsername: u.referrer,
1016
+ }));
1017
+ }
1018
+ // gift_item: owner/admin drops an item into a username's inventory for
1019
+ // free. Resolves the bound ed25519 key like purchaseItem. `payer` funds
1020
+ // the inventory rent (defaults to `signer`).
1021
+ async giftItem({ username, itemId, signer, payer = signer, admin = null }) {
1022
+ const u = await this.fetchUsername(username);
1023
+ if (!u) throw new Error(`Username ${username} not registered`);
1024
+ return this.submit(ix.giftItem({
1025
+ username, itemId, signer, payer, admin,
1026
+ boundEd25519Pubkey: u.ed25519Pubkey,
1027
+ }));
1028
+ }
1029
+ async equipItem(args) { return this.submit(ix.equipItem(args)); }
1030
+ async unequipSlot(args) { return this.submit(ix.unequipSlot(args)); }
1031
+ async equipProDefault(args) { return this.submit(ix.equipProDefault(args)); }
1032
+ async discardItem(args) { return this.submit(ix.discardItem(args)); }
1033
+
1034
+ async subscribePro({ username, months, payer }) {
1035
+ const u = await this.fetchUsername(username);
1036
+ if (!u) throw new Error(`Username ${username} not registered`);
1037
+ return this.submit(ix.subscribePro({
1038
+ username, months, payer,
1039
+ boundEd25519Pubkey: u.ed25519Pubkey,
1040
+ referrerForUsername: u.referrer,
1041
+ }));
1042
+ }
1043
+ // setPro writes to UsernameAccount; no ed25519_account in the
1044
+ // on-chain account list (admin/owner path is not freeze-gated).
1045
+ async setPro(args) { return this.submit(ix.setPro(args)); }
1046
+ async withdrawReferral(args) { return this.submit(ix.withdrawReferral(args)); }
1047
+
1048
+ async initialize(args) { return this.submit(ix.initialize(args)); }
1049
+ async addAdmin(args) { return this.submit(ix.addAdmin(args)); }
1050
+ async removeAdmin(args) { return this.submit(ix.removeAdmin(args)); }
1051
+ async proposeOwner(args) { return this.submit(ix.proposeOwner(args)); }
1052
+ async acceptOwner(args) { return this.submit(ix.acceptOwner(args)); }
1053
+ async cancelProposeOwner(args) { return this.submit(ix.cancelProposeOwner(args)); }
1054
+ async pauseRegistration(args) { return this.submit(ix.pauseRegistration(args)); }
1055
+ async pausePro(args) { return this.submit(ix.pausePro(args)); }
1056
+ async blocklistAdd(args) { return this.submit(ix.blocklistAdd(args)); }
1057
+ async blocklistRemove(args) { return this.submit(ix.blocklistRemove(args)); }
1058
+
1059
+ async addTreasuryWallet(args) { return this.submit(ix.addTreasuryWallet(args)); }
1060
+ async removeTreasuryWallet(args) { return this.submit(ix.removeTreasuryWallet(args)); }
1061
+ // Resolves whether the signer is a treasury wallet (vs the owner) and
1062
+ // passes the right optional PDA so a treasury wallet claims to itself.
1063
+ // Back-compat: `{ owner }` still works (owner path).
1064
+ async withdrawTreasury({ signer, owner, treasuryWalletPda } = {}) {
1065
+ const s = signer ?? owner;
1066
+ let twPda = treasuryWalletPda ?? null;
1067
+ if (!twPda && s) {
1068
+ const tw = await this.getTreasuryWallet(s.publicKey);
1069
+ if (tw) twPda = findTreasuryWalletPDA(s.publicKey)[0];
1070
+ }
1071
+ return this.submit(ix.withdrawTreasury({ signer: s, treasuryWalletPda: twPda }));
1072
+ }
1073
+ async setRegistrationFee(args) { return this.submit(ix.setRegistrationFee(args)); }
1074
+ async setProPriceMonthly(args) { return this.submit(ix.setProPriceMonthly(args)); }
1075
+ async setProPriceLifetime(args) { return this.submit(ix.setProPriceLifetime(args)); }
1076
+ async setSaleFee(args) { return this.submit(ix.setSaleFee(args)); }
1077
+ async setMinSalePrice(args) { return this.submit(ix.setMinSalePrice(args)); }
1078
+ async setGracePeriod(args) { return this.submit(ix.setGracePeriod(args)); }
1079
+ async setUnlockDelay(args) { return this.submit(ix.setUnlockDelay(args)); }
1080
+ async setUnlockWindow(args) { return this.submit(ix.setUnlockWindow(args)); }
1081
+ async setAutoUnfreezeDelay(args) { return this.submit(ix.setAutoUnfreezeDelay(args)); }
1082
+ async setProfilePhotoFee(args) { return this.submit(ix.setProfilePhotoFee(args)); }
1083
+
1084
+ // Sweep the dust ReferralBalance PDA at the zero-pubkey seed (owner-only).
1085
+ async adminCloseZeroReferral(args) { return this.submit(ix.adminCloseZeroReferral(args)); }
1086
+
1087
+ // ── Key revocation ───────────────────────────────────────────────
1088
+ async markCompromised(args) { return this.submit(ix.markCompromised(args)); }
1089
+
1090
+ // ── Permissionless time-gated unfreeze for compromised-key freezes ─
1091
+ // Resolves the bound ed25519 pubkey + user_id from chain state and
1092
+ // builds the `auto_unfreeze_username` ix. Anyone can call this once
1093
+ // `Config.auto_unfreeze_delay` has elapsed since `mark_compromised`.
1094
+ // The username's UsernameAccount flips to UNREGISTERED in place (the
1095
+ // PDA is permanent); inventory / photo / sale_listing PDAs (if any)
1096
+ // are closed in-place and their rent flows to `caller`. UidMap rent
1097
+ // also flows to `caller`. The slot is then immediately re-claimable.
1098
+ async autoUnfreezeUsername({ username, caller }) {
1099
+ const u = await this.fetchUsername(username);
1100
+ if (!u) throw new Error(`Username ${username} not registered`);
1101
+ const map = await this.fetchUserIdMap(username);
1102
+ if (!map) throw new Error(`UidMap missing for ${username}`);
1103
+ return this.submit(ix.autoUnfreezeUsername({
1104
+ username,
1105
+ frozenEd25519Pubkey: u.ed25519Pubkey,
1106
+ userId: map.userId,
1107
+ caller,
1108
+ }));
1109
+ }
1110
+ }