@w3lib/solana-batch-call 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,18 +1,86 @@
1
1
  import { base64 } from '@scure/base';
2
2
  import { getAssociatedTokenAddress, getMetadataAddress, isAddress } from './address.js';
3
3
  import { NATIVE_MINT, SOL_DECIMALS, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, clusterOfGenesis, } from './constants.js';
4
- import { formatAmount, parseMetaplexMetadata, parseMint, parseTokenAccount } from './layout.js';
5
- import { HttpError, RpcError } from './rpc.js';
4
+ import { formatUnits, parseMetaplexMetadata, parseMint, parseTokenAccount, parseTransferFeeConfig, } from './layout.js';
5
+ import { AllNodesFailedError, HttpError, RpcError, isBehind } from './rpc.js';
6
6
  import { NetworkMismatchError, resolveSource } from './source.js';
7
7
  import { DEFAULT_TOKEN_FIELDS, } from './types.js';
8
+ /** 节点高度不够时的重试间隔与总时长 */
9
+ const BEHIND_RETRY_INTERVAL = 500;
10
+ const BEHIND_RETRY_TIMEOUT = 10_000;
8
11
  const MAX_ACCOUNTS_PER_REQUEST = 100;
9
12
  /** 同时进行的 getMultipleAccounts 请求数上限 */
10
13
  const MAX_PARALLEL_ACCOUNT_REQUESTS = 3;
11
14
  /** #fetchAccounts 里没能读到(节点问题)的账户 */
12
15
  const FAILED = Symbol('failed');
13
- /** 可以降级处理的错误:网络 / HTTP 错误、节点限制(限频、需要 Key 等)、节点不在期望的网络上;其他(参数错误、程序 bug)照常抛出 */
16
+ /** 可以降级处理的错误:网络 / HTTP 错误、节点限制(限频、需要 Key 等)、节点不在期望的网络上、所有节点都失败;其他(参数错误、程序 bug)照常抛出 */
14
17
  function isRecoverable(err) {
15
- return err instanceof HttpError || (err instanceof RpcError && err.nodeFault) || err instanceof NetworkMismatchError;
18
+ return err instanceof HttpError || (err instanceof RpcError && err.nodeFault) || err instanceof NetworkMismatchError || err instanceof AllNodesFailedError;
19
+ }
20
+ /** 查询带上取消:已取消时不执行;中途取消时立即 reject,查询本身继续(结果丢弃) */
21
+ function abortable(signal, run) {
22
+ if (!signal) {
23
+ return run();
24
+ }
25
+ if (signal.aborted) {
26
+ return Promise.reject(signal.reason);
27
+ }
28
+ return new Promise((resolve, reject) => {
29
+ const onAbort = () => reject(signal.reason);
30
+ signal.addEventListener('abort', onAbort, { once: true });
31
+ run()
32
+ .then(resolve, reject)
33
+ .finally(() => signal.removeEventListener('abort', onAbort));
34
+ });
35
+ }
36
+ function sleep(ms, signal) {
37
+ return new Promise((resolve, reject) => {
38
+ if (signal?.aborted) {
39
+ reject(signal.reason);
40
+ return;
41
+ }
42
+ const timer = setTimeout(() => {
43
+ signal?.removeEventListener('abort', onAbort);
44
+ resolve();
45
+ }, ms);
46
+ const onAbort = () => {
47
+ clearTimeout(timer);
48
+ reject(signal?.reason);
49
+ };
50
+ signal?.addEventListener('abort', onAbort, { once: true });
51
+ });
52
+ }
53
+ const TOKEN_PROGRAMS = [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID];
54
+ // 用 Map:普通对象会让 'constructor' 等原型上的名字通过校验
55
+ const PROGRAM_ALIASES = new Map([
56
+ ['spl', TOKEN_PROGRAM_ID],
57
+ ['token-2022', TOKEN_2022_PROGRAM_ID],
58
+ [TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID],
59
+ [TOKEN_2022_PROGRAM_ID, TOKEN_2022_PROGRAM_ID],
60
+ ]);
61
+ /**
62
+ * 简写 / 程序地址转成程序地址(顺序固定为 Token、Token-2022)。
63
+ * 不认识的值和空数组报错,避免拼错或漏选时悄悄过滤掉全部代币
64
+ */
65
+ function resolvePrograms(tokenPrograms) {
66
+ if (tokenPrograms === undefined) {
67
+ return TOKEN_PROGRAMS;
68
+ }
69
+ if (tokenPrograms.length === 0) {
70
+ throw new Error(`tokenPrograms is empty (expected 'spl' and / or 'token-2022'; omit it to include both)`);
71
+ }
72
+ const wanted = new Set(tokenPrograms.map((value) => {
73
+ const program = PROGRAM_ALIASES.get(value);
74
+ if (!program) {
75
+ throw new Error(`Unknown token program: ${value} (expected 'spl', 'token-2022' or a token program id)`);
76
+ }
77
+ return program;
78
+ }));
79
+ return TOKEN_PROGRAMS.filter((program) => wanted.has(program));
80
+ }
81
+ /** 所属程序已知且不在允许范围内 */
82
+ function isExcluded(tokenProgram, allowed) {
83
+ return tokenProgram !== undefined && !allowed.includes(tokenProgram);
16
84
  }
17
85
  /** 按 mint 汇总后精度 0、数量 1 的持仓视为 NFT(ownerTokens 与 ownerNfts 共用) */
18
86
  function isNftHolding(holding) {
@@ -32,24 +100,109 @@ const NAME_TTL = 60 * 60 * 1000;
32
100
  const MAX_CACHED_TOKENS = 50_000;
33
101
  const tokenMetaCache = new Map();
34
102
  let clientIds = 0;
103
+ /** 缓存写入监听(persistTokenMetaCache 用) */
104
+ const cacheWriteListeners = new Set();
35
105
  /** 测试用:清空代币信息缓存 */
36
106
  export function resetTokenMetaCache() {
37
107
  tokenMetaCache.clear();
38
108
  }
109
+ const SHARED_SCOPES = new Set(['mainnet', 'devnet', 'testnet']);
110
+ /**
111
+ * 导出代币信息缓存(decimals / 所属程序 / name / symbol),用于持久化:刷新页面后导入,不必重查 decimals。
112
+ * 只导出按网络共享的条目(自定义节点且没指定 cluster 的客户端,缓存只在该客户端内,不导出)。
113
+ * maxEntries:只导出最近写入的这么多条
114
+ */
115
+ export function exportTokenMetaCache(maxEntries = Number.POSITIVE_INFINITY) {
116
+ const entries = [];
117
+ for (const [key, meta] of tokenMetaCache) {
118
+ if (SHARED_SCOPES.has(key.slice(0, key.indexOf(':')))) {
119
+ entries.push([key, { ...meta }]);
120
+ }
121
+ }
122
+ return { version: 1, entries: entries.slice(Math.max(0, entries.length - maxEntries)) };
123
+ }
124
+ /** 导入 exportTokenMetaCache() 的结果;格式不对的条目跳过,内存里已有的条目不覆盖 */
125
+ export function importTokenMetaCache(snapshot) {
126
+ const entries = snapshot?.entries;
127
+ if (snapshot?.version !== 1 || !Array.isArray(entries)) {
128
+ return;
129
+ }
130
+ for (const entry of entries) {
131
+ const meta = validMeta(entry);
132
+ if (meta && !tokenMetaCache.has(entry[0])) {
133
+ tokenMetaCache.set(entry[0], meta);
134
+ if (tokenMetaCache.size > MAX_CACHED_TOKENS) {
135
+ tokenMetaCache.delete(tokenMetaCache.keys().next().value);
136
+ }
137
+ }
138
+ }
139
+ }
140
+ function validMeta(entry) {
141
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string') {
142
+ return null;
143
+ }
144
+ const [key, raw] = entry;
145
+ const colon = key.indexOf(':');
146
+ if (!SHARED_SCOPES.has(key.slice(0, colon)) || !isAddress(key.slice(colon + 1)) || !raw || typeof raw !== 'object') {
147
+ return null;
148
+ }
149
+ const meta = {};
150
+ if (typeof raw.decimals === 'number' && Number.isInteger(raw.decimals) && raw.decimals >= 0 && raw.decimals <= 255)
151
+ meta.decimals = raw.decimals;
152
+ if (raw.tokenProgram === TOKEN_PROGRAM_ID || raw.tokenProgram === TOKEN_2022_PROGRAM_ID)
153
+ meta.tokenProgram = raw.tokenProgram;
154
+ if (typeof raw.namesAt === 'number' && (typeof raw.name === 'string' || raw.name === null) && (typeof raw.symbol === 'string' || raw.symbol === null)) {
155
+ meta.name = raw.name;
156
+ meta.symbol = raw.symbol;
157
+ meta.namesAt = raw.namesAt;
158
+ }
159
+ return Object.keys(meta).length ? meta : null;
160
+ }
39
161
  /**
40
- * Solana 批量读取客户端。
162
+ * 把代币信息缓存持久化到同步的键值存储(如 localStorage):调用时读入,之后缓存有写入就防抖(约 1 秒)整体写回。
163
+ * 返回停止函数(会立即写回还没写的变化)。读到格式不对的数据会丢弃;写入失败(如存储已满)忽略。
41
164
  *
42
165
  * ```ts
43
- * const sol = new SolanaClient() // 内置公共节点(mainnet)
44
- * const sol = new SolanaClient('https://my-rpc.example') // 自己的节点
45
- * const sol = new SolanaClient([connection, 'https://backup…']) // web3.js Connection + 备用节点
46
- *
47
- * await sol.balances(owner, [NATIVE_MINT, USDC])
48
- * await sol.tokens([USDC, PYUSD], { fields: ['name', 'symbol', 'decimals', 'supply'] })
166
+ * persistTokenMetaCache(localStorage) // 刷新页面后 decimals 不必重查
49
167
  * ```
50
- *
51
- * 同一 tick 内发起的 RPC 调用会合并成一个 JSON-RPC 批量请求;账户读取按 100 个一组用 getMultipleAccounts。
52
168
  */
