@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.
- package/LICENSE +201 -0
- package/README.md +152 -0
- package/package.json +51 -0
- package/src/PingDirectory.js +1110 -0
- package/src/constants.js +103 -0
- package/src/deserialize.js +322 -0
- package/src/disc.js +76 -0
- package/src/encoding.js +130 -0
- package/src/fees.js +102 -0
- package/src/format.js +149 -0
- package/src/identicon.js +364 -0
- package/src/index.js +58 -0
- package/src/ix/admin.js +351 -0
- package/src/ix/identity.js +418 -0
- package/src/ix/index.js +10 -0
- package/src/ix/lock.js +63 -0
- package/src/ix/marketplace.js +198 -0
- package/src/ix/photo.js +173 -0
- package/src/ix/pro.js +91 -0
- package/src/ix/revoke.js +41 -0
- package/src/ix/shop.js +322 -0
- package/src/pda.js +75 -0
- package/src/wallet.js +189 -0
|
@@ -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
|
+
}
|