169
+ export function persistTokenMetaCache(store, options = {}) {
170
+ const key = options.key ?? 'w3lib:solana-token-meta';
171
+ const maxEntries = options.maxEntries ?? 5000;
172
+ try {
173
+ const raw = store.getItem(key);
174
+ if (raw) {
175
+ importTokenMetaCache(JSON.parse(raw));
176
+ }
177
+ }
178
+ catch {
179
+ // 格式不对:丢弃
180
+ }
181
+ let timer = null;
182
+ const write = () => {
183
+ timer = null;
184
+ try {
185
+ store.setItem(key, JSON.stringify(exportTokenMetaCache(maxEntries)));
186
+ }
187
+ catch {
188
+ // 存储已满等:忽略
189
+ }
190
+ };
191
+ const onWrite = () => {
192
+ if (!timer) {
193
+ timer = setTimeout(write, 1000);
194
+ timer.unref?.();
195
+ }
196
+ };
197
+ cacheWriteListeners.add(onWrite);
198
+ return () => {
199
+ cacheWriteListeners.delete(onWrite);
200
+ if (timer) {
201
+ clearTimeout(timer);
202
+ write();
203
+ }
204
+ };
205
+ }
53
206
  export class SolanaClient {
54
207
  transport;
55
208
  #genesis;
@@ -72,22 +225,58 @@ export class SolanaClient {
72
225
  return this.#cluster ?? clusterOfGenesis(await this.#genesis());
73
226
  }
74
227
  /** 直接发 JSON-RPC 调用(同样参与自动合并与故障切换) */
75
- request(method, params) {
76
- return this.transport.request(method, params);
228
+ request(method, params, options = {}) {
229
+ return abortable(options.signal, () => this.transport.request(method, params));
230
+ }
231
+ /**
232
+ * 发一个读账户类的请求:params 最后一项是配置对象,传了 minContextSlot 时带上;
233
+ * 节点高度不够(所有节点都落后)时每 500ms 重试,最多约 10 秒;记录返回的 context.slot(取最小值)
234
+ */
235
+ async #call(ctx, method, params) {
236
+ const last = params[params.length - 1];
237
+ const sent = ctx.minContextSlot === undefined ? params : [...params.slice(0, -1), { ...last, minContextSlot: ctx.minContextSlot }];
238
+ const started = Date.now();
239
+ for (;;) {
240
+ try {
241
+ const res = await this.transport.request(method, sent);
242
+ const slot = res?.context?.slot;
243
+ if (typeof slot === 'number') {
244
+ ctx.slot = ctx.slot === undefined ? slot : Math.min(ctx.slot, slot);
245
+ }
246
+ return res;
247
+ }
248
+ catch (err) {
249
+ if (ctx.minContextSlot === undefined || !isBehind(err) || Date.now() - started + BEHIND_RETRY_INTERVAL > BEHIND_RETRY_TIMEOUT) {
250
+ throw err;
251
+ }
252
+ await sleep(BEHIND_RETRY_INTERVAL, ctx.signal);
253
+ }
254
+ }
255
+ }
256
+ /** 结果项带上 slot(传了 withSlot / minContextSlot,且这次查询发了请求时) */
257
+ #attachSlot(items, ctx, options) {
258
+ if ((!options.withSlot && options.minContextSlot === undefined) || ctx.slot === undefined) {
259
+ return items;
260
+ }
261
+ const slot = ctx.slot;
262
+ return items.map((item) => ({ ...item, slot }));
77
263
  }
78
264
  /**
79
265
  * 批量读取账户(getMultipleAccounts,按 100 个一组,去重),结果与 addresses 一一对应。
80
266
  * 账户不存在或地址非法时为 null。
81
267
  */
82
- async accounts(addresses) {
83
- const fetched = await this.#fetchAccounts(addresses, false);
268
+ accounts(addresses, options = {}) {
269
+ return abortable(options.signal, () => this.#accounts(addresses, { signal: options.signal, minContextSlot: options.minContextSlot }));
270
+ }
271
+ async #accounts(addresses, ctx) {
272
+ const fetched = await this.#fetchAccounts(addresses, false, ctx);
84
273
  return addresses.map((address) => fetched.get(address) ?? null);
85
274
  }
86
275
  /**
87
276
  * getMultipleAccounts:去重、按 100 个一组,最多同时 3 组(避免大批量时触发节点限频)。
88
277
  * tolerant 为 true 时,某组因节点问题(网络、限频等)失败不抛错:该组标记为 FAILED,并且不再发后面的组(节点已经在限频了)。
89
278
  */
90
- async #fetchAccounts(addresses, tolerant) {
279
+ async #fetchAccounts(addresses, tolerant, ctx) {
91
280
  const unique = [...new Set(addresses.filter((a) => isAddress(a)))];
92
281
  const chunks = [];
93
282
  for (let i = 0; i < unique.length; i += MAX_ACCOUNTS_PER_REQUEST) {
@@ -97,13 +286,17 @@ export class SolanaClient {
97
286
  let aborted = false;
98
287
  for (let i = 0; i < chunks.length; i += MAX_PARALLEL_ACCOUNT_REQUESTS) {
99
288
  const wave = chunks.slice(i, i + MAX_PARALLEL_ACCOUNT_REQUESTS);
289
+ // 已取消的查询不再发后面的组
290
+ if (ctx.signal?.aborted) {
291
+ throw ctx.signal.reason;
292
+ }
100
293
  if (aborted) {
101
294
  wave.flat().forEach((address) => result.set(address, FAILED));
102
295
  continue;
103
296
  }
104
297
  await Promise.all(wave.map(async (chunk) => {
105
298
  try {
106
- const res = await this.transport.request('getMultipleAccounts', [
299
+ const res = await this.#call(ctx, 'getMultipleAccounts', [
107
300
  chunk,
108
301
  { encoding: 'base64', commitment: this.#commitment },
109
302
  ]);
@@ -121,109 +314,170 @@ export class SolanaClient {
121
314
  return result;
122
315
  }
123
316
  /** 批量查多个地址的 SOL 余额(一次 getMultipleAccounts;账户不存在视为 0) */
124
- async solBalances(addresses) {
125
- const accounts = await this.accounts(addresses);
126
- return addresses.map((address, i) => {
127
- if (!isAddress(address)) {
128
- return { address, balance: '0', formatted: '0', success: false };
129
- }
130
- const lamports = accounts[i]?.lamports ?? 0n;
131
- return { address, balance: lamports.toString(), formatted: formatAmount(lamports, SOL_DECIMALS), success: true };
317
+ solBalances(addresses, options = {}) {
318
+ return abortable(options.signal, async () => {
319
+ const ctx = this.#context(options);
320
+ const accounts = await this.#accounts(addresses, ctx);
321
+ const list = addresses.map((address, i) => {
322
+ if (!isAddress(address)) {
323
+ return { address, balance: '0', formatted: '0', success: false, error: 'invalid-address' };
324
+ }
325
+ const lamports = accounts[i]?.lamports ?? 0n;
326
+ return { address, balance: lamports.toString(), formatted: formatUnits(lamports, SOL_DECIMALS), success: true };
327
+ });
328
+ return this.#attachSlot(list, ctx, options);
132
329
  });
133
330
  }
331
+ #context(options) {
332
+ return { signal: options.signal, minContextSlot: options.minContextSlot };
333
+ }
134
334
  /**
135
335
  * 批量查余额(主币 SOL + SPL Token + Token-2022),返回原始余额、decimals 和换算后的数值。
136
336
  *
137
337
  * - 传 mints:本地推导 ATA,与主币账户、mint 账户一起用 getMultipleAccounts 一次读完,免费节点也能用
338
+ * - 传 mints 且 accounts: 'all':每个代币按 mint 过滤查 getTokenAccountsByOwner,统计全部代币账户(含非 ATA)
138
339
  * - 不传 mints(或 scan: true):用 getTokenAccountsByOwner 扫描全部代币账户,返回所有余额大于 0 的代币
340
+ * - 失败项(success: false)带 error:invalid-address(mint 地址非法)/ not-found(mint 不存在)/ not-token(不是代币 mint)
341
+ * - tokenPrograms:只返回这些类型的代币,如 ['spl'] 不含 Token-2022(传了 mints 时,结果里去掉不符合的代币)
342
+ * - minContextSlot / withSlot:要求节点高度、返回读取时的 slot,见 SlotOptions
139
343
  */
140
- async balances(owner, mints, options = {}) {
141
- if (!isAddress(owner)) {
142
- throw new Error(`Invalid owner address: ${owner}`);
143
- }
344
+ balances(owner, mints, options = {}) {
345
+ return abortable(options.signal, async () => {
346
+ if (!isAddress(owner)) {
347
+ throw new Error(`Invalid owner address: ${owner}`);
348
+ }
349
+ const ctx = this.#context(options);
350
+ const list = await this.#balances(ctx, owner, mints, options, resolvePrograms(options.tokenPrograms));
351
+ return this.#attachSlot(list, ctx, options);
352
+ });
353
+ }
354
+ #balances(ctx, owner, mints, options, programs) {
355
+ const withSymbol = options.symbol ?? false;
144
356
  if (mints === undefined || options.scan) {
145
- return this.#scanBalances(owner, mints, options.symbol ?? false);
357
+ return this.#scanBalances(ctx, owner, mints, withSymbol, programs);
146
358
  }
147
- return this.#ataBalances(owner, mints, options.symbol ?? false);
359
+ if (options.accounts === 'all') {
360
+ return this.#mintAccountBalances(ctx, owner, mints, withSymbol, programs);
361
+ }
362
+ return this.#ataBalances(ctx, [{ owner, mints }], withSymbol, programs).then((lists) => lists[0]);
148
363
  }
149
- async #ataBalances(owner, mints, withSymbol) {
150
- const scope = this.#scope();
151
- const addresses = [];
152
- const plan = mints.map((mint) => {
153
- if (this.#nativeMints.has(mint)) {
154
- return { mint, native: true, owner: addresses.push(owner) - 1 };
155
- }
156
- if (!isAddress(mint)) {
157
- return { mint, native: false, invalid: true };
364
+ /**
365
+ * 多个钱包一次查(如多钱包的持仓列表),结果与 queries 一一对应。
366
+ * ATA 模式(默认)下所有钱包的账户合并成一次(按 100 个一组的)getMultipleAccounts;
367
+ * scan / accounts: 'all' 时每个钱包各自查询(同一 tick 发出,合并成批量请求)。其他选项与 balances() 相同;
368
+ * withSlot 时所有钱包的结果带同一个 slot(整次查询的最小值)。任一 owner 非法时报错
369
+ *
370
+ * ```ts
371
+ * await sol.multiBalances([{ owner: a, mints: [NATIVE_MINT, USDC] }, { owner: b, mints: [USDC] }])
372
+ * ```
373
+ */
374
+ multiBalances(queries, options = {}) {
375
+ return abortable(options.signal, async () => {
376
+ for (const { owner } of queries) {
377
+ if (!isAddress(owner)) {
378
+ throw new Error(`Invalid owner address: ${owner}`);
379
+ }
158
380
  }
159
- const meta = getMeta(scope, mint);
160
- const needMint = meta.decimals === undefined || meta.tokenProgram === undefined || (withSymbol && meta.symbol === undefined);
161
- const programs = meta.tokenProgram ? [meta.tokenProgram] : [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID];
162
- return {
163
- mint,
164
- native: false,
165
- meta,
166
- mintIndex: needMint ? addresses.push(mint) - 1 : -1,
167
- metadataIndex: withSymbol && meta.symbol === undefined ? addresses.push(getMetadataAddress(mint)) - 1 : -1,
168
- atas: programs.map((program) => ({ program, index: addresses.push(getAssociatedTokenAddress(owner, mint, program)) - 1 })),
169
- };
381
+ const programs = resolvePrograms(options.tokenPrograms);
382
+ const ctx = this.#context(options);
383
+ const lists = options.scan || options.accounts === 'all'
384
+ ? await Promise.all(queries.map(({ owner, mints }) => this.#balances(ctx, owner, mints, options, programs)))
385
+ : await this.#ataBalances(ctx, queries, options.symbol ?? false, programs);
386
+ return lists.map((list) => this.#attachSlot(list, ctx, options));
170
387
  });
171
- const accounts = await this.accounts(addresses);
172
- return plan.map((item) => {
173
- if (item.native) {
174
- const lamports = accounts[item.owner]?.lamports ?? 0n;
175
- return withSymbolField({ token: item.mint, native: true, balance: lamports.toString(), decimals: SOL_DECIMALS, formatted: formatAmount(lamports, SOL_DECIMALS), tokenProgram: null, success: true }, withSymbol, 'SOL');
176
- }
177
- if ('invalid' in item) {
178
- return withSymbolField({ token: item.mint, native: false, balance: '0', decimals: 0, formatted: '0', tokenProgram: null, success: false }, withSymbol, null);
179
- }
180
- const mintAccount = item.mintIndex === -1 ? null : (accounts[item.mintIndex] ?? null);
181
- const meta = this.#learn(scope, item.mint, mintAccount, item.metadataIndex === -1 ? null : (accounts[item.metadataIndex] ?? null), item.metadataIndex !== -1 && item.mintIndex !== -1);
182
- // 代币账户由哪个程序持有就是哪个;mint 账户的 owner 也能确定程序
183
- let amount = 0n;
184
- for (const ata of item.atas) {
185
- const account = accounts[ata.index];
186
- if (account && account.owner === ata.program) {
187
- try {
188
- amount = parseTokenAccount(account.data).amount;
189
- if (meta.tokenProgram === undefined) {
190
- setMeta(scope, item.mint, { tokenProgram: ata.program });
191
- meta.tokenProgram = ata.program;
192
- }
193
- }
194
- catch {
195
- // 不是代币账户
388
+ }
389
+ /** ATA 模式:所有钱包的 主币账户 / mint / 元数据 / ATA 一起读,结果按钱包分组 */
390
+ async #ataBalances(ctx, queries, withSymbol, allowed) {
391
+ const scope = this.#scope();
392
+ const addresses = [];
393
+ const plans = queries.map(({ owner, mints }) => mints.map((mint) => this.#ataPlan(scope, owner, mint, withSymbol, allowed, addresses)));
394
+ const accounts = addresses.length ? await this.#accounts(addresses, ctx) : [];
395
+ return plans.map((plan) => plan.flatMap((item) => this.#ataResult(scope, item, accounts, withSymbol, allowed)));
396
+ }
397
+ #ataPlan(scope, owner, mint, withSymbol, allowed, addresses) {
398
+ if (this.#nativeMints.has(mint)) {
399
+ return { kind: 'native', mint, owner: addresses.push(owner) - 1 };
400
+ }
401
+ if (!isAddress(mint)) {
402
+ return { kind: 'invalid', mint };
403
+ }
404
+ const meta = getMeta(scope, mint);
405
+ if (isExcluded(meta.tokenProgram, allowed)) {
406
+ return { kind: 'excluded', mint };
407
+ }
408
+ const needMint = meta.decimals === undefined || meta.tokenProgram === undefined || (withSymbol && meta.symbol === undefined);
409
+ // 所属程序未知时只推导允许的程序的 ATA,读到 mint 账户后再确认
410
+ const programs = meta.tokenProgram ? [meta.tokenProgram] : allowed;
411
+ return {
412
+ kind: 'token',
413
+ mint,
414
+ mintIndex: needMint ? addresses.push(mint) - 1 : -1,
415
+ metadataIndex: withSymbol && meta.symbol === undefined ? addresses.push(getMetadataAddress(mint)) - 1 : -1,
416
+ atas: programs.map((program) => ({ program, index: addresses.push(getAssociatedTokenAddress(owner, mint, program)) - 1 })),
417
+ };
418
+ }
419
+ #ataResult(scope, item, accounts, withSymbol, allowed) {
420
+ if (item.kind === 'native') {
421
+ const lamports = accounts[item.owner]?.lamports ?? 0n;
422
+ return [
423
+ withSymbolField({ token: item.mint, native: true, balance: lamports.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(lamports, SOL_DECIMALS), tokenProgram: null, success: true }, withSymbol, 'SOL'),
424
+ ];
425
+ }
426
+ if (item.kind === 'excluded') {
427
+ return [];
428
+ }
429
+ if (item.kind === 'invalid') {
430
+ return [failedBalance(item.mint, 'invalid-address', null, withSymbol, null)];
431
+ }
432
+ const mintAccount = item.mintIndex === -1 ? null : (accounts[item.mintIndex] ?? null);
433
+ const meta = this.#learn(scope, item.mint, mintAccount, item.metadataIndex === -1 ? null : (accounts[item.metadataIndex] ?? null), item.metadataIndex !== -1 && item.mintIndex !== -1);
434
+ // 代币账户由哪个程序持有就是哪个;mint 账户的 owner 也能确定程序
435
+ let amount = 0n;
436
+ for (const ata of item.atas) {
437
+ const account = accounts[ata.index];
438
+ if (account && account.owner === ata.program) {
439
+ try {
440
+ amount = parseTokenAccount(account.data).amount;
441
+ if (meta.tokenProgram === undefined) {
442
+ setMeta(scope, item.mint, { tokenProgram: ata.program });
443
+ meta.tokenProgram = ata.program;
196
444
  }
197
445
  }
446
+ catch {
447
+ // 不是代币账户
448
+ }
198
449
  }
199
- if (meta.decimals === undefined) {
200
- return withSymbolField({ token: item.mint, native: false, balance: '0', decimals: 0, formatted: '0', tokenProgram: meta.tokenProgram ?? null, success: false }, withSymbol, meta.symbol ?? null);
201
- }
202
- return withSymbolField({
450
+ }
451
+ // 读到 mint 账户后才知道是不允许的程序
452
+ if (isExcluded(meta.tokenProgram, allowed)) {
453
+ return [];
454
+ }
455
+ if (meta.decimals === undefined) {
456
+ // decimals 未知时一定读了 mint 账户:不存在 / 存在但不是 mint
457
+ return [failedBalance(item.mint, mintAccount ? 'not-token' : 'not-found', meta.tokenProgram ?? null, withSymbol, meta.symbol ?? null)];
458
+ }
459
+ return [
460
+ withSymbolField({
203
461
  token: item.mint,
204
462
  native: false,
205
463
  balance: amount.toString(),
206
464
  decimals: meta.decimals,
207
- formatted: formatAmount(amount, meta.decimals),
465
+ formatted: formatUnits(amount, meta.decimals),
208
466
  tokenProgram: meta.tokenProgram ?? null,
209
467
  success: true,
210
- }, withSymbol, meta.symbol ?? null);
211
- });
468
+ }, withSymbol, meta.symbol ?? null),
469
+ ];
212
470
  }
213
- /** 扫描持有人的 SOL 余额和全部代币账户(Token + Token-2022),同一代币的多个账户合计 */
214
- async #scanHoldings(owner) {
471
+ /** 扫描持有人的 SOL 余额和全部代币账户(默认 Token + Token-2022,只请求 programs 里的程序),同一代币的多个账户合计 */
472
+ async #scanHoldings(ctx, owner, programs = TOKEN_PROGRAMS) {
215
473
  const scope = this.#scope();
216
- const [lamports, ...programs] = await Promise.all([
217
- this.transport.request('getBalance', [owner, { commitment: this.#commitment }]),
218
- ...[TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID].map((programId) => this.transport.request('getTokenAccountsByOwner', [
219
- owner,
220
- { programId },
221
- { encoding: 'jsonParsed', commitment: this.#commitment },
222
- ])),
474
+ const [lamports, ...results] = await Promise.all([
475
+ this.#call(ctx, 'getBalance', [owner, { commitment: this.#commitment }]),
476
+ ...programs.map((programId) => this.#call(ctx, 'getTokenAccountsByOwner', [owner, { programId }, { encoding: 'jsonParsed', commitment: this.#commitment }])),
223
477
  ]);
224
478
  const held = new Map();
225
- [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID].forEach((program, i) => {
226
- for (const entry of programs[i]?.value ?? []) {
479
+ programs.forEach((program, i) => {
480
+ for (const entry of results[i]?.value ?? []) {
227
481
  const info = entry.account?.data?.parsed?.info;
228
482
  const mint = info?.mint;
229
483
  const decimals = info?.tokenAmount?.decimals;
@@ -245,66 +499,143 @@ export class SolanaClient {
245
499
  * - 默认不含 NFT(精度 0 且数量 1)和余额为 0 的代币账户(如已清空的 ATA),可用 includeNfts / includeZero 打开
246
500
  * - accounts 是该代币的代币账户数量;大于 1 时余额为所有账户的合计
247
501
  * - 顺序:SOL,然后 SPL Token 的代币,再然后 Token-2022 的代币(各自按节点返回的顺序;没有价格信息,不按价值排序)
502
+ * - tokenPrograms: ['spl'] 时不含 Token-2022 代币
503
+ * - withSlot 的 slot 只算余额扫描(元数据读取不计入)
248
504
  */
249
- async ownerTokens(owner, options = {}) {
250
- if (!isAddress(owner)) {
251
- throw new Error(`Invalid owner address: ${owner}`);
252
- }
253
- const { metadata = true, includeNative = true, includeZero = false, includeNfts = false } = options;
254
- const scope = this.#scope();
255
- const { sol, held } = await this.#scanHoldings(owner);
256
- // Wrapped SOL 是独立的代币账户(需要 unwrap 才是 SOL),资产列表里单独列出(native: false)
257
- const tokens = [...held].filter(([, h]) => (includeZero || h.amount > 0n) && (includeNfts || !isNftHolding(h)));
258
- const failed = metadata ? await this.#loadMeta(scope, tokens.map(([mint]) => mint), true, true) : new Set();
259
- const list = tokens.map(([mint, h]) => {
260
- const item = {
261
- token: mint,
262
- native: false,
263
- balance: h.amount.toString(),
264
- decimals: h.decimals,
265
- formatted: formatAmount(h.amount, h.decimals),
266
- tokenProgram: h.program,
267
- accounts: h.accounts,
268
- success: true,
269
- };
270
- if (!metadata) {
271
- return item;
505
+ ownerTokens(owner, options = {}) {
506
+ return abortable(options.signal, async () => {
507
+ if (!isAddress(owner)) {
508
+ throw new Error(`Invalid owner address: ${owner}`);
272
509
  }
273
- const meta = getMeta(scope, mint);
274
- const status = failed.has(mint) ? 'failed' : meta.name == null && meta.symbol == null ? 'missing' : 'ok';
275
- return { ...item, name: meta.name ?? null, symbol: meta.symbol ?? null, metadataStatus: status };
510
+ const { metadata = true, includeNative = true, includeZero = false, includeNfts = false } = options;
511
+ const scope = this.#scope();
512
+ const ctx = this.#context(options);
513
+ const { sol, held } = await this.#scanHoldings(ctx, owner, resolvePrograms(options.tokenPrograms));
514
+ const scanSlot = ctx.slot;
515
+ // Wrapped SOL 是独立的代币账户(需要 unwrap 才是 SOL),资产列表里单独列出(native: false)
516
+ const tokens = [...held].filter(([, h]) => (includeZero || h.amount > 0n) && (includeNfts || !isNftHolding(h)));
517
+ // 元数据不受 minContextSlot 约束(不会因为刚成交而变化)
518
+ const failures = metadata ? await this.#loadMeta({ signal: options.signal }, scope, tokens.map(([mint]) => mint), true, true) : new Map();
519
+ const list = tokens.map(([mint, h]) => {
520
+ const item = {
521
+ token: mint,
522
+ native: false,
523
+ balance: h.amount.toString(),
524
+ decimals: h.decimals,
525
+ formatted: formatUnits(h.amount, h.decimals),
526
+ tokenProgram: h.program,
527
+ accounts: h.accounts,
528
+ success: true,
529
+ };
530
+ if (!metadata) {
531
+ return item;
532
+ }
533
+ const meta = getMeta(scope, mint);
534
+ const status = failures.get(mint) === 'failed' ? 'failed' : meta.name == null && meta.symbol == null ? 'missing' : 'ok';
535
+ return { ...item, name: meta.name ?? null, symbol: meta.symbol ?? null, metadataStatus: status };
536
+ });
537
+ if (includeNative) {
538
+ const sol_ = { token: NATIVE_MINT, native: true, balance: sol.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(sol, SOL_DECIMALS), tokenProgram: null, accounts: 1, success: true };
539
+ list.unshift(metadata ? { ...sol_, name: 'Solana', symbol: 'SOL', metadataStatus: 'ok' } : sol_);
540
+ }
541
+ return this.#attachSlot(list, { slot: scanSlot }, options);
276
542
  });
277
- if (includeNative) {
278
- const sol_ = { token: NATIVE_MINT, native: true, balance: sol.toString(), decimals: SOL_DECIMALS, formatted: formatAmount(sol, SOL_DECIMALS), tokenProgram: null, accounts: 1, success: true };
279
- list.unshift(metadata ? { ...sol_, name: 'Solana', symbol: 'SOL', metadataStatus: 'ok' } : sol_);
280
- }
281
- return list;
282
543
  }
283
- async #scanBalances(owner, mints, withSymbol) {
544
+ async #scanBalances(ctx, owner, mints, withSymbol, allowed) {
284
545
  const scope = this.#scope();
285
- const { sol, held } = await this.#scanHoldings(owner);
546
+ // held 里只有允许的程序的代币
547
+ const { sol, held } = await this.#scanHoldings(ctx, owner, allowed);
286
548
  // Wrapped SOL(So111…112)默认按原生 SOL 处理:余额列表里只有一项 SOL,不再列出 wSOL 代币账户
287
- const targets = mints ?? [NATIVE_MINT, ...[...held].filter(([mint, h]) => h.amount > 0n && !this.#nativeMints.has(mint)).map(([mint]) => mint)];
549
+ // 第一项是主币:用 nativeMints 里的地址(nativeMints 不含 So111…112 时 wSOL 作为普通代币列出,不能再拿它当 SOL)
550
+ const nativeLead = this.#nativeMints.has(NATIVE_MINT) ? NATIVE_MINT : this.#nativeMints.values().next().value;
551
+ const targets = mints ?? [
552
+ ...(nativeLead === undefined ? [] : [nativeLead]),
553
+ ...[...held].filter(([mint, h]) => h.amount > 0n && !this.#nativeMints.has(mint)).map(([mint]) => mint),
554
+ ];
288
555
  // 没持有的代币(拿不到 decimals)和缺 symbol 的代币:只读 mint / 元数据账户补上,不需要 ATA
289
- await this.#loadMeta(scope, targets.filter((mint) => !this.#nativeMints.has(mint) && isAddress(mint)), withSymbol);
290
- return targets.map((mint) => {
556
+ const failures = await this.#loadMeta(ctx, scope, targets.filter((mint) => !this.#nativeMints.has(mint) && isAddress(mint)), withSymbol);
557
+ return targets.flatMap((mint) => {
291
558
  if (this.#nativeMints.has(mint)) {
292
- return withSymbolField({ token: mint, native: true, balance: sol.toString(), decimals: SOL_DECIMALS, formatted: formatAmount(sol, SOL_DECIMALS), tokenProgram: null, success: true }, withSymbol, 'SOL');
559
+ return [
560
+ withSymbolField({ token: mint, native: true, balance: sol.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(sol, SOL_DECIMALS), tokenProgram: null, success: true }, withSymbol, 'SOL'),
561
+ ];
293
562
  }
294
- const h = held.get(mint);
295
- if (!h) {
296
- // 没持有:余额为 0;decimals 来自 mint 账户(mint 不存在时 success 为 false)
297
- const meta = getMeta(scope, mint);
298
- return withSymbolField({ token: mint, native: false, balance: '0', decimals: meta.decimals ?? 0, formatted: '0', tokenProgram: meta.tokenProgram ?? null, success: meta.decimals !== undefined }, withSymbol, meta.symbol ?? null);
563
+ return this.#heldBalance(scope, mint, held.get(mint), failures, withSymbol, allowed);
564
+ });
565
+ }
566
+ /**
567
+ * 指定 mints、统计全部代币账户:每个代币一次按 mint 过滤的 getTokenAccountsByOwner(同一 tick 发出,自动合并)。
568
+ * mint 不存在 / 不是代币时节点返回参数错误,按“没持有”处理,再由 #loadMeta 确认原因
569
+ */
570
+ async #mintAccountBalances(ctx, owner, mints, withSymbol, allowed) {
571
+ const scope = this.#scope();
572
+ const queried = [...new Set(mints.filter((mint) => !this.#nativeMints.has(mint) && isAddress(mint) && !isExcluded(getMeta(scope, mint).tokenProgram, allowed)))];
573
+ const [lamports, ...results] = await Promise.all([
574
+ mints.some((mint) => this.#nativeMints.has(mint))
575
+ ? this.#call(ctx, 'getBalance', [owner, { commitment: this.#commitment }])
576
+ : Promise.resolve(null),
577
+ ...queried.map((mint) => this.#call(ctx, 'getTokenAccountsByOwner', [owner, { mint }, { encoding: 'jsonParsed', commitment: this.#commitment }]).catch((err) => {
578
+ // "Invalid param: could not find mint" 等参数错误:mint 不存在或不是代币;节点问题照常抛出
579
+ if (err instanceof RpcError && !err.nodeFault && err.code === -32602)
580
+ return null;
581
+ throw err;
582
+ })),
583
+ ]);
584
+ const held = new Map();
585
+ queried.forEach((mint, i) => {
586
+ for (const entry of results[i]?.value ?? []) {
587
+ const decimals = entry.account?.data?.parsed?.info?.tokenAmount?.decimals;
588
+ const program = entry.account?.owner;
589
+ if (decimals === undefined || !program) {
590
+ continue;
591
+ }
592
+ const prev = held.get(mint);
593
+ const amount = BigInt(entry.account.data.parsed?.info?.tokenAmount?.amount ?? '0');
594
+ held.set(mint, { amount: (prev?.amount ?? 0n) + amount, decimals, program, accounts: (prev?.accounts ?? 0) + 1 });
595
+ setMeta(scope, mint, { decimals, tokenProgram: program });
299
596
  }
300
- return withSymbolField({ token: mint, native: false, balance: h.amount.toString(), decimals: h.decimals, formatted: formatAmount(h.amount, h.decimals), tokenProgram: h.program, success: true }, withSymbol, getMeta(scope, mint).symbol ?? null);
301
597
  });
598
+ // 没持有的代币补 decimals(同时确认 mint 是否存在),需要时补 symbol
599
+ const failures = await this.#loadMeta(ctx, scope, queried, withSymbol);
600
+ const sol = BigInt(lamports?.value ?? 0);
601
+ return mints.flatMap((mint) => {
602
+ if (this.#nativeMints.has(mint)) {
603
+ return [
604
+ withSymbolField({ token: mint, native: true, balance: sol.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(sol, SOL_DECIMALS), tokenProgram: null, success: true }, withSymbol, 'SOL'),
605
+ ];
606
+ }
607
+ return this.#heldBalance(scope, mint, held.get(mint), failures, withSymbol, allowed);
608
+ });
609
+ }
610
+ /** 扫描类查询(scan / accounts: 'all')里单个代币的结果;不允许的程序返回空数组 */
611
+ #heldBalance(scope, mint, h, failures, withSymbol, allowed) {
612
+ if (!isAddress(mint)) {
613
+ return [failedBalance(mint, 'invalid-address', null, withSymbol, null)];
614
+ }
615
+ const meta = getMeta(scope, mint);
616
+ if (!h) {
617
+ // 没持有(或属于不允许的程序):余额为 0;decimals 来自 mint 账户
618
+ if (isExcluded(meta.tokenProgram, allowed)) {
619
+ return [];
620
+ }
621
+ if (meta.decimals === undefined) {
622
+ const failure = failures.get(mint);
623
+ return [failedBalance(mint, failure === 'not-token' ? 'not-token' : 'not-found', meta.tokenProgram ?? null, withSymbol, meta.symbol ?? null)];
624
+ }
625
+ return [withSymbolField({ token: mint, native: false, balance: '0', decimals: meta.decimals, formatted: '0', tokenProgram: meta.tokenProgram ?? null, success: true }, withSymbol, meta.symbol ?? null)];
626
+ }
627
+ if (isExcluded(h.program, allowed)) {
628
+ return [];
629
+ }
630
+ return [
631
+ withSymbolField({ token: mint, native: false, balance: h.amount.toString(), decimals: h.decimals, formatted: formatUnits(h.amount, h.decimals), tokenProgram: h.program, success: true }, withSymbol, meta.symbol ?? null),
632
+ ];
302
633
  }
303
634
  /**
304
635
  * 补齐 decimals / 所属程序(以及需要时的 symbol / name):只读缺的 mint 账户和元数据账户。
305
- * tolerant 为 true 时节点问题不抛错,返回没能读到的 mint(这些代币的信息保持未知,不写缓存)。
636
+ * 返回没能补上 decimals 的 mint 及原因:failed(tolerant 为 true 时的节点问题,信息保持未知、不写缓存)、not-found、not-token
306
637
  */
307
- async #loadMeta(scope, mints, withSymbol, tolerant = false) {
638
+ async #loadMeta(ctx, scope, mints, withSymbol, tolerant = false) {
308
639
  const plan = [...new Set(mints)].flatMap((mint) => {
309
640
  const meta = getMeta(scope, mint);
310
641
  const needSymbol = withSymbol && meta.symbol === undefined;
@@ -313,31 +644,38 @@ export class SolanaClient {
313
644
  }
314
645
  return [{ mint, metadata: needSymbol ? getMetadataAddress(mint) : null }];
315
646
  });
316
- const failed = new Set();
647
+ const failures = new Map();
317
648
  if (!plan.length) {
318
- return failed;
649
+ return failures;
319
650
  }
320
- const fetched = await this.#fetchAccounts(plan.flatMap(({ mint, metadata }) => (metadata ? [mint, metadata] : [mint])), tolerant);
651
+ const fetched = await this.#fetchAccounts(plan.flatMap(({ mint, metadata }) => (metadata ? [mint, metadata] : [mint])), tolerant, ctx);
321
652
  for (const { mint, metadata } of plan) {
322
653
  const mintAccount = fetched.get(mint);
323
654
  const metadataAccount = metadata ? fetched.get(metadata) : null;
324
655
  // mint 和元数据账户可能落在不同的请求里:任一个没读到都算失败,不能当成“没有元数据”缓存
325
656
  if (mintAccount === FAILED || metadataAccount === FAILED) {
326
- failed.add(mint);
657
+ failures.set(mint, 'failed');
327
658
  continue;
328
659
  }
329
- this.#learn(scope, mint, mintAccount ?? null, metadataAccount ?? null, metadata !== null);
660
+ if (this.#learn(scope, mint, mintAccount ?? null, metadataAccount ?? null, metadata !== null).decimals === undefined) {
661
+ failures.set(mint, mintAccount ? 'not-token' : 'not-found');
662
+ }
330
663
  }
331
- return failed;
664
+ return failures;
332
665
  }
333
666
  /**
334
667
  * 批量查代币详情(一次 getMultipleAccounts),可以选择返回哪些字段。
335
668
  * name / symbol / uri 优先取 Token-2022 TokenMetadata 扩展,其次 Metaplex 元数据。
669
+ * transferFee:Token-2022 转账手续费,请求时会顺带发一次 getEpochInfo(同一个批量请求里)选出当前生效的配置
336
670
  */
337
- async tokens(mints, options = {}) {
671
+ tokens(mints, options = {}) {
672
+ return abortable(options.signal, () => this.#tokens(mints, options));
673
+ }
674
+ async #tokens(mints, options) {
338
675
  const fields = (options.fields ?? DEFAULT_TOKEN_FIELDS);
339
676
  const wanted = new Set(fields);
340
677
  const scope = this.#scope();
678
+ const ctx = { signal: options.signal };
341
679
  const needsNames = wanted.has('name') || wanted.has('symbol') || wanted.has('uri');
342
680
  const addresses = [];
343
681
  const plan = mints.map((mint) => {
@@ -345,11 +683,12 @@ export class SolanaClient {
345
683
  return { mint, mintIndex: -1, metadataIndex: -1 };
346
684
  }
347
685
  const meta = getMeta(scope, mint);
348
- // uri / supply / 权限不缓存,需要时总要读 mint;name / symbol 命中缓存时可以省掉元数据账户
686
+ // uri / supply / 权限 / 手续费不缓存,需要时总要读 mint;name / symbol 命中缓存时可以省掉元数据账户
349
687
  const cachedOnly = !wanted.has('uri') &&
350
688
  !wanted.has('supply') &&
351
689
  !wanted.has('mintAuthority') &&
352
690
  !wanted.has('freezeAuthority') &&
691
+ !wanted.has('transferFee') &&
353
692
  (!wanted.has('decimals') || meta.decimals !== undefined) &&
354
693
  (!wanted.has('tokenProgram') || meta.tokenProgram !== undefined) &&
355
694
  (!wanted.has('name') || meta.name !== undefined) &&
@@ -364,35 +703,59 @@ export class SolanaClient {
364
703
  metadataIndex: needsNames && !namesCached ? addresses.push(getMetadataAddress(mint)) - 1 : -1,
365
704
  };
366
705
  });
367
- const accounts = addresses.length ? await this.accounts(addresses) : [];
706
+ const [accounts, epoch] = await Promise.all([
707
+ addresses.length ? this.#accounts(addresses, ctx) : Promise.resolve([]),
708
+ // 手续费按当前 epoch 选新 / 旧配置;和账户读取在同一个批量请求里
709
+ wanted.has('transferFee') && addresses.length
710
+ ? this.#call(ctx, 'getEpochInfo', [{ commitment: this.#commitment }]).then((res) => BigInt(res.epoch))
711
+ : Promise.resolve(null),
712
+ ]);
368
713
  return plan.map(({ mint, mintIndex, metadataIndex }) => {
369
714
  const native = this.#nativeMints.has(mint);
370
715
  const values = native
371
- ? { exists: true, name: 'Solana', symbol: 'SOL', uri: null, decimals: SOL_DECIMALS, supply: null, tokenProgram: null, mintAuthority: null, freezeAuthority: null }
372
- : this.#tokenValues(scope, mint, mintIndex === -1 ? null : (accounts[mintIndex] ?? null), metadataIndex === -1 ? null : (accounts[metadataIndex] ?? null), mintIndex !== -1, mintIndex !== -1 && metadataIndex !== -1);
716
+ ? { exists: true, name: 'Solana', symbol: 'SOL', uri: null, decimals: SOL_DECIMALS, supply: null, tokenProgram: null, mintAuthority: null, freezeAuthority: null, transferFee: null }
717
+ : this.#tokenValues(scope, mint, mintIndex === -1 ? null : (accounts[mintIndex] ?? null), metadataIndex === -1 ? null : (accounts[metadataIndex] ?? null), mintIndex !== -1, mintIndex !== -1 && metadataIndex !== -1, epoch);
373
718
  const out = { address: mint, native };
374
- let success = values.exists;
719
+ let missing;
375
720
  for (const field of fields) {
376
721
  out[field] = values[field] ?? null;
377
- // 权限为 null 表示没有权限,是有效值;主币没有 uri / supply / tokenProgram
378
- const nullable = field === 'mintAuthority' || field === 'freezeAuthority' || (native && (field === 'uri' || field === 'supply' || field === 'tokenProgram'));
722
+ // 权限为 null 表示没有权限、手续费为 null 表示没有手续费,都是有效值;主币没有 uri / supply / tokenProgram
723
+ const nullable = field === 'mintAuthority' || field === 'freezeAuthority' || field === 'transferFee' || (native && (field === 'uri' || field === 'supply' || field === 'tokenProgram'));
379
724
  if (out[field] === null && !nullable) {
380
- success = false;
725
+ missing ??= field;
381
726
  }
382
727
  }
383
728
  if (wanted.has('supply')) {
384
729
  const supply = values.supply;
385
730
  const decimals = values.decimals;
386
- out.supplyFormatted = supply === null || decimals === null ? null : formatAmount(BigInt(supply), decimals);
731
+ out.supplyFormatted = supply === null || decimals === null ? null : formatUnits(BigInt(supply), decimals);
732
+ }
733
+ const error = native
734
+ ? undefined
735
+ : !isAddress(mint)
736
+ ? 'invalid-address'
737
+ : !values.exists
738
+ ? accounts[mintIndex]
739
+ ? 'not-token'
740
+ : 'not-found'
741
+ : missing
742
+ ? 'missing-field'
743
+ : undefined;
744
+ out.success = error === undefined;
745
+ if (error) {
746
+ out.error = error;
747
+ if (error === 'missing-field') {
748
+ out.errorField = missing;
749
+ }
387
750
  }
388
- out.success = success;
389
751
  return out;
390
752
  });
391
753
  }
392
- #tokenValues(scope, mint, mintAccount, metadataAccount, fetchedMint, lookedForNames = false) {
754
+ #tokenValues(scope, mint, mintAccount, metadataAccount, fetchedMint, lookedForNames, epoch) {
393
755
  const meta = this.#learn(scope, mint, mintAccount, metadataAccount, lookedForNames);
394
756
  const info = mintAccount ? safeParseMint(mintAccount) : null;
395
757
  const uri = info?.metadata?.uri ?? (metadataAccount ? (safeParseMetaplex(metadataAccount)?.uri ?? null) : null);
758
+ const feeConfig = info && mintAccount?.owner === TOKEN_2022_PROGRAM_ID ? parseTransferFeeConfig(mintAccount.data) : null;
396
759
  return {
397
760
  // 读了 mint 账户就以它为准(不存在则 false);没读说明请求的字段全部命中缓存
398
761
  exists: fetchedMint ? info !== null : true,
@@ -404,77 +767,95 @@ export class SolanaClient {
404
767
  tokenProgram: meta.tokenProgram ?? null,
405
768
  mintAuthority: info?.mintAuthority ?? null,
406
769
  freezeAuthority: info?.freezeAuthority ?? null,
770
+ transferFee: feeConfig ? toTransferFeeConfig(feeConfig.older, feeConfig.newer, epoch) : null,
407
771
  };
408
772
  }
409
773
  /** 批量查 NFT 元数据(Metaplex;Token-2022 NFT 取 TokenMetadata 扩展),一次 getMultipleAccounts */
410
- async nfts(mints) {
411
- const scope = this.#scope();
412
- const addresses = [];
413
- const plan = mints.map((mint) => isAddress(mint) ? { mint, metadataIndex: addresses.push(getMetadataAddress(mint)) - 1, mintIndex: addresses.push(mint) - 1 } : { mint, metadataIndex: -1, mintIndex: -1 });
414
- const accounts = addresses.length ? await this.accounts(addresses) : [];
415
- return plan.map(({ mint, metadataIndex, mintIndex }) => {
416
- const metadataAccount = metadataIndex === -1 ? null : (accounts[metadataIndex] ?? null);
417
- const mintAccount = mintIndex === -1 ? null : (accounts[mintIndex] ?? null);
418
- this.#learn(scope, mint, mintAccount, metadataAccount, mintIndex !== -1);
419
- const metaplex = metadataAccount ? safeParseMetaplex(metadataAccount) : null;
420
- const ext = mintAccount ? (safeParseMint(mintAccount)?.metadata ?? null) : null;
421
- if (!metaplex && !ext) {
422
- return { mint, name: null, symbol: null, uri: null, collection: null, creators: [], sellerFeeBasisPoints: null, tokenStandard: null, isMutable: null, updateAuthority: null, success: false };
423
- }
424
- return {
425
- mint,
426
- name: ext?.name ?? metaplex?.name ?? null,
427
- symbol: ext?.symbol ?? metaplex?.symbol ?? null,
428
- uri: ext?.uri ?? metaplex?.uri ?? null,
429
- collection: metaplex?.collection ?? null,
430
- creators: metaplex?.creators ?? [],
431
- sellerFeeBasisPoints: metaplex?.sellerFeeBasisPoints ?? null,
432
- tokenStandard: metaplex?.tokenStandard == null ? null : (TOKEN_STANDARDS[metaplex.tokenStandard] ?? null),
433
- isMutable: metaplex?.isMutable ?? null,
434
- updateAuthority: metaplex?.updateAuthority ?? null,
435
- success: true,
436
- };
774
+ nfts(mints, options = {}) {
775
+ return abortable(options.signal, async () => {
776
+ const scope = this.#scope();
777
+ const addresses = [];
778
+ const plan = mints.map((mint) => isAddress(mint) ? { mint, metadataIndex: addresses.push(getMetadataAddress(mint)) - 1, mintIndex: addresses.push(mint) - 1 } : { mint, metadataIndex: -1, mintIndex: -1 });
779
+ const accounts = addresses.length ? await this.#accounts(addresses, { signal: options.signal }) : [];
780
+ return plan.map(({ mint, metadataIndex, mintIndex }) => {
781
+ const metadataAccount = metadataIndex === -1 ? null : (accounts[metadataIndex] ?? null);
782
+ const mintAccount = mintIndex === -1 ? null : (accounts[mintIndex] ?? null);
783
+ this.#learn(scope, mint, mintAccount, metadataAccount, mintIndex !== -1);
784
+ const metaplex = metadataAccount ? safeParseMetaplex(metadataAccount) : null;
785
+ const ext = mintAccount ? (safeParseMint(mintAccount)?.metadata ?? null) : null;
786
+ if (!metaplex && !ext) {
787
+ const error = mintIndex === -1 ? 'invalid-address' : !mintAccount ? 'not-found' : !safeParseMint(mintAccount) ? 'not-token' : 'no-metadata';
788
+ return { mint, name: null, symbol: null, uri: null, collection: null, creators: [], sellerFeeBasisPoints: null, tokenStandard: null, isMutable: null, updateAuthority: null, success: false, error };
789
+ }
790
+ return {
791
+ mint,
792
+ name: ext?.name ?? metaplex?.name ?? null,
793
+ symbol: ext?.symbol ?? metaplex?.symbol ?? null,
794
+ uri: ext?.uri ?? metaplex?.uri ?? null,
795
+ collection: metaplex?.collection ?? null,
796
+ creators: metaplex?.creators ?? [],
797
+ sellerFeeBasisPoints: metaplex?.sellerFeeBasisPoints ?? null,
798
+ tokenStandard: metaplex?.tokenStandard == null ? null : (TOKEN_STANDARDS[metaplex.tokenStandard] ?? null),
799
+ isMutable: metaplex?.isMutable ?? null,
800
+ updateAuthority: metaplex?.updateAuthority ?? null,
801
+ success: true,
802
+ };
803
+ });
437
804
  });
438
805
  }
439
- /** 批量查 NFT 持有人:getTokenLargestAccounts(合并成一个批量请求)+ 一次 getMultipleAccounts */
440
- async nftOwners(mints) {
441
- const largest = await Promise.all(mints.map((mint) => isAddress(mint)
442
- ? this.transport
443
- .request('getTokenLargestAccounts', [mint, { commitment: this.#commitment }])
444
- .then((res) => res.value.find((a) => a.amount !== '0')?.address ?? null)
445
- .catch((err) => {
446
- // mint 不存在 / 不是代币:该项失败;节点问题照常抛出
447
- if (err instanceof RpcError && !err.nodeFault)
448
- return null;
449
- throw err;
450
- })
451
- : Promise.resolve(null)));
452
- const tokenAccounts = largest.filter((a) => a !== null);
453
- const accounts = tokenAccounts.length ? await this.accounts(tokenAccounts) : [];
454
- const byAddress = new Map(tokenAccounts.map((a, i) => [a, accounts[i] ?? null]));
455
- return mints.map((mint, i) => {
456
- const tokenAccount = largest[i] ?? null;
457
- const account = tokenAccount ? byAddress.get(tokenAccount) : null;
458
- let owner = null;
459
- if (account) {
460
- try {
461
- owner = parseTokenAccount(account.data).owner;
806
+ /**
807
+ * 批量查 NFT 持有人:getTokenLargestAccounts(合并成一个批量请求)+ 一次 getMultipleAccounts。
808
+ * 节点拒绝的 mint(不存在 / 不是代币)在同一次 getMultipleAccounts 里读 mint 账户区分原因
809
+ */
810
+ nftOwners(mints, options = {}) {
811
+ return abortable(options.signal, async () => {
812
+ const ctx = { signal: options.signal };
813
+ const largest = await Promise.all(mints.map((mint) => isAddress(mint)
814
+ ? this.#call(ctx, 'getTokenLargestAccounts', [mint, { commitment: this.#commitment }])
815
+ .then((res) => ({ tokenAccount: res.value.find((a) => a.amount !== '0')?.address ?? null, rejected: false }))
816
+ .catch((err) => {
817
+ // mint 不存在 / 不是代币:该项失败;节点问题照常抛出
818
+ if (err instanceof RpcError && !err.nodeFault)
819
+ return { tokenAccount: null, rejected: true };
820
+ throw err;
821
+ })
822
+ : Promise.resolve({ tokenAccount: null, rejected: false })));
823
+ const toRead = largest.flatMap(({ tokenAccount, rejected }, i) => (tokenAccount ? [tokenAccount] : rejected ? [mints[i]] : []));
824
+ const accounts = toRead.length ? await this.#accounts(toRead, ctx) : [];
825
+ const byAddress = new Map(toRead.map((a, i) => [a, accounts[i] ?? null]));
826
+ return mints.map((mint, i) => {
827
+ const { tokenAccount, rejected } = largest[i] ?? { tokenAccount: null, rejected: false };
828
+ if (!isAddress(mint)) {
829
+ return { mint, owner: null, tokenAccount: null, success: false, error: 'invalid-address' };
462
830
  }
463
- catch {
464
- owner = null;
831
+ if (rejected) {
832
+ return { mint, owner: null, tokenAccount: null, success: false, error: byAddress.get(mint) ? 'not-token' : 'not-found' };
465
833
  }
466
- }
467
- return { mint, owner, tokenAccount: owner ? tokenAccount : null, success: owner !== null };
834
+ const account = tokenAccount ? byAddress.get(tokenAccount) : null;
835
+ let owner = null;
836
+ if (account) {
837
+ try {
838
+ owner = parseTokenAccount(account.data).owner;
839
+ }
840
+ catch {
841
+ owner = null;
842
+ }
843
+ }
844
+ // 没有余额大于 0 的代币账户(供应量为 0 / 已销毁),或代币账户刚被关闭
845
+ return owner ? { mint, owner, tokenAccount, success: true } : { mint, owner: null, tokenAccount: null, success: false, error: 'no-holder' };
846
+ });
468
847
  });
469
848
  }
470
849
  /** 查某地址持有的全部 NFT(扫描代币账户:数量 1、精度 0),再批量读元数据。需要支持 getTokenAccountsByOwner 的节点 */
471
- async ownerNfts(owner) {
472
- if (!isAddress(owner)) {
473
- throw new Error(`Invalid owner address: ${owner}`);
474
- }
475
- const { held } = await this.#scanHoldings(owner);
476
- const mints = [...held].filter(([, h]) => isNftHolding(h)).map(([mint]) => mint);
477
- return mints.length ? (await this.nfts(mints)).filter((nft) => nft.success) : [];
850
+ ownerNfts(owner, options = {}) {
851
+ return abortable(options.signal, async () => {
852
+ if (!isAddress(owner)) {
853
+ throw new Error(`Invalid owner address: ${owner}`);
854
+ }
855
+ const { held } = await this.#scanHoldings({ signal: options.signal }, owner);
856
+ const mints = [...held].filter(([, h]) => isNftHolding(h)).map(([mint]) => mint);
857
+ return mints.length ? (await this.nfts(mints, options)).filter((nft) => nft.success) : [];
858
+ });
478
859
  }
479
860
  #scope() {
480
861
  return this.#scopeId;
@@ -509,6 +890,14 @@ export class SolanaClient {
509
890
  return getMeta(scope, mint);
510
891
  }
511
892
  }
893
+ function toTransferFee(fee) {
894
+ return { basisPoints: fee.basisPoints, maximumFee: fee.maximumFee.toString(), epoch: fee.epoch.toString() };
895
+ }
896
+ /** 当前生效的配置:epoch >= newer.epoch 用 newer,否则 older;不知道当前 epoch 时按 newer */
897
+ function toTransferFeeConfig(older, newer, epoch) {
898
+ const current = epoch === null || epoch >= newer.epoch ? newer : older;
899
+ return { ...toTransferFee(current), older: toTransferFee(older), newer: toTransferFee(newer) };
900
+ }
512
901
  function toAccountInfo(address, raw) {
513
902
  if (!raw) {
514
903
  return null;
@@ -568,8 +957,15 @@ function setMeta(scope, mint, meta) {
568
957
  if (tokenMetaCache.size > MAX_CACHED_TOKENS) {
569
958
  tokenMetaCache.delete(tokenMetaCache.keys().next().value);
570
959
  }
960
+ for (const listener of cacheWriteListeners) {
961
+ listener();
962
+ }
571
963
  }
572
964
  function withSymbolField(balance, withSymbol, symbol) {
573
965
  return withSymbol ? { ...balance, symbol } : balance;
574
966
  }
967
+ /** 失败项:余额为 0,带原因 */
968
+ function failedBalance(token, error, tokenProgram, withSymbol, symbol) {
969
+ return { ...withSymbolField({ token, native: false, balance: '0', decimals: 0, formatted: '0', tokenProgram, success: false }, withSymbol, symbol), error };
970
+ }
575
971
  //# sourceMappingURL=client.js.map