@w3lib/solana-batch-call 0.1.0 → 0.2.1

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/src/client.ts CHANGED
@@ -10,13 +10,24 @@ import {
10
10
  clusterOfGenesis,
11
11
  type Cluster,
12
12
  } from './constants.js'
13
- import { formatAmount, parseMetaplexMetadata, parseMint, parseTokenAccount, type AccountInfo, type MetaplexMetadata, type MintInfo } from './layout.js'
14
- import { HttpError, RpcError, type RpcTransport } from './rpc.js'
13
+ import {
14
+ formatUnits,
15
+ parseMetaplexMetadata,
16
+ parseMint,
17
+ parseTokenAccount,
18
+ parseTransferFeeConfig,
19
+ type AccountInfo,
20
+ type MetaplexMetadata,
21
+ type MintInfo,
22
+ type TransferFeeInfo,
23
+ } from './layout.js'
24
+ import { AllNodesFailedError, HttpError, RpcError, isBehind, type RpcTransport } from './rpc.js'
15
25
  import { NetworkMismatchError, resolveSource, type RpcSource, type SourceOptions } from './source.js'
16
26
  import {
17
27
  DEFAULT_TOKEN_FIELDS,
18
28
  type Commitment,
19
29
  type DefaultTokenField,
30
+ type FailureReason,
20
31
  type NftDetails,
21
32
  type NftOwner,
22
33
  type OwnedToken,
@@ -24,7 +35,10 @@ import {
24
35
  type TokenBalance,
25
36
  type TokenDetails,
26
37
  type TokenField,
38
+ type TokenProgramOption,
27
39
  type TokenStandard,
40
+ type TransferFee,
41
+ type TransferFeeConfig,
28
42
  } from './types.js'
29
43
 
30
44
  export interface ClientConfig extends SourceOptions {
@@ -32,11 +46,35 @@ export interface ClientConfig extends SourceOptions {
32
46
  cluster?: Cluster
33
47
  /** 默认 'confirmed' */
34
48
  commitment?: Commitment
35
- /** 视为原生 SOL 的 mint,默认 [So111…112(Wrapped SOL), 11111…1(System Program)] */
49
+ /**
50
+ * 视为原生 SOL 的 mint,默认 [So111…112(Wrapped SOL), 11111…1(System Program)]。
51
+ * 可以包含非 Solana 地址(如 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE),作为调用方自己的“主币”标识:
52
+ * balances / tokens 遇到它时按 SOL 返回,token 原样带回(这是支持的用法,不会做地址校验)
53
+ */
36
54
  nativeMints?: readonly string[]
37
55
  }
38
56
 
39
- export interface BalancesOptions {
57
+ /** 所有查询共用:取消 */
58
+ export interface RequestOptions {
59
+ /**
60
+ * 取消查询:已取消时直接 reject(不发请求);中途取消时立即 reject(reason 为 signal.reason)。
61
+ * 已经发出、和其他查询合并在一起的底层请求会继续完成(结果给其他调用方),不会中断
62
+ */
63
+ signal?: AbortSignal
64
+ }
65
+
66
+ /** 读余额类查询共用:指定最低 slot、返回读取时的 slot */
67
+ export interface SlotOptions {
68
+ /**
69
+ * 最低 slot:节点高度不到这个 slot 时(如刚成交、公共节点还落后一两个 slot)换下一个节点;
70
+ * 所有节点都不够时每 500ms 重试,最多约 10 秒后抛错。传了它时结果项带 slot
71
+ */
72
+ minContextSlot?: number
73
+ /** 结果项带 slot(读取时节点的 context.slot;一次查询发了多个请求时取最小的)。默认 false */
74
+ withSlot?: boolean
75
+ }
76
+
77
+ export interface BalancesOptions extends RequestOptions, SlotOptions {
40
78
  /** 同时返回 symbol(代币查一次后缓存,主币为 SOL)。默认 false */
41
79
  symbol?: boolean
42
80
  /**
@@ -44,9 +82,23 @@ export interface BalancesOptions {
44
82
  * 不传 mints 时总是扫描。注意:免费公共节点大多不支持这个方法,需要自己的节点。
45
83
  */
46
84
  scan?: boolean
85
+ /**
86
+ * 指定 mints 时统计哪些代币账户(不传 mints 或 scan: true 时不起作用,总是统计全部账户):
87
+ * - 'ata'(默认):只看关联代币账户(ATA),本地推导地址 + getMultipleAccounts,免费节点也能用
88
+ * - 'all':每个代币发一次按 mint 过滤的 getTokenAccountsByOwner(同一 tick 发出,合并成批量请求),统计该代币的全部账户并合计。
89
+ * 只关心少数几个代币、又要算上非 ATA 账户时比 scan 轻得多;同样需要支持索引方法的节点
90
+ */
91
+ accounts?: 'ata' | 'all'
92
+ /**
93
+ * 只返回这些类型的代币:'spl'(SPL Token)、'token-2022',也可以传程序地址。默认两种都返回。
94
+ * 如传 ['spl']:结果里不含 Token-2022 代币(扫描时也不再请求 Token-2022 的代币账户)。主币 SOL 不受影响
95
+ */
96
+ tokenPrograms?: readonly TokenProgramOption[]
47
97
  }
48
98
 
49
- export interface OwnerTokensOptions {
99
+ export interface OwnerTokensOptions extends RequestOptions, SlotOptions {
100
+ /** 只返回这些类型的代币:'spl'、'token-2022' 或程序地址,默认两种都返回;主币 SOL 不受影响(用 includeNative 控制) */
101
+ tokenPrograms?: readonly TokenProgramOption[]
50
102
  /** 返回 name / symbol(代币查一次后缓存)。默认 true */
51
103
  metadata?: boolean
52
104
  /** 第一项返回 SOL。默认 true */
@@ -57,20 +109,119 @@ export interface OwnerTokensOptions {
57
109
  includeNfts?: boolean
58
110
  }
59
111
 
60
- export interface TokensOptions<F extends TokenField = DefaultTokenField> {
112
+ export interface TokensOptions<F extends TokenField = DefaultTokenField> extends RequestOptions {
61
113
  /** 要返回的字段,默认 ['name', 'symbol', 'decimals'] */
62
114
  fields?: readonly F[]
63
115
  }
64
116
 
117
+ /** accounts() 的参数 */
118
+ export interface AccountsOptions extends RequestOptions {
119
+ /** 最低 slot,见 SlotOptions.minContextSlot */
120
+ minContextSlot?: number
121
+ }
122
+
123
+ /** solBalances() 的参数 */
124
+ export interface SolBalancesOptions extends RequestOptions, SlotOptions {}
125
+
126
+ /** multiBalances() 的单项:一个钱包和要查的代币 */
127
+ export interface BalanceQuery {
128
+ owner: string
129
+ mints: readonly string[]
130
+ }
131
+
132
+ /** 一次查询的上下文:取消、最低 slot,以及读到的 slot(各请求 context.slot 的最小值) */
133
+ interface QueryContext {
134
+ signal?: AbortSignal
135
+ minContextSlot?: number
136
+ slot?: number
137
+ }
138
+
139
+ /** 节点高度不够时的重试间隔与总时长 */
140
+ const BEHIND_RETRY_INTERVAL = 500
141
+ const BEHIND_RETRY_TIMEOUT = 10_000
142
+
65
143
  const MAX_ACCOUNTS_PER_REQUEST = 100
66
144
  /** 同时进行的 getMultipleAccounts 请求数上限 */
67
145
  const MAX_PARALLEL_ACCOUNT_REQUESTS = 3
68
146
  /** #fetchAccounts 里没能读到(节点问题)的账户 */
69
147
  const FAILED = Symbol('failed')
70
148
 
71
- /** 可以降级处理的错误:网络 / HTTP 错误、节点限制(限频、需要 Key 等)、节点不在期望的网络上;其他(参数错误、程序 bug)照常抛出 */
149
+ /** 可以降级处理的错误:网络 / HTTP 错误、节点限制(限频、需要 Key 等)、节点不在期望的网络上、所有节点都失败;其他(参数错误、程序 bug)照常抛出 */
72
150
  function isRecoverable(err: unknown): boolean {
73
- return err instanceof HttpError || (err instanceof RpcError && err.nodeFault) || err instanceof NetworkMismatchError
151
+ return err instanceof HttpError || (err instanceof RpcError && err.nodeFault) || err instanceof NetworkMismatchError || err instanceof AllNodesFailedError
152
+ }
153
+
154
+ /** 查询带上取消:已取消时不执行;中途取消时立即 reject,查询本身继续(结果丢弃) */
155
+ function abortable<T>(signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T> {
156
+ if (!signal) {
157
+ return run()
158
+ }
159
+ if (signal.aborted) {
160
+ return Promise.reject(signal.reason)
161
+ }
162
+ return new Promise<T>((resolve, reject) => {
163
+ const onAbort = () => reject(signal.reason)
164
+ signal.addEventListener('abort', onAbort, { once: true })
165
+ run()
166
+ .then(resolve, reject)
167
+ .finally(() => signal.removeEventListener('abort', onAbort))
168
+ })
169
+ }
170
+
171
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
172
+ return new Promise((resolve, reject) => {
173
+ if (signal?.aborted) {
174
+ reject(signal.reason)
175
+ return
176
+ }
177
+ const timer = setTimeout(() => {
178
+ signal?.removeEventListener('abort', onAbort)
179
+ resolve()
180
+ }, ms)
181
+ const onAbort = () => {
182
+ clearTimeout(timer)
183
+ reject(signal?.reason)
184
+ }
185
+ signal?.addEventListener('abort', onAbort, { once: true })
186
+ })
187
+ }
188
+
189
+ const TOKEN_PROGRAMS: readonly string[] = [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]
190
+
191
+ // 用 Map:普通对象会让 'constructor' 等原型上的名字通过校验
192
+ const PROGRAM_ALIASES = new Map<string, string>([
193
+ ['spl', TOKEN_PROGRAM_ID],
194
+ ['token-2022', TOKEN_2022_PROGRAM_ID],
195
+ [TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID],
196
+ [TOKEN_2022_PROGRAM_ID, TOKEN_2022_PROGRAM_ID],
197
+ ])
198
+
199
+ /**
200
+ * 简写 / 程序地址转成程序地址(顺序固定为 Token、Token-2022)。
201
+ * 不认识的值和空数组报错,避免拼错或漏选时悄悄过滤掉全部代币
202
+ */
203
+ function resolvePrograms(tokenPrograms: readonly TokenProgramOption[] | undefined): readonly string[] {
204
+ if (tokenPrograms === undefined) {
205
+ return TOKEN_PROGRAMS
206
+ }
207
+ if (tokenPrograms.length === 0) {
208
+ throw new Error(`tokenPrograms is empty (expected 'spl' and / or 'token-2022'; omit it to include both)`)
209
+ }
210
+ const wanted = new Set(
211
+ tokenPrograms.map((value) => {
212
+ const program = PROGRAM_ALIASES.get(value)
213
+ if (!program) {
214
+ throw new Error(`Unknown token program: ${value} (expected 'spl', 'token-2022' or a token program id)`)
215
+ }
216
+ return program
217
+ }),
218
+ )
219
+ return TOKEN_PROGRAMS.filter((program) => wanted.has(program))
220
+ }
221
+
222
+ /** 所属程序已知且不在允许范围内 */
223
+ function isExcluded(tokenProgram: string | undefined, allowed: readonly string[]): boolean {
224
+ return tokenProgram !== undefined && !allowed.includes(tokenProgram)
74
225
  }
75
226
 
76
227
  /** 按 mint 汇总后精度 0、数量 1 的持仓视为 NFT(ownerTokens 与 ownerNfts 共用) */
@@ -90,6 +241,8 @@ const TOKEN_STANDARDS: readonly TokenStandard[] = [
90
241
  interface TokenMeta {
91
242
  decimals?: number
92
243
  tokenProgram?: string
244
+ /** tokenProgram 来自导入的缓存、还没从链上确认过:ATA 模式第一次用时按程序未知处理(推导两个程序的 ATA、读 mint 确认) */
245
+ programUnverified?: boolean
93
246
  /** null:确认没有元数据 */
94
247
  name?: string | null
95
248
  symbol?: string | null
@@ -106,13 +259,168 @@ const NAME_TTL = 60 * 60 * 1000
106
259
  const MAX_CACHED_TOKENS = 50_000
107
260
  const tokenMetaCache = new Map<string, CachedMeta>()
108
261
 
262
+ // 已知数据为空的钱包账户(系统账户或不存在):ATA 模式下和代币账户放在同一个请求里读,不必单独带 dataSlice 多发一个请求。
263
+ // 按 scope + 地址记,最多 1 万个,超出淘汰最早的;读到不是系统账户(如程序账户)时移除
264
+ const MAX_SMALL_OWNERS = 10_000
265
+ const smallOwners = new Set<string>()
266
+
267
+ function isSmallOwner(scope: string, owner: string): boolean {
268
+ return smallOwners.has(`${scope}:${owner}`)
269
+ }
270
+
271
+ /** 根据只读 lamports 的结果判断钱包账户是否“数据为空”(dataSlice 读不到数据长度,按所属程序判断:系统账户的数据为空) */
272
+ function learnOwner(scope: string, owner: string, account: AccountInfo | null): void {
273
+ const key = `${scope}:${owner}`
274
+ if (account === null || account.owner === SYSTEM_PROGRAM_ID) {
275
+ smallOwners.delete(key)
276
+ smallOwners.add(key)
277
+ if (smallOwners.size > MAX_SMALL_OWNERS) {
278
+ smallOwners.delete(smallOwners.values().next().value as string)
279
+ }
280
+ } else {
281
+ smallOwners.delete(key)
282
+ }
283
+ }
284
+
109
285
  let clientIds = 0
110
286
 
287
+ /** 缓存写入监听(persistTokenMetaCache 用) */
288
+ const cacheWriteListeners = new Set<() => void>()
289
+
111
290
  /** 测试用:清空代币信息缓存 */
112
291
  export function resetTokenMetaCache(): void {
292
+ smallOwners.clear()
113
293
  tokenMetaCache.clear()
114
294
  }
115
295
 
296
+ /** exportTokenMetaCache() 的结果,可以 JSON 序列化 */
297
+ export interface TokenMetaSnapshot {
298
+ version: 1
299
+ /** [网络:mint, 信息];namesAt 是 name / symbol 的写入时间(毫秒时间戳),导入后继续按 1 小时过期 */
300
+ entries: Array<[string, { decimals?: number; tokenProgram?: string; name?: string | null; symbol?: string | null; namesAt?: number }]>
301
+ }
302
+
303
+ const SHARED_SCOPES = new Set<string>(['mainnet', 'devnet', 'testnet'])
304
+
305
+ /**
306
+ * 导出代币信息缓存(decimals / 所属程序 / name / symbol),用于持久化:刷新页面后导入,不必重查 decimals。
307
+ * 只导出按网络共享的条目(自定义节点且没指定 cluster 的客户端,缓存只在该客户端内,不导出)。
308
+ * maxEntries:只导出最近写入的这么多条
309
+ */
310
+ export function exportTokenMetaCache(maxEntries = Number.POSITIVE_INFINITY): TokenMetaSnapshot {
311
+ const entries: TokenMetaSnapshot['entries'] = []
312
+ for (const [key, meta] of tokenMetaCache) {
313
+ if (SHARED_SCOPES.has(key.slice(0, key.indexOf(':')))) {
314
+ const { programUnverified: _, ...exported } = meta
315
+ entries.push([key, exported])
316
+ }
317
+ }
318
+ return { version: 1, entries: entries.slice(Math.max(0, entries.length - maxEntries)) }
319
+ }
320
+
321
+ /** 导入 exportTokenMetaCache() 的结果;格式不对的条目跳过,内存里已有的条目不覆盖 */
322
+ export function importTokenMetaCache(snapshot: unknown): void {
323
+ const entries = (snapshot as Partial<TokenMetaSnapshot> | null)?.entries
324
+ if ((snapshot as Partial<TokenMetaSnapshot> | null)?.version !== 1 || !Array.isArray(entries)) {
325
+ return
326
+ }
327
+ for (const entry of entries) {
328
+ const meta = validMeta(entry)
329
+ if (meta && !tokenMetaCache.has(entry[0])) {
330
+ tokenMetaCache.set(entry[0], meta)
331
+ if (tokenMetaCache.size > MAX_CACHED_TOKENS) {
332
+ tokenMetaCache.delete(tokenMetaCache.keys().next().value as string)
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ function validMeta(entry: unknown): CachedMeta | null {
339
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string') {
340
+ return null
341
+ }
342
+ const [key, raw] = entry as [string, Record<string, unknown>]
343
+ const colon = key.indexOf(':')
344
+ if (!SHARED_SCOPES.has(key.slice(0, colon)) || !isAddress(key.slice(colon + 1)) || !raw || typeof raw !== 'object') {
345
+ return null
346
+ }
347
+ const meta: CachedMeta = {}
348
+ if (typeof raw.decimals === 'number' && Number.isInteger(raw.decimals) && raw.decimals >= 0 && raw.decimals <= 255) meta.decimals = raw.decimals
349
+ if (raw.tokenProgram === TOKEN_PROGRAM_ID || raw.tokenProgram === TOKEN_2022_PROGRAM_ID) {
350
+ meta.tokenProgram = raw.tokenProgram
351
+ meta.programUnverified = true
352
+ }
353
+ // 写入时间只接受有限值、且不晚于现在(留 1 分钟时钟误差):未来的时间会让 name / symbol 永不过期,截到现在
354
+ if (
355
+ typeof raw.namesAt === 'number' &&
356
+ Number.isFinite(raw.namesAt) &&
357
+ (typeof raw.name === 'string' || raw.name === null) &&
358
+ (typeof raw.symbol === 'string' || raw.symbol === null)
359
+ ) {
360
+ meta.name = raw.name
361
+ meta.symbol = raw.symbol
362
+ meta.namesAt = Math.min(raw.namesAt, Date.now())
363
+ }
364
+ return Object.keys(meta).length ? meta : null
365
+ }
366
+
367
+ export interface PersistTokenMetaOptions {
368
+ /** 存储的键,默认 'w3lib:solana-token-meta' */
369
+ key?: string
370
+ /** 最多保存多少条(最近写入的优先),默认 5000 */
371
+ maxEntries?: number
372
+ }
373
+
374
+ /**
375
+ * 把代币信息缓存持久化到同步的键值存储(如 localStorage):调用时读入,之后缓存有写入就防抖(约 1 秒)整体写回。
376
+ * 返回停止函数(会立即写回还没写的变化)。读到格式不对的数据会丢弃;写入失败(如存储已满)忽略。
377
+ *
378
+ * ```ts
379
+ * persistTokenMetaCache(localStorage) // 刷新页面后 decimals 不必重查
380
+ * ```
381
+ */
382
+ export function persistTokenMetaCache(
383
+ store: { getItem(key: string): string | null; setItem(key: string, value: string): void },
384
+ options: PersistTokenMetaOptions = {},
385
+ ): () => void {
386
+ const key = options.key ?? 'w3lib:solana-token-meta'
387
+ const maxEntries = options.maxEntries ?? 5000
388
+ try {
389
+ const raw = store.getItem(key)
390
+ if (raw) {
391
+ importTokenMetaCache(JSON.parse(raw))
392
+ }
393
+ } catch {
394
+ // 格式不对:丢弃
395
+ }
396
+ let timer: ReturnType<typeof setTimeout> | null = null
397
+ const write = () => {
398
+ timer = null
399
+ try {
400
+ store.setItem(key, JSON.stringify(exportTokenMetaCache(maxEntries)))
401
+ } catch {
402
+ // 存储已满等:忽略
403
+ }
404
+ }
405
+ const onWrite = () => {
406
+ if (!timer) {
407
+ timer = setTimeout(write, 1000)
408
+ ;(timer as { unref?: () => void }).unref?.()
409
+ }
410
+ }
411
+ cacheWriteListeners.add(onWrite)
412
+ return () => {
413
+ cacheWriteListeners.delete(onWrite)
414
+ if (timer) {
415
+ clearTimeout(timer)
416
+ write()
417
+ }
418
+ }
419
+ }
420
+
421
+ /** #loadMeta 没能补上 decimals 的原因;failed 为节点问题(tolerant 时) */
422
+ type MetaFailure = 'failed' | 'not-found' | 'not-token'
423
+
116
424
  interface Holding {
117
425
  amount: bigint
118
426
  decimals: number
@@ -123,7 +431,32 @@ interface Holding {
123
431
 
124
432
  interface ParsedTokenAccount {
125
433
  pubkey: string
126
- account: { owner: string; data: { parsed?: { info?: { mint?: string; owner?: string; tokenAmount?: { amount?: string; decimals?: number } } } } }
434
+ account: {
435
+ owner: string
436
+ // 节点解析不了的账户(如 Token-2022 的新扩展)会退回 base64:["…", "base64"]
437
+ data: { parsed?: { info?: { mint?: string; owner?: string; tokenAmount?: { amount?: string; decimals?: number } } } } | [string, string] | string
438
+ }
439
+ }
440
+
441
+ /** jsonParsed 结果里的一个代币账户:节点解析好的直接取;退回 base64 的自己解析(这时没有 decimals,要从 mint 账户补) */
442
+ function readTokenEntry(entry: ParsedTokenAccount): { mint: string; amount: bigint; decimals: number | undefined } | null {
443
+ const data = entry.account?.data
444
+ if (data && !Array.isArray(data) && typeof data === 'object') {
445
+ const info = data.parsed?.info
446
+ if (info?.mint && info.tokenAmount?.decimals !== undefined) {
447
+ return { mint: info.mint, amount: BigInt(info.tokenAmount.amount ?? '0'), decimals: info.tokenAmount.decimals }
448
+ }
449
+ }
450
+ const encoded = Array.isArray(data) ? (data[1] === 'base64' ? data[0] : undefined) : typeof data === 'string' ? data : undefined
451
+ if (encoded === undefined) {
452
+ return null
453
+ }
454
+ try {
455
+ const parsed = parseTokenAccount(base64.decode(encoded))
456
+ return { mint: parsed.mint, amount: parsed.amount, decimals: undefined }
457
+ } catch {
458
+ return null
459
+ }
127
460
  }
128
461
 
129
462
  /**
@@ -140,6 +473,14 @@ interface ParsedTokenAccount {
140
473
  *
141
474
  * 同一 tick 内发起的 RPC 调用会合并成一个 JSON-RPC 批量请求;账户读取按 100 个一组用 getMultipleAccounts。
142
475
  */
476
+ /** ATA 模式下单个代币的查询计划(multiBalances 时多个钱包共用一次 getMultipleAccounts) */
477
+ type AtaItem =
478
+ /** owner:钱包地址(主币余额按地址从两组结果里取) */
479
+ | { kind: 'native'; mint: string; owner: string }
480
+ | { kind: 'invalid'; mint: string }
481
+ | { kind: 'excluded'; mint: string }
482
+ | { kind: 'token'; mint: string; mintIndex: number; metadataIndex: number; atas: Array<{ program: string; index: number }> }
483
+
143
484
  export class SolanaClient {
144
485
  readonly transport: RpcTransport
145
486
  readonly #genesis: () => Promise<string>
@@ -165,194 +506,395 @@ export class SolanaClient {
165
506
  }
166
507
 
167
508
  /** 直接发 JSON-RPC 调用(同样参与自动合并与故障切换) */
168
- request<T = unknown>(method: string, params?: readonly unknown[]): Promise<T> {
169
- return this.transport.request<T>(method, params)
509
+ request<T = unknown>(method: string, params?: readonly unknown[], options: RequestOptions = {}): Promise<T> {
510
+ return abortable(options.signal, () => this.transport.request<T>(method, params))
511
+ }
512
+
513
+ /**
514
+ * 发一个读账户类的请求:params 最后一项是配置对象,传了 minContextSlot 时带上;
515
+ * 节点高度不够(所有节点都落后)时每 500ms 重试,最多约 10 秒;记录返回的 context.slot(取最小值)
516
+ */
517
+ async #call<T>(ctx: QueryContext, method: string, params: unknown[]): Promise<T> {
518
+ const last = params[params.length - 1]
519
+ const sent =
520
+ ctx.minContextSlot === undefined ? params : [...params.slice(0, -1), { ...(last as object), minContextSlot: ctx.minContextSlot }]
521
+ const started = Date.now()
522
+ for (;;) {
523
+ try {
524
+ const res = await this.transport.request<T>(method, sent)
525
+ const slot = (res as { context?: { slot?: unknown } } | null)?.context?.slot
526
+ if (typeof slot === 'number') {
527
+ ctx.slot = ctx.slot === undefined ? slot : Math.min(ctx.slot, slot)
528
+ }
529
+ return res
530
+ } catch (err) {
531
+ if (ctx.minContextSlot === undefined || !isBehind(err) || Date.now() - started + BEHIND_RETRY_INTERVAL > BEHIND_RETRY_TIMEOUT) {
532
+ throw err
533
+ }
534
+ await sleep(BEHIND_RETRY_INTERVAL, ctx.signal)
535
+ }
536
+ }
537
+ }
538
+
539
+ /** 结果项带上 slot(传了 withSlot / minContextSlot,且这次查询发了请求时) */
540
+ #attachSlot<T extends object>(items: T[], ctx: QueryContext, options: SlotOptions): T[] {
541
+ if ((!options.withSlot && options.minContextSlot === undefined) || ctx.slot === undefined) {
542
+ return items
543
+ }
544
+ const slot = ctx.slot
545
+ return items.map((item) => ({ ...item, slot }))
170
546
  }
171
547
 
172
548
  /**
173
549
  * 批量读取账户(getMultipleAccounts,按 100 个一组,去重),结果与 addresses 一一对应。
174
550
  * 账户不存在或地址非法时为 null。
175
551
  */
176
- async accounts(addresses: readonly string[]): Promise<(AccountInfo | null)[]> {
177
- const fetched = await this.#fetchAccounts(addresses, false)
552
+ accounts(addresses: readonly string[], options: AccountsOptions = {}): Promise<(AccountInfo | null)[]> {
553
+ return abortable(options.signal, () => this.#accounts(addresses, { signal: options.signal, minContextSlot: options.minContextSlot }))
554
+ }
555
+
556
+ async #accounts(addresses: readonly string[], ctx: QueryContext): Promise<(AccountInfo | null)[]> {
557
+ const fetched = await this.#fetchAccounts(addresses, false, ctx)
178
558
  return addresses.map((address) => (fetched.get(address) as AccountInfo | null | undefined) ?? null)
179
559
  }
180
560
 
181
561
  /**
182
- * getMultipleAccounts:去重、按 100 个一组,最多同时 3 组(避免大批量时触发节点限频)。
183
- * tolerant 为 true 时,某组因节点问题(网络、限频等)失败不抛错:该组标记为 FAILED,并且不再发后面的组(节点已经在限频了)。
562
+ * 只读 lamports(主币余额):getMultipleAccounts 带 dataSlice { offset: 0, length: 0 },不读回账户数据——
563
+ * 钱包可能是数据很大的程序账户(上百 KB),整份读回会拖慢甚至超时。结果与 addresses 一一对应,账户不存在或地址非法时为 null
184
564
  */
185
- async #fetchAccounts(addresses: readonly string[], tolerant: boolean): Promise<Map<string, AccountInfo | null | typeof FAILED>> {
186
- const unique = [...new Set(addresses.filter((a) => isAddress(a)))]
187
- const chunks: string[][] = []
188
- for (let i = 0; i < unique.length; i += MAX_ACCOUNTS_PER_REQUEST) {
189
- chunks.push(unique.slice(i, i + MAX_ACCOUNTS_PER_REQUEST))
190
- }
191
- const result = new Map<string, AccountInfo | null | typeof FAILED>()
565
+ async #lamports(addresses: readonly string[], ctx: QueryContext): Promise<(bigint | null)[]> {
566
+ const [fetched] = await this.#fetchGroups([{ addresses, lamportsOnly: true }], false, ctx)
567
+ return addresses.map((address) => (fetched?.get(address) as AccountInfo | null | undefined)?.lamports ?? null)
568
+ }
569
+
570
+ /** getMultipleAccounts(读账户数据):见 #fetchGroups */
571
+ async #fetchAccounts(addresses: readonly string[], tolerant: boolean, ctx: QueryContext): Promise<Map<string, AccountInfo | null | typeof FAILED>> {
572
+ const [fetched] = await this.#fetchGroups([{ addresses, lamportsOnly: false }], tolerant, ctx)
573
+ return fetched as Map<string, AccountInfo | null | typeof FAILED>
574
+ }
575
+
576
+ /**
577
+ * getMultipleAccounts:每组去重、按 100 个一组拆分,所有组的请求一起排队,最多同时 3 个(避免大批量时触发节点限频)。
578
+ * lamportsOnly 的组带 dataSlice(长度 0,对同一请求里的所有账户生效,所以只要 lamports 的账户要单独成组)。
579
+ * tolerant 为 true 时,某个请求因节点问题(网络、限频等)失败不抛错:该请求的账户标记为 FAILED,并且不再发后面的请求(节点已经在限频了);
580
+ * 否则第一个错误抛出,后面的请求也不再发。结果按组返回
581
+ */
582
+ async #fetchGroups(
583
+ groups: ReadonlyArray<{ addresses: readonly string[]; lamportsOnly: boolean }>,
584
+ tolerant: boolean,
585
+ ctx: QueryContext,
586
+ ): Promise<Array<Map<string, AccountInfo | null | typeof FAILED>>> {
587
+ const chunks: Array<{ group: number; addresses: string[]; lamportsOnly: boolean }> = []
588
+ groups.forEach(({ addresses, lamportsOnly }, group) => {
589
+ const unique = [...new Set(addresses.filter((a) => isAddress(a)))]
590
+ for (let i = 0; i < unique.length; i += MAX_ACCOUNTS_PER_REQUEST) {
591
+ chunks.push({ group, addresses: unique.slice(i, i + MAX_ACCOUNTS_PER_REQUEST), lamportsOnly })
592
+ }
593
+ })
594
+ const results = groups.map(() => new Map<string, AccountInfo | null | typeof FAILED>())
192
595
  let aborted = false
193
596
  for (let i = 0; i < chunks.length; i += MAX_PARALLEL_ACCOUNT_REQUESTS) {
194
597
  const wave = chunks.slice(i, i + MAX_PARALLEL_ACCOUNT_REQUESTS)
598
+ // 已取消的查询不再发后面的请求
599
+ if (ctx.signal?.aborted) {
600
+ throw ctx.signal.reason
601
+ }
195
602
  if (aborted) {
196
- wave.flat().forEach((address) => result.set(address, FAILED))
603
+ wave.forEach((chunk) => chunk.addresses.forEach((address) => results[chunk.group]?.set(address, FAILED)))
197
604
  continue
198
605
  }
199
- await Promise.all(
606
+ // 同一批里的请求同时发出(合并成一个 JSON-RPC 批量请求);一个失败时等这一批都结束再抛,后面的批次不再发
607
+ const settled = await Promise.allSettled(
200
608
  wave.map(async (chunk) => {
609
+ const result = results[chunk.group] as Map<string, AccountInfo | null | typeof FAILED>
201
610
  try {
202
- const res = await this.transport.request<{ value: Array<RawAccount | null> }>('getMultipleAccounts', [
203
- chunk,
204
- { encoding: 'base64', commitment: this.#commitment },
611
+ const res = await this.#call<{ value: Array<RawAccount | null> }>(ctx, 'getMultipleAccounts', [
612
+ chunk.addresses,
613
+ { encoding: 'base64', commitment: this.#commitment, ...(chunk.lamportsOnly ? { dataSlice: { offset: 0, length: 0 } } : {}) },
205
614
  ])
206
- chunk.forEach((address, j) => result.set(address, toAccountInfo(address, res?.value?.[j] ?? null)))
615
+ chunk.addresses.forEach((address, j) => result.set(address, toAccountInfo(address, res?.value?.[j] ?? null)))
207
616
  } catch (err) {
208
617
  if (!tolerant || !isRecoverable(err)) {
209
618
  throw err
210
619
  }
211
620
  aborted = true
212
- chunk.forEach((address) => result.set(address, FAILED))
621
+ chunk.addresses.forEach((address) => result.set(address, FAILED))
213
622
  }
214
623
  }),
215
624
  )
625
+ const failed = settled.find((r): r is PromiseRejectedResult => r.status === 'rejected')
626
+ if (failed) {
627
+ throw failed.reason
628
+ }
216
629
  }
217
- return result
630
+ return results
218
631
  }
219
632
 
220
- /** 批量查多个地址的 SOL 余额(一次 getMultipleAccounts;账户不存在视为 0) */
221
- async solBalances(addresses: readonly string[]): Promise<SolBalance[]> {
222
- const accounts = await this.accounts(addresses)
223
- return addresses.map((address, i) => {
224
- if (!isAddress(address)) {
225
- return { address, balance: '0', formatted: '0', success: false }
226
- }
227
- const lamports = accounts[i]?.lamports ?? 0n
228
- return { address, balance: lamports.toString(), formatted: formatAmount(lamports, SOL_DECIMALS), success: true }
633
+ /** 批量查多个地址的 SOL 余额(getMultipleAccounts 只读 lamports、不读回账户数据;账户不存在视为 0) */
634
+ solBalances(addresses: readonly string[], options: SolBalancesOptions = {}): Promise<SolBalance[]> {
635
+ return abortable(options.signal, async () => {
636
+ const ctx = this.#context(options)
637
+ const balances = await this.#lamports(addresses, ctx)
638
+ const list = addresses.map((address, i): SolBalance => {
639
+ if (!isAddress(address)) {
640
+ return { address, balance: '0', formatted: '0', success: false, error: 'invalid-address' }
641
+ }
642
+ const lamports = balances[i] ?? 0n
643
+ return { address, balance: lamports.toString(), formatted: formatUnits(lamports, SOL_DECIMALS), success: true }
644
+ })
645
+ return this.#attachSlot(list, ctx, options)
229
646
  })
230
647
  }
231
648
 
649
+ #context(options: RequestOptions & SlotOptions): QueryContext {
650
+ return { signal: options.signal, minContextSlot: options.minContextSlot }
651
+ }
652
+
232
653
  /**
233
654
  * 批量查余额(主币 SOL + SPL Token + Token-2022),返回原始余额、decimals 和换算后的数值。
234
655
  *
235
- * - 传 mints:本地推导 ATA,与主币账户、mint 账户一起用 getMultipleAccounts 一次读完,免费节点也能用
656
+ * - 传 mints:本地推导 ATA,与 mint 账户一起用 getMultipleAccounts 读完,免费节点也能用;主币只读 lamports(dataSlice),另成一组同时发出
657
+ * - 传 mints 且 accounts: 'all':每个代币按 mint 过滤查 getTokenAccountsByOwner,统计全部代币账户(含非 ATA)
236
658
  * - 不传 mints(或 scan: true):用 getTokenAccountsByOwner 扫描全部代币账户,返回所有余额大于 0 的代币
659
+ * - 失败项(success: false)带 error:invalid-address(mint 地址非法)/ not-found(mint 不存在)/ not-token(不是代币 mint)
660
+ * - tokenPrograms:只返回这些类型的代币,如 ['spl'] 不含 Token-2022(传了 mints 时,结果里去掉不符合的代币)
661
+ * - minContextSlot / withSlot:要求节点高度、返回读取时的 slot,见 SlotOptions
237
662
  */
238
- async balances(owner: string, mints?: readonly string[], options: BalancesOptions = {}): Promise<TokenBalance[]> {
239
- if (!isAddress(owner)) {
240
- throw new Error(`Invalid owner address: ${owner}`)
241
- }
663
+ balances(owner: string, mints?: readonly string[], options: BalancesOptions = {}): Promise<TokenBalance[]> {
664
+ return abortable(options.signal, async () => {
665
+ if (!isAddress(owner)) {
666
+ throw new Error(`Invalid owner address: ${owner}`)
667
+ }
668
+ const ctx = this.#context(options)
669
+ const list = await this.#balances(ctx, owner, mints, options, resolvePrograms(options.tokenPrograms))
670
+ return this.#attachSlot(list, ctx, options)
671
+ })
672
+ }
673
+
674
+ #balances(ctx: QueryContext, owner: string, mints: readonly string[] | undefined, options: BalancesOptions, programs: readonly string[]): Promise<TokenBalance[]> {
675
+ const withSymbol = options.symbol ?? false
242
676
  if (mints === undefined || options.scan) {
243
- return this.#scanBalances(owner, mints, options.symbol ?? false)
677
+ return this.#scanBalances(ctx, owner, mints, withSymbol, programs)
678
+ }
679
+ if (options.accounts === 'all') {
680
+ return this.#mintAccountBalances(ctx, owner, mints, withSymbol, programs)
244
681
  }
245
- return this.#ataBalances(owner, mints, options.symbol ?? false)
682
+ return this.#ataBalances(ctx, [{ owner, mints }], withSymbol, programs).then((lists) => lists[0] as TokenBalance[])
246
683
  }
247
684
 
248
- async #ataBalances(owner: string, mints: readonly string[], withSymbol: boolean): Promise<TokenBalance[]> {
685
+ /**
686
+ * 多个钱包一次查(如多钱包的持仓列表),结果与 queries 一一对应。
687
+ * ATA 模式(默认)下所有钱包的账户合并成一次(按 100 个一组的)getMultipleAccounts;
688
+ * scan / accounts: 'all' 时每个钱包各自查询(同一 tick 发出,合并成批量请求)。其他选项与 balances() 相同;
689
+ * withSlot 时所有钱包的结果带同一个 slot(整次查询的最小值)。任一 owner 非法时报错
690
+ *
691
+ * ```ts
692
+ * await sol.multiBalances([{ owner: a, mints: [NATIVE_MINT, USDC] }, { owner: b, mints: [USDC] }])
693
+ * ```
694
+ */
695
+ multiBalances(queries: readonly BalanceQuery[], options: BalancesOptions = {}): Promise<TokenBalance[][]> {
696
+ return abortable(options.signal, async () => {
697
+ for (const { owner } of queries) {
698
+ if (!isAddress(owner)) {
699
+ throw new Error(`Invalid owner address: ${owner}`)
700
+ }
701
+ }
702
+ const programs = resolvePrograms(options.tokenPrograms)
703
+ const ctx = this.#context(options)
704
+ const lists =
705
+ options.scan || options.accounts === 'all'
706
+ ? await Promise.all(queries.map(({ owner, mints }) => this.#balances(ctx, owner, mints, options, programs)))
707
+ : await this.#ataBalances(ctx, queries, options.symbol ?? false, programs)
708
+ return lists.map((list) => this.#attachSlot(list, ctx, options))
709
+ })
710
+ }
711
+
712
+ /** ATA 模式:所有钱包的 主币账户 / mint / 元数据 / ATA 一起读,结果按钱包分组 */
713
+ async #ataBalances(ctx: QueryContext, queries: readonly BalanceQuery[], withSymbol: boolean, allowed: readonly string[]): Promise<TokenBalance[][]> {
249
714
  const scope = this.#scope()
715
+ // 要数据的账户(mint、元数据、ATA)一组;钱包账户只要 lamports:已知是普通钱包(系统账户 / 不存在,数据为空)的
716
+ // 放进同一组(一个请求、同一个快照),其余(可能是数据很大的程序账户)单独一组带 dataSlice——同时发出,合并在同一个 JSON-RPC 批量请求里
250
717
  const addresses: string[] = []
251
- const plan = mints.map((mint) => {
252
- if (this.#nativeMints.has(mint)) {
253
- return { mint, native: true as const, owner: addresses.push(owner) - 1 }
254
- }
255
- if (!isAddress(mint)) {
256
- return { mint, native: false as const, invalid: true as const }
257
- }
258
- const meta = getMeta(scope, mint)
259
- const needMint = meta.decimals === undefined || meta.tokenProgram === undefined || (withSymbol && meta.symbol === undefined)
260
- const programs = meta.tokenProgram ? [meta.tokenProgram] : [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]
261
- return {
262
- mint,
263
- native: false as const,
264
- meta,
265
- mintIndex: needMint ? addresses.push(mint) - 1 : -1,
266
- metadataIndex: withSymbol && meta.symbol === undefined ? addresses.push(getMetadataAddress(mint)) - 1 : -1,
267
- atas: programs.map((program) => ({ program, index: addresses.push(getAssociatedTokenAddress(owner, mint, program)) - 1 })),
718
+ const owners: string[] = []
719
+ const plans = queries.map(({ owner, mints }) => mints.map((mint) => this.#ataPlan(scope, owner, mint, withSymbol, allowed, addresses, owners)))
720
+ const [dataMap, ownerMap] = await this.#fetchGroups(
721
+ [
722
+ { addresses, lamportsOnly: false },
723
+ { addresses: owners, lamportsOnly: true },
724
+ ],
725
+ false,
726
+ ctx,
727
+ )
728
+ // 记下钱包账户是否数据为空:单独读的按结果学习;已放进代币那组的,读到不再是系统账户(如被分配给程序)时移除,下次单独读
729
+ for (const owner of new Set(queries.map((q) => q.owner))) {
730
+ // null(账户不存在)也是结果,不能用 ?? 落到另一组
731
+ const account = ownerMap?.has(owner) ? ownerMap.get(owner) : dataMap?.get(owner)
732
+ if (account !== undefined && account !== FAILED) {
733
+ learnOwner(scope, owner, account)
268
734
  }
269
- })
270
- const accounts = await this.accounts(addresses)
735
+ }
736
+ const accounts = addresses.map((address) => (dataMap?.get(address) as AccountInfo | null | undefined) ?? null)
737
+ const lamportsOf = (owner: string) => ((ownerMap?.has(owner) ? ownerMap.get(owner) : dataMap?.get(owner)) as AccountInfo | null | undefined)?.lamports ?? 0n
738
+ return plans.map((plan) => plan.flatMap((item) => this.#ataResult(scope, item, accounts, lamportsOf, withSymbol, allowed)))
739
+ }
740
+
741
+ #ataPlan(scope: string, owner: string, mint: string, withSymbol: boolean, allowed: readonly string[], addresses: string[], owners: string[]): AtaItem {
742
+ if (this.#nativeMints.has(mint)) {
743
+ ;(isSmallOwner(scope, owner) ? addresses : owners).push(owner)
744
+ return { kind: 'native', mint, owner }
745
+ }
746
+ if (!isAddress(mint)) {
747
+ return { kind: 'invalid', mint }
748
+ }
749
+ const meta = getMeta(scope, mint)
750
+ // 导入的缓存里的所属程序没确认过:按未知处理(记错时不会让余额恒为 0)
751
+ const program = meta.programUnverified ? undefined : meta.tokenProgram
752
+ if (isExcluded(program, allowed)) {
753
+ return { kind: 'excluded', mint }
754
+ }
755
+ const needMint = meta.decimals === undefined || program === undefined || (withSymbol && meta.symbol === undefined)
756
+ // 所属程序未知时只推导允许的程序的 ATA,读到 mint 账户后再确认
757
+ const programs = program ? [program] : allowed
758
+ return {
759
+ kind: 'token',
760
+ mint,
761
+ mintIndex: needMint ? addresses.push(mint) - 1 : -1,
762
+ metadataIndex: withSymbol && meta.symbol === undefined ? addresses.push(getMetadataAddress(mint)) - 1 : -1,
763
+ atas: programs.map((program) => ({ program, index: addresses.push(getAssociatedTokenAddress(owner, mint, program)) - 1 })),
764
+ }
765
+ }
271
766
 
272
- return plan.map((item): TokenBalance => {
273
- if (item.native) {
274
- const lamports = accounts[item.owner]?.lamports ?? 0n
275
- return withSymbolField(
276
- { token: item.mint, native: true, balance: lamports.toString(), decimals: SOL_DECIMALS, formatted: formatAmount(lamports, SOL_DECIMALS), tokenProgram: null, success: true },
767
+ #ataResult(
768
+ scope: string,
769
+ item: AtaItem,
770
+ accounts: readonly (AccountInfo | null)[],
771
+ lamportsOf: (owner: string) => bigint,
772
+ withSymbol: boolean,
773
+ allowed: readonly string[],
774
+ ): TokenBalance[] {
775
+ if (item.kind === 'native') {
776
+ const lamports = lamportsOf(item.owner)
777
+ return [
778
+ withSymbolField(
779
+ { token: item.mint, native: true, balance: lamports.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(lamports, SOL_DECIMALS), tokenProgram: null, success: true },
277
780
  withSymbol,
278
781
  'SOL',
279
- )
280
- }
281
- if ('invalid' in item) {
282
- return withSymbolField({ token: item.mint, native: false, balance: '0', decimals: 0, formatted: '0', tokenProgram: null, success: false }, withSymbol, null)
283
- }
284
- const mintAccount = item.mintIndex === -1 ? null : (accounts[item.mintIndex] ?? null)
285
- const meta = this.#learn(
286
- scope,
287
- item.mint,
288
- mintAccount,
289
- item.metadataIndex === -1 ? null : (accounts[item.metadataIndex] ?? null),
290
- item.metadataIndex !== -1 && item.mintIndex !== -1,
291
- )
292
- // 代币账户由哪个程序持有就是哪个;mint 账户的 owner 也能确定程序
293
- let amount = 0n
294
- for (const ata of item.atas) {
295
- const account = accounts[ata.index]
296
- if (account && account.owner === ata.program) {
297
- try {
298
- amount = parseTokenAccount(account.data).amount
299
- if (meta.tokenProgram === undefined) {
300
- setMeta(scope, item.mint, { tokenProgram: ata.program })
301
- meta.tokenProgram = ata.program
302
- }
303
- } catch {
304
- // 不是代币账户
305
- }
306
- }
782
+ ),
783
+ ]
784
+ }
785
+ if (item.kind === 'excluded') {
786
+ return []
787
+ }
788
+ if (item.kind === 'invalid') {
789
+ return [failedBalance(item.mint, 'invalid-address', null, withSymbol, null)]
790
+ }
791
+ const mintAccount = item.mintIndex === -1 ? null : (accounts[item.mintIndex] ?? null)
792
+ const meta = this.#learn(
793
+ scope,
794
+ item.mint,
795
+ mintAccount,
796
+ item.metadataIndex === -1 ? null : (accounts[item.metadataIndex] ?? null),
797
+ item.metadataIndex !== -1 && item.mintIndex !== -1,
798
+ )
799
+ // 代币账户由哪个程序持有就是哪个;mint 账户的 owner 也能确定程序。
800
+ // 所属程序未知时推导了两个程序的 ATA,但一个 mint 只属于一个程序,另一个程序下不可能有这个 mint 的代币账户:找到就结束
801
+ let amount = 0n
802
+ for (const ata of item.atas) {
803
+ const account = accounts[ata.index]
804
+ if (!account || account.owner !== ata.program) {
805
+ continue
307
806
  }
308
- if (meta.decimals === undefined) {
309
- return withSymbolField({ token: item.mint, native: false, balance: '0', decimals: 0, formatted: '0', tokenProgram: meta.tokenProgram ?? null, success: false }, withSymbol, meta.symbol ?? null)
807
+ try {
808
+ amount = parseTokenAccount(account.data).amount
809
+ } catch {
810
+ continue // 不是代币账户
811
+ }
812
+ if (meta.tokenProgram === undefined) {
813
+ setMeta(scope, item.mint, { tokenProgram: ata.program })
814
+ meta.tokenProgram = ata.program
310
815
  }
311
- return withSymbolField(
816
+ break
817
+ }
818
+ // 读到 mint 账户后才知道是不允许的程序
819
+ if (isExcluded(meta.tokenProgram, allowed)) {
820
+ return []
821
+ }
822
+ if (meta.decimals === undefined) {
823
+ // decimals 未知时一定读了 mint 账户:不存在 / 存在但不是 mint
824
+ return [failedBalance(item.mint, mintAccount ? 'not-token' : 'not-found', meta.tokenProgram ?? null, withSymbol, meta.symbol ?? null)]
825
+ }
826
+ return [
827
+ withSymbolField(
312
828
  {
313
829
  token: item.mint,
314
830
  native: false,
315
831
  balance: amount.toString(),
316
832
  decimals: meta.decimals,
317
- formatted: formatAmount(amount, meta.decimals),
833
+ formatted: formatUnits(amount, meta.decimals),
318
834
  tokenProgram: meta.tokenProgram ?? null,
319
835
  success: true,
320
836
  },
321
837
  withSymbol,
322
838
  meta.symbol ?? null,
323
- )
324
- })
839
+ ),
840
+ ]
325
841
  }
326
842
 
327
- /** 扫描持有人的 SOL 余额和全部代币账户(Token + Token-2022),同一代币的多个账户合计 */
328
- async #scanHoldings(owner: string): Promise<{ sol: bigint; held: Map<string, Holding> }> {
843
+ /** 扫描持有人的 SOL 余额和全部代币账户(默认 Token + Token-2022,只请求 programs 里的程序),同一代币的多个账户合计 */
844
+ async #scanHoldings(
845
+ ctx: QueryContext,
846
+ owner: string,
847
+ programs: readonly string[] = TOKEN_PROGRAMS,
848
+ withSymbol = false,
849
+ ): Promise<{ sol: bigint; held: Map<string, Holding> }> {
329
850
  const scope = this.#scope()
330
- const [lamports, ...programs] = await Promise.all([
331
- this.transport.request<{ value: number | string }>('getBalance', [owner, { commitment: this.#commitment }]),
332
- ...[TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID].map((programId) =>
333
- this.transport.request<{ value: ParsedTokenAccount[] }>('getTokenAccountsByOwner', [
334
- owner,
335
- { programId },
336
- { encoding: 'jsonParsed', commitment: this.#commitment },
337
- ]),
851
+ const [lamports, ...results] = await Promise.all([
852
+ this.#call<{ value: number | string }>(ctx, 'getBalance', [owner, { commitment: this.#commitment }]),
853
+ ...programs.map((programId) =>
854
+ this.#call<{ value: ParsedTokenAccount[] }>(ctx, 'getTokenAccountsByOwner', [owner, { programId }, { encoding: 'jsonParsed', commitment: this.#commitment }]),
338
855
  ),
339
856
  ])
857
+ const entries = programs.flatMap((program, i) => (results[i]?.value ?? []).map((entry) => ({ program, entry })))
858
+ const held = await this.#collectHoldings(ctx, scope, entries, withSymbol)
859
+ return { sol: BigInt(lamports.value), held }
860
+ }
861
+
862
+ /**
863
+ * 把 getTokenAccountsByOwner(jsonParsed)的结果按 mint 汇总。节点解析不了、退回 base64 的账户自己解析,
864
+ * decimals 读 mint 账户补上(不丢弃,也不会把余额算成 0);mint 读不到 decimals 时才跳过
865
+ */
866
+ async #collectHoldings(
867
+ ctx: QueryContext,
868
+ scope: string,
869
+ entries: ReadonlyArray<{ program: string | undefined; entry: ParsedTokenAccount }>,
870
+ withSymbol = false,
871
+ ): Promise<Map<string, Holding>> {
872
+ const read = entries.flatMap(({ program, entry }) => {
873
+ const token = readTokenEntry(entry)
874
+ return token ? [{ ...token, program: entry.account?.owner || program }] : []
875
+ })
876
+ read.forEach(({ mint, decimals, program }) => decimals !== undefined && program !== undefined && setMeta(scope, mint, { decimals, tokenProgram: program }))
877
+ // 缺 decimals 或所属程序(节点没给账户 owner)的读 mint 账户补上;需要 symbol 时一起读,后面不必再读一遍
878
+ const unknown = [
879
+ ...new Set(read.filter((t) => getMeta(scope, t.mint).decimals === undefined || (t.program === undefined && getMeta(scope, t.mint).tokenProgram === undefined)).map((t) => t.mint)),
880
+ ]
881
+ if (unknown.length) {
882
+ // 补读 mint 不受 minContextSlot 约束、不计入扫描的 slot(decimals / 所属程序不会因为刚成交而变化);
883
+ // 节点问题时不让整次扫描失败,读不到的代币跳过
884
+ await this.#loadMeta({ signal: ctx.signal }, scope, unknown, withSymbol, true)
885
+ }
340
886
  const held = new Map<string, Holding>()
341
- ;[TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID].forEach((program, i) => {
342
- for (const entry of programs[i]?.value ?? []) {
343
- const info = entry.account?.data?.parsed?.info
344
- const mint = info?.mint
345
- const decimals = info?.tokenAmount?.decimals
346
- if (!mint || decimals === undefined) {
347
- continue
348
- }
349
- const prev = held.get(mint)
350
- const amount = BigInt(info?.tokenAmount?.amount ?? '0')
351
- held.set(mint, { amount: (prev?.amount ?? 0n) + amount, decimals, program, accounts: (prev?.accounts ?? 0) + 1 })
352
- setMeta(scope, mint, { decimals, tokenProgram: program })
887
+ for (const { mint, amount, program: entryProgram } of read) {
888
+ const meta = getMeta(scope, mint)
889
+ const decimals = meta.decimals
890
+ const program = entryProgram ?? (meta.programUnverified ? undefined : meta.tokenProgram)
891
+ if (decimals === undefined || program === undefined) {
892
+ continue
353
893
  }
354
- })
355
- return { sol: BigInt(lamports.value), held }
894
+ const prev = held.get(mint)
895
+ held.set(mint, { amount: (prev?.amount ?? 0n) + amount, decimals, program, accounts: (prev?.accounts ?? 0) + 1 })
896
+ }
897
+ return held
356
898
  }
357
899
 
358
900
  /**
@@ -362,87 +904,171 @@ export class SolanaClient {
362
904
  * - 默认不含 NFT(精度 0 且数量 1)和余额为 0 的代币账户(如已清空的 ATA),可用 includeNfts / includeZero 打开
363
905
  * - accounts 是该代币的代币账户数量;大于 1 时余额为所有账户的合计
364
906
  * - 顺序:SOL,然后 SPL Token 的代币,再然后 Token-2022 的代币(各自按节点返回的顺序;没有价格信息,不按价值排序)
907
+ * - tokenPrograms: ['spl'] 时不含 Token-2022 代币
908
+ * - withSlot 的 slot 只算余额扫描(元数据读取不计入)
365
909
  */
366
- async ownerTokens(owner: string, options: OwnerTokensOptions = {}): Promise<OwnedToken[]> {
367
- if (!isAddress(owner)) {
368
- throw new Error(`Invalid owner address: ${owner}`)
369
- }
370
- const { metadata = true, includeNative = true, includeZero = false, includeNfts = false } = options
371
- const scope = this.#scope()
372
- const { sol, held } = await this.#scanHoldings(owner)
373
- // Wrapped SOL 是独立的代币账户(需要 unwrap 才是 SOL),资产列表里单独列出(native: false)
374
- const tokens = [...held].filter(([, h]) => (includeZero || h.amount > 0n) && (includeNfts || !isNftHolding(h)))
375
- const failed = metadata ? await this.#loadMeta(scope, tokens.map(([mint]) => mint), true, true) : new Set<string>()
376
-
377
- const list: OwnedToken[] = tokens.map(([mint, h]) => {
378
- const item: OwnedToken = {
379
- token: mint,
380
- native: false,
381
- balance: h.amount.toString(),
382
- decimals: h.decimals,
383
- formatted: formatAmount(h.amount, h.decimals),
384
- tokenProgram: h.program,
385
- accounts: h.accounts,
386
- success: true,
387
- }
388
- if (!metadata) {
389
- return item
910
+ ownerTokens(owner: string, options: OwnerTokensOptions = {}): Promise<OwnedToken[]> {
911
+ return abortable(options.signal, async () => {
912
+ if (!isAddress(owner)) {
913
+ throw new Error(`Invalid owner address: ${owner}`)
390
914
  }
391
- const meta = getMeta(scope, mint)
392
- const status = failed.has(mint) ? 'failed' : meta.name == null && meta.symbol == null ? 'missing' : 'ok'
393
- return { ...item, name: meta.name ?? null, symbol: meta.symbol ?? null, metadataStatus: status }
915
+ const { metadata = true, includeNative = true, includeZero = false, includeNfts = false } = options
916
+ const scope = this.#scope()
917
+ const ctx = this.#context(options)
918
+ const { sol, held } = await this.#scanHoldings(ctx, owner, resolvePrograms(options.tokenPrograms), metadata)
919
+ const scanSlot = ctx.slot
920
+ // Wrapped SOL 是独立的代币账户(需要 unwrap 才是 SOL),资产列表里单独列出(native: false)
921
+ const tokens = [...held].filter(([, h]) => (includeZero || h.amount > 0n) && (includeNfts || !isNftHolding(h)))
922
+ // 元数据不受 minContextSlot 约束(不会因为刚成交而变化)
923
+ const failures = metadata ? await this.#loadMeta({ signal: options.signal }, scope, tokens.map(([mint]) => mint), true, true) : new Map<string, MetaFailure>()
924
+
925
+ const list: OwnedToken[] = tokens.map(([mint, h]) => {
926
+ const item: OwnedToken = {
927
+ token: mint,
928
+ native: false,
929
+ balance: h.amount.toString(),
930
+ decimals: h.decimals,
931
+ formatted: formatUnits(h.amount, h.decimals),
932
+ tokenProgram: h.program,
933
+ accounts: h.accounts,
934
+ success: true,
935
+ }
936
+ if (!metadata) {
937
+ return item
938
+ }
939
+ const meta = getMeta(scope, mint)
940
+ const status = failures.get(mint) === 'failed' ? 'failed' : meta.name == null && meta.symbol == null ? 'missing' : 'ok'
941
+ return { ...item, name: meta.name ?? null, symbol: meta.symbol ?? null, metadataStatus: status }
942
+ })
943
+ if (includeNative) {
944
+ const sol_: OwnedToken = { token: NATIVE_MINT, native: true, balance: sol.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(sol, SOL_DECIMALS), tokenProgram: null, accounts: 1, success: true }
945
+ list.unshift(metadata ? { ...sol_, name: 'Solana', symbol: 'SOL', metadataStatus: 'ok' } : sol_)
946
+ }
947
+ return this.#attachSlot(list, { slot: scanSlot }, options)
394
948
  })
395
- if (includeNative) {
396
- const sol_: OwnedToken = { token: NATIVE_MINT, native: true, balance: sol.toString(), decimals: SOL_DECIMALS, formatted: formatAmount(sol, SOL_DECIMALS), tokenProgram: null, accounts: 1, success: true }
397
- list.unshift(metadata ? { ...sol_, name: 'Solana', symbol: 'SOL', metadataStatus: 'ok' } : sol_)
398
- }
399
- return list
400
949
  }
401
950
 
402
- async #scanBalances(owner: string, mints: readonly string[] | undefined, withSymbol: boolean): Promise<TokenBalance[]> {
951
+ async #scanBalances(ctx: QueryContext, owner: string, mints: readonly string[] | undefined, withSymbol: boolean, allowed: readonly string[]): Promise<TokenBalance[]> {
403
952
  const scope = this.#scope()
404
- const { sol, held } = await this.#scanHoldings(owner)
953
+ // held 里只有允许的程序的代币
954
+ const { sol, held } = await this.#scanHoldings(ctx, owner, allowed, withSymbol)
405
955
 
406
956
  // Wrapped SOL(So111…112)默认按原生 SOL 处理:余额列表里只有一项 SOL,不再列出 wSOL 代币账户
407
- const targets = mints ?? [NATIVE_MINT, ...[...held].filter(([mint, h]) => h.amount > 0n && !this.#nativeMints.has(mint)).map(([mint]) => mint)]
957
+ // 第一项是主币:用 nativeMints 里的地址(nativeMints 不含 So111…112 时 wSOL 作为普通代币列出,不能再拿它当 SOL)
958
+ const nativeLead = this.#nativeMints.has(NATIVE_MINT) ? NATIVE_MINT : this.#nativeMints.values().next().value
959
+ const targets = mints ?? [
960
+ ...(nativeLead === undefined ? [] : [nativeLead]),
961
+ ...[...held].filter(([mint, h]) => h.amount > 0n && !this.#nativeMints.has(mint)).map(([mint]) => mint),
962
+ ]
408
963
  // 没持有的代币(拿不到 decimals)和缺 symbol 的代币:只读 mint / 元数据账户补上,不需要 ATA
409
- await this.#loadMeta(
964
+ const failures = await this.#loadMeta(
965
+ ctx,
410
966
  scope,
411
967
  targets.filter((mint) => !this.#nativeMints.has(mint) && isAddress(mint)),
412
968
  withSymbol,
413
969
  )
414
970
 
415
- return targets.map((mint): TokenBalance => {
971
+ return targets.flatMap((mint): TokenBalance[] => {
416
972
  if (this.#nativeMints.has(mint)) {
417
- return withSymbolField(
418
- { token: mint, native: true, balance: sol.toString(), decimals: SOL_DECIMALS, formatted: formatAmount(sol, SOL_DECIMALS), tokenProgram: null, success: true },
419
- withSymbol,
420
- 'SOL',
421
- )
973
+ return [
974
+ withSymbolField(
975
+ { token: mint, native: true, balance: sol.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(sol, SOL_DECIMALS), tokenProgram: null, success: true },
976
+ withSymbol,
977
+ 'SOL',
978
+ ),
979
+ ]
422
980
  }
423
- const h = held.get(mint)
424
- if (!h) {
425
- // 没持有:余额为 0;decimals 来自 mint 账户(mint 不存在时 success 为 false)
426
- const meta = getMeta(scope, mint)
427
- return withSymbolField(
428
- { token: mint, native: false, balance: '0', decimals: meta.decimals ?? 0, formatted: '0', tokenProgram: meta.tokenProgram ?? null, success: meta.decimals !== undefined },
429
- withSymbol,
430
- meta.symbol ?? null,
431
- )
981
+ return this.#heldBalance(scope, mint, held.get(mint), failures, withSymbol, allowed)
982
+ })
983
+ }
984
+
985
+ /**
986
+ * 指定 mints、统计全部代币账户:每个代币一次按 mint 过滤的 getTokenAccountsByOwner(同一 tick 发出,自动合并)。
987
+ * mint 不存在 / 不是代币时节点返回参数错误,按“没持有”处理,再由 #loadMeta 确认原因
988
+ */
989
+ async #mintAccountBalances(ctx: QueryContext, owner: string, mints: readonly string[], withSymbol: boolean, allowed: readonly string[]): Promise<TokenBalance[]> {
990
+ const scope = this.#scope()
991
+ // 导入的、未经链上确认的所属程序不用来排除代币(可能记错):照常查询,从链上学到后再按程序过滤
992
+ const queried = [
993
+ ...new Set(
994
+ mints.filter((mint) => {
995
+ const meta = getMeta(scope, mint)
996
+ return !this.#nativeMints.has(mint) && isAddress(mint) && (meta.programUnverified || !isExcluded(meta.tokenProgram, allowed))
997
+ }),
998
+ ),
999
+ ]
1000
+ const [lamports, ...results] = await Promise.all([
1001
+ mints.some((mint) => this.#nativeMints.has(mint))
1002
+ ? this.#call<{ value: number | string }>(ctx, 'getBalance', [owner, { commitment: this.#commitment }])
1003
+ : Promise.resolve(null),
1004
+ ...queried.map((mint) =>
1005
+ this.#call<{ value: ParsedTokenAccount[] }>(ctx, 'getTokenAccountsByOwner', [owner, { mint }, { encoding: 'jsonParsed', commitment: this.#commitment }]).catch(
1006
+ (err: unknown) => {
1007
+ // "Invalid param: could not find mint" 等参数错误:mint 不存在或不是代币;节点问题照常抛出
1008
+ if (err instanceof RpcError && !err.nodeFault && err.code === -32602) return null
1009
+ throw err
1010
+ },
1011
+ ),
1012
+ ),
1013
+ ])
1014
+ const held = await this.#collectHoldings(
1015
+ ctx,
1016
+ scope,
1017
+ queried.flatMap((_, i) => (results[i]?.value ?? []).map((entry) => ({ program: entry.account?.owner, entry }))),
1018
+ withSymbol,
1019
+ )
1020
+ // 没持有的代币补 decimals(同时确认 mint 是否存在),需要时补 symbol
1021
+ const failures = await this.#loadMeta(ctx, scope, queried, withSymbol)
1022
+ const sol = BigInt(lamports?.value ?? 0)
1023
+
1024
+ return mints.flatMap((mint): TokenBalance[] => {
1025
+ if (this.#nativeMints.has(mint)) {
1026
+ return [
1027
+ withSymbolField(
1028
+ { token: mint, native: true, balance: sol.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(sol, SOL_DECIMALS), tokenProgram: null, success: true },
1029
+ withSymbol,
1030
+ 'SOL',
1031
+ ),
1032
+ ]
432
1033
  }
433
- return withSymbolField(
434
- { token: mint, native: false, balance: h.amount.toString(), decimals: h.decimals, formatted: formatAmount(h.amount, h.decimals), tokenProgram: h.program, success: true },
435
- withSymbol,
436
- getMeta(scope, mint).symbol ?? null,
437
- )
1034
+ return this.#heldBalance(scope, mint, held.get(mint), failures, withSymbol, allowed)
438
1035
  })
439
1036
  }
440
1037
 
1038
+ /** 扫描类查询(scan / accounts: 'all')里单个代币的结果;不允许的程序返回空数组 */
1039
+ #heldBalance(scope: string, mint: string, h: Holding | undefined, failures: Map<string, MetaFailure>, withSymbol: boolean, allowed: readonly string[]): TokenBalance[] {
1040
+ if (!isAddress(mint)) {
1041
+ return [failedBalance(mint, 'invalid-address', null, withSymbol, null)]
1042
+ }
1043
+ const meta = getMeta(scope, mint)
1044
+ if (!h) {
1045
+ // 没持有(或属于不允许的程序):余额为 0;decimals 来自 mint 账户
1046
+ if (isExcluded(meta.tokenProgram, allowed)) {
1047
+ return []
1048
+ }
1049
+ if (meta.decimals === undefined) {
1050
+ const failure = failures.get(mint)
1051
+ return [failedBalance(mint, failure === 'not-token' ? 'not-token' : 'not-found', meta.tokenProgram ?? null, withSymbol, meta.symbol ?? null)]
1052
+ }
1053
+ return [withSymbolField({ token: mint, native: false, balance: '0', decimals: meta.decimals, formatted: '0', tokenProgram: meta.tokenProgram ?? null, success: true }, withSymbol, meta.symbol ?? null)]
1054
+ }
1055
+ if (isExcluded(h.program, allowed)) {
1056
+ return []
1057
+ }
1058
+ return [
1059
+ withSymbolField(
1060
+ { token: mint, native: false, balance: h.amount.toString(), decimals: h.decimals, formatted: formatUnits(h.amount, h.decimals), tokenProgram: h.program, success: true },
1061
+ withSymbol,
1062
+ meta.symbol ?? null,
1063
+ ),
1064
+ ]
1065
+ }
1066
+
441
1067
  /**
442
1068
  * 补齐 decimals / 所属程序(以及需要时的 symbol / name):只读缺的 mint 账户和元数据账户。
443
- * tolerant 为 true 时节点问题不抛错,返回没能读到的 mint(这些代币的信息保持未知,不写缓存)。
1069
+ * 返回没能补上 decimals 的 mint 及原因:failed(tolerant 为 true 时的节点问题,信息保持未知、不写缓存)、not-found、not-token
444
1070
  */
445
- async #loadMeta(scope: string, mints: readonly string[], withSymbol: boolean, tolerant = false): Promise<Set<string>> {
1071
+ async #loadMeta(ctx: QueryContext, scope: string, mints: readonly string[], withSymbol: boolean, tolerant = false): Promise<Map<string, MetaFailure>> {
446
1072
  const plan = [...new Set(mints)].flatMap((mint) => {
447
1073
  const meta = getMeta(scope, mint)
448
1074
  const needSymbol = withSymbol && meta.symbol === undefined
@@ -451,35 +1077,44 @@ export class SolanaClient {
451
1077
  }
452
1078
  return [{ mint, metadata: needSymbol ? getMetadataAddress(mint) : null }]
453
1079
  })
454
- const failed = new Set<string>()
1080
+ const failures = new Map<string, MetaFailure>()
455
1081
  if (!plan.length) {
456
- return failed
1082
+ return failures
457
1083
  }
458
1084
  const fetched = await this.#fetchAccounts(
459
1085
  plan.flatMap(({ mint, metadata }) => (metadata ? [mint, metadata] : [mint])),
460
1086
  tolerant,
1087
+ ctx,
461
1088
  )
462
1089
  for (const { mint, metadata } of plan) {
463
1090
  const mintAccount = fetched.get(mint)
464
1091
  const metadataAccount = metadata ? fetched.get(metadata) : null
465
1092
  // mint 和元数据账户可能落在不同的请求里:任一个没读到都算失败,不能当成“没有元数据”缓存
466
1093
  if (mintAccount === FAILED || metadataAccount === FAILED) {
467
- failed.add(mint)
1094
+ failures.set(mint, 'failed')
468
1095
  continue
469
1096
  }
470
- this.#learn(scope, mint, mintAccount ?? null, metadataAccount ?? null, metadata !== null)
1097
+ if (this.#learn(scope, mint, mintAccount ?? null, metadataAccount ?? null, metadata !== null).decimals === undefined) {
1098
+ failures.set(mint, mintAccount ? 'not-token' : 'not-found')
1099
+ }
471
1100
  }
472
- return failed
1101
+ return failures
473
1102
  }
474
1103
 
475
1104
  /**
476
1105
  * 批量查代币详情(一次 getMultipleAccounts),可以选择返回哪些字段。
477
1106
  * name / symbol / uri 优先取 Token-2022 TokenMetadata 扩展,其次 Metaplex 元数据。
1107
+ * transferFee:Token-2022 转账手续费,请求时会顺带发一次 getEpochInfo(同一个批量请求里)选出当前生效的配置
478
1108
  */
479
- async tokens<const F extends TokenField = DefaultTokenField>(mints: readonly string[], options: TokensOptions<F> = {}): Promise<TokenDetails<F>[]> {
1109
+ tokens<const F extends TokenField = DefaultTokenField>(mints: readonly string[], options: TokensOptions<F> = {}): Promise<TokenDetails<F>[]> {
1110
+ return abortable(options.signal, () => this.#tokens<F>(mints, options))
1111
+ }
1112
+
1113
+ async #tokens<F extends TokenField>(mints: readonly string[], options: TokensOptions<F>): Promise<TokenDetails<F>[]> {
480
1114
  const fields = (options.fields ?? DEFAULT_TOKEN_FIELDS) as readonly F[]
481
1115
  const wanted = new Set<TokenField>(fields)
482
1116
  const scope = this.#scope()
1117
+ const ctx: QueryContext = { signal: options.signal }
483
1118
  const needsNames = wanted.has('name') || wanted.has('symbol') || wanted.has('uri')
484
1119
  const addresses: string[] = []
485
1120
  const plan = mints.map((mint) => {
@@ -487,12 +1122,13 @@ export class SolanaClient {
487
1122
  return { mint, mintIndex: -1, metadataIndex: -1 }
488
1123
  }
489
1124
  const meta = getMeta(scope, mint)
490
- // uri / supply / 权限不缓存,需要时总要读 mint;name / symbol 命中缓存时可以省掉元数据账户
1125
+ // uri / supply / 权限 / 手续费不缓存,需要时总要读 mint;name / symbol 命中缓存时可以省掉元数据账户
491
1126
  const cachedOnly =
492
1127
  !wanted.has('uri') &&
493
1128
  !wanted.has('supply') &&
494
1129
  !wanted.has('mintAuthority') &&
495
1130
  !wanted.has('freezeAuthority') &&
1131
+ !wanted.has('transferFee') &&
496
1132
  (!wanted.has('decimals') || meta.decimals !== undefined) &&
497
1133
  (!wanted.has('tokenProgram') || meta.tokenProgram !== undefined) &&
498
1134
  (!wanted.has('name') || meta.name !== undefined) &&
@@ -507,12 +1143,18 @@ export class SolanaClient {
507
1143
  metadataIndex: needsNames && !namesCached ? addresses.push(getMetadataAddress(mint)) - 1 : -1,
508
1144
  }
509
1145
  })
510
- const accounts = addresses.length ? await this.accounts(addresses) : []
1146
+ const [accounts, epoch] = await Promise.all([
1147
+ addresses.length ? this.#accounts(addresses, ctx) : Promise.resolve([] as (AccountInfo | null)[]),
1148
+ // 手续费按当前 epoch 选新 / 旧配置;和账户读取在同一个批量请求里
1149
+ wanted.has('transferFee') && addresses.length
1150
+ ? this.#call<{ epoch: number | string }>(ctx, 'getEpochInfo', [{ commitment: this.#commitment }]).then((res) => BigInt(res.epoch))
1151
+ : Promise.resolve(null),
1152
+ ])
511
1153
 
512
1154
  return plan.map(({ mint, mintIndex, metadataIndex }) => {
513
1155
  const native = this.#nativeMints.has(mint)
514
1156
  const values: Record<TokenField, unknown> & { exists: boolean } = native
515
- ? { exists: true, name: 'Solana', symbol: 'SOL', uri: null, decimals: SOL_DECIMALS, supply: null, tokenProgram: null, mintAuthority: null, freezeAuthority: null }
1157
+ ? { exists: true, name: 'Solana', symbol: 'SOL', uri: null, decimals: SOL_DECIMALS, supply: null, tokenProgram: null, mintAuthority: null, freezeAuthority: null, transferFee: null }
516
1158
  : this.#tokenValues(
517
1159
  scope,
518
1160
  mint,
@@ -520,23 +1162,42 @@ export class SolanaClient {
520
1162
  metadataIndex === -1 ? null : (accounts[metadataIndex] ?? null),
521
1163
  mintIndex !== -1,
522
1164
  mintIndex !== -1 && metadataIndex !== -1,
1165
+ epoch,
523
1166
  )
524
1167
  const out: Record<string, unknown> = { address: mint, native }
525
- let success = values.exists
1168
+ let missing: F | undefined
526
1169
  for (const field of fields) {
527
1170
  out[field] = values[field] ?? null
528
- // 权限为 null 表示没有权限,是有效值;主币没有 uri / supply / tokenProgram
529
- const nullable = field === 'mintAuthority' || field === 'freezeAuthority' || (native && (field === 'uri' || field === 'supply' || field === 'tokenProgram'))
1171
+ // 权限为 null 表示没有权限、手续费为 null 表示没有手续费,都是有效值;主币没有 uri / supply / tokenProgram
1172
+ const nullable =
1173
+ field === 'mintAuthority' || field === 'freezeAuthority' || field === 'transferFee' || (native && (field === 'uri' || field === 'supply' || field === 'tokenProgram'))
530
1174
  if (out[field] === null && !nullable) {
531
- success = false
1175
+ missing ??= field
532
1176
  }
533
1177
  }
534
1178
  if (wanted.has('supply')) {
535
1179
  const supply = values.supply as string | null
536
1180
  const decimals = values.decimals as number | null
537
- out.supplyFormatted = supply === null || decimals === null ? null : formatAmount(BigInt(supply), decimals)
1181
+ out.supplyFormatted = supply === null || decimals === null ? null : formatUnits(BigInt(supply), decimals)
1182
+ }
1183
+ const error: FailureReason | undefined = native
1184
+ ? undefined
1185
+ : !isAddress(mint)
1186
+ ? 'invalid-address'
1187
+ : !values.exists
1188
+ ? accounts[mintIndex]
1189
+ ? 'not-token'
1190
+ : 'not-found'
1191
+ : missing
1192
+ ? 'missing-field'
1193
+ : undefined
1194
+ out.success = error === undefined
1195
+ if (error) {
1196
+ out.error = error
1197
+ if (error === 'missing-field') {
1198
+ out.errorField = missing
1199
+ }
538
1200
  }
539
- out.success = success
540
1201
  return out as TokenDetails<F>
541
1202
  })
542
1203
  }
@@ -547,11 +1208,13 @@ export class SolanaClient {
547
1208
  mintAccount: AccountInfo | null,
548
1209
  metadataAccount: AccountInfo | null,
549
1210
  fetchedMint: boolean,
550
- lookedForNames = false,
1211
+ lookedForNames: boolean,
1212
+ epoch: bigint | null,
551
1213
  ): Record<TokenField, unknown> & { exists: boolean } {
552
1214
  const meta = this.#learn(scope, mint, mintAccount, metadataAccount, lookedForNames)
553
1215
  const info = mintAccount ? safeParseMint(mintAccount) : null
554
1216
  const uri = info?.metadata?.uri ?? (metadataAccount ? (safeParseMetaplex(metadataAccount)?.uri ?? null) : null)
1217
+ const feeConfig = info && mintAccount?.owner === TOKEN_2022_PROGRAM_ID ? parseTransferFeeConfig(mintAccount.data) : null
555
1218
  return {
556
1219
  // 读了 mint 账户就以它为准(不存在则 false);没读说明请求的字段全部命中缓存
557
1220
  exists: fetchedMint ? info !== null : true,
@@ -563,84 +1226,102 @@ export class SolanaClient {
563
1226
  tokenProgram: meta.tokenProgram ?? null,
564
1227
  mintAuthority: info?.mintAuthority ?? null,
565
1228
  freezeAuthority: info?.freezeAuthority ?? null,
1229
+ transferFee: feeConfig ? toTransferFeeConfig(feeConfig.older, feeConfig.newer, epoch) : null,
566
1230
  }
567
1231
  }
568
1232
 
569
1233
  /** 批量查 NFT 元数据(Metaplex;Token-2022 NFT 取 TokenMetadata 扩展),一次 getMultipleAccounts */
570
- async nfts(mints: readonly string[]): Promise<NftDetails[]> {
571
- const scope = this.#scope()
572
- const addresses: string[] = []
573
- const plan = mints.map((mint) =>
574
- isAddress(mint) ? { mint, metadataIndex: addresses.push(getMetadataAddress(mint)) - 1, mintIndex: addresses.push(mint) - 1 } : { mint, metadataIndex: -1, mintIndex: -1 },
575
- )
576
- const accounts = addresses.length ? await this.accounts(addresses) : []
577
- return plan.map(({ mint, metadataIndex, mintIndex }): NftDetails => {
578
- const metadataAccount = metadataIndex === -1 ? null : (accounts[metadataIndex] ?? null)
579
- const mintAccount = mintIndex === -1 ? null : (accounts[mintIndex] ?? null)
580
- this.#learn(scope, mint, mintAccount, metadataAccount, mintIndex !== -1)
581
- const metaplex = metadataAccount ? safeParseMetaplex(metadataAccount) : null
582
- const ext = mintAccount ? (safeParseMint(mintAccount)?.metadata ?? null) : null
583
- if (!metaplex && !ext) {
584
- return { mint, name: null, symbol: null, uri: null, collection: null, creators: [], sellerFeeBasisPoints: null, tokenStandard: null, isMutable: null, updateAuthority: null, success: false }
585
- }
586
- return {
587
- mint,
588
- name: ext?.name ?? metaplex?.name ?? null,
589
- symbol: ext?.symbol ?? metaplex?.symbol ?? null,
590
- uri: ext?.uri ?? metaplex?.uri ?? null,
591
- collection: metaplex?.collection ?? null,
592
- creators: metaplex?.creators ?? [],
593
- sellerFeeBasisPoints: metaplex?.sellerFeeBasisPoints ?? null,
594
- tokenStandard: metaplex?.tokenStandard == null ? null : (TOKEN_STANDARDS[metaplex.tokenStandard] ?? null),
595
- isMutable: metaplex?.isMutable ?? null,
596
- updateAuthority: metaplex?.updateAuthority ?? null,
597
- success: true,
598
- }
1234
+ nfts(mints: readonly string[], options: RequestOptions = {}): Promise<NftDetails[]> {
1235
+ return abortable(options.signal, async () => {
1236
+ const scope = this.#scope()
1237
+ const addresses: string[] = []
1238
+ const plan = mints.map((mint) =>
1239
+ isAddress(mint) ? { mint, metadataIndex: addresses.push(getMetadataAddress(mint)) - 1, mintIndex: addresses.push(mint) - 1 } : { mint, metadataIndex: -1, mintIndex: -1 },
1240
+ )
1241
+ const accounts = addresses.length ? await this.#accounts(addresses, { signal: options.signal }) : []
1242
+ return plan.map(({ mint, metadataIndex, mintIndex }): NftDetails => {
1243
+ const metadataAccount = metadataIndex === -1 ? null : (accounts[metadataIndex] ?? null)
1244
+ const mintAccount = mintIndex === -1 ? null : (accounts[mintIndex] ?? null)
1245
+ this.#learn(scope, mint, mintAccount, metadataAccount, mintIndex !== -1)
1246
+ const metaplex = metadataAccount ? safeParseMetaplex(metadataAccount) : null
1247
+ const ext = mintAccount ? (safeParseMint(mintAccount)?.metadata ?? null) : null
1248
+ if (!metaplex && !ext) {
1249
+ const error: FailureReason = mintIndex === -1 ? 'invalid-address' : !mintAccount ? 'not-found' : !safeParseMint(mintAccount) ? 'not-token' : 'no-metadata'
1250
+ return { mint, name: null, symbol: null, uri: null, collection: null, creators: [], sellerFeeBasisPoints: null, tokenStandard: null, isMutable: null, updateAuthority: null, success: false, error }
1251
+ }
1252
+ return {
1253
+ mint,
1254
+ name: ext?.name ?? metaplex?.name ?? null,
1255
+ symbol: ext?.symbol ?? metaplex?.symbol ?? null,
1256
+ uri: ext?.uri ?? metaplex?.uri ?? null,
1257
+ collection: metaplex?.collection ?? null,
1258
+ creators: metaplex?.creators ?? [],
1259
+ sellerFeeBasisPoints: metaplex?.sellerFeeBasisPoints ?? null,
1260
+ tokenStandard: metaplex?.tokenStandard == null ? null : (TOKEN_STANDARDS[metaplex.tokenStandard] ?? null),
1261
+ isMutable: metaplex?.isMutable ?? null,
1262
+ updateAuthority: metaplex?.updateAuthority ?? null,
1263
+ success: true,
1264
+ }
1265
+ })
599
1266
  })
600
1267
  }
601
1268
 
602
- /** 批量查 NFT 持有人:getTokenLargestAccounts(合并成一个批量请求)+ 一次 getMultipleAccounts */
603
- async nftOwners(mints: readonly string[]): Promise<NftOwner[]> {
604
- const largest = await Promise.all(
605
- mints.map((mint) =>
606
- isAddress(mint)
607
- ? this.transport
608
- .request<{ value: Array<{ address: string; amount: string }> }>('getTokenLargestAccounts', [mint, { commitment: this.#commitment }])
609
- .then((res) => res.value.find((a) => a.amount !== '0')?.address ?? null)
610
- .catch((err: unknown) => {
611
- // mint 不存在 / 不是代币:该项失败;节点问题照常抛出
612
- if (err instanceof RpcError && !err.nodeFault) return null
613
- throw err
614
- })
615
- : Promise.resolve(null),
616
- ),
617
- )
618
- const tokenAccounts = largest.filter((a): a is string => a !== null)
619
- const accounts = tokenAccounts.length ? await this.accounts(tokenAccounts) : []
620
- const byAddress = new Map(tokenAccounts.map((a, i) => [a, accounts[i] ?? null]))
621
- return mints.map((mint, i) => {
622
- const tokenAccount = largest[i] ?? null
623
- const account = tokenAccount ? byAddress.get(tokenAccount) : null
624
- let owner: string | null = null
625
- if (account) {
626
- try {
627
- owner = parseTokenAccount(account.data).owner
628
- } catch {
629
- owner = null
1269
+ /**
1270
+ * 批量查 NFT 持有人:getTokenLargestAccounts(合并成一个批量请求)+ 一次 getMultipleAccounts。
1271
+ * 节点拒绝的 mint(不存在 / 不是代币)在同一次 getMultipleAccounts 里读 mint 账户区分原因
1272
+ */
1273
+ nftOwners(mints: readonly string[], options: RequestOptions = {}): Promise<NftOwner[]> {
1274
+ return abortable(options.signal, async () => {
1275
+ const ctx: QueryContext = { signal: options.signal }
1276
+ const largest = await Promise.all(
1277
+ mints.map((mint): Promise<{ tokenAccount: string | null; rejected: boolean }> =>
1278
+ isAddress(mint)
1279
+ ? this.#call<{ value: Array<{ address: string; amount: string }> }>(ctx, 'getTokenLargestAccounts', [mint, { commitment: this.#commitment }])
1280
+ .then((res) => ({ tokenAccount: res.value.find((a) => a.amount !== '0')?.address ?? null, rejected: false }))
1281
+ .catch((err: unknown) => {
1282
+ // mint 不存在 / 不是代币:该项失败;节点问题照常抛出
1283
+ if (err instanceof RpcError && !err.nodeFault) return { tokenAccount: null, rejected: true }
1284
+ throw err
1285
+ })
1286
+ : Promise.resolve({ tokenAccount: null, rejected: false }),
1287
+ ),
1288
+ )
1289
+ const toRead = largest.flatMap(({ tokenAccount, rejected }, i) => (tokenAccount ? [tokenAccount] : rejected ? [mints[i] as string] : []))
1290
+ const accounts = toRead.length ? await this.#accounts(toRead, ctx) : []
1291
+ const byAddress = new Map(toRead.map((a, i) => [a, accounts[i] ?? null]))
1292
+ return mints.map((mint, i): NftOwner => {
1293
+ const { tokenAccount, rejected } = largest[i] ?? { tokenAccount: null, rejected: false }
1294
+ if (!isAddress(mint)) {
1295
+ return { mint, owner: null, tokenAccount: null, success: false, error: 'invalid-address' }
630
1296
  }
631
- }
632
- return { mint, owner, tokenAccount: owner ? tokenAccount : null, success: owner !== null }
1297
+ if (rejected) {
1298
+ return { mint, owner: null, tokenAccount: null, success: false, error: byAddress.get(mint) ? 'not-token' : 'not-found' }
1299
+ }
1300
+ const account = tokenAccount ? byAddress.get(tokenAccount) : null
1301
+ let owner: string | null = null
1302
+ if (account) {
1303
+ try {
1304
+ owner = parseTokenAccount(account.data).owner
1305
+ } catch {
1306
+ owner = null
1307
+ }
1308
+ }
1309
+ // 没有余额大于 0 的代币账户(供应量为 0 / 已销毁),或代币账户刚被关闭
1310
+ return owner ? { mint, owner, tokenAccount, success: true } : { mint, owner: null, tokenAccount: null, success: false, error: 'no-holder' }
1311
+ })
633
1312
  })
634
1313
  }
635
1314
 
636
1315
  /** 查某地址持有的全部 NFT(扫描代币账户:数量 1、精度 0),再批量读元数据。需要支持 getTokenAccountsByOwner 的节点 */
637
- async ownerNfts(owner: string): Promise<NftDetails[]> {
638
- if (!isAddress(owner)) {
639
- throw new Error(`Invalid owner address: ${owner}`)
640
- }
641
- const { held } = await this.#scanHoldings(owner)
642
- const mints = [...held].filter(([, h]) => isNftHolding(h)).map(([mint]) => mint)
643
- return mints.length ? (await this.nfts(mints)).filter((nft) => nft.success) : []
1316
+ ownerNfts(owner: string, options: RequestOptions = {}): Promise<NftDetails[]> {
1317
+ return abortable(options.signal, async () => {
1318
+ if (!isAddress(owner)) {
1319
+ throw new Error(`Invalid owner address: ${owner}`)
1320
+ }
1321
+ const { held } = await this.#scanHoldings({ signal: options.signal }, owner)
1322
+ const mints = [...held].filter(([, h]) => isNftHolding(h)).map(([mint]) => mint)
1323
+ return mints.length ? (await this.nfts(mints, options)).filter((nft) => nft.success) : []
1324
+ })
644
1325
  }
645
1326
 
646
1327
  #scope(): string {
@@ -678,6 +1359,16 @@ export class SolanaClient {
678
1359
  }
679
1360
  }
680
1361
 
1362
+ function toTransferFee(fee: TransferFeeInfo): TransferFee {
1363
+ return { basisPoints: fee.basisPoints, maximumFee: fee.maximumFee.toString(), epoch: fee.epoch.toString() }
1364
+ }
1365
+
1366
+ /** 当前生效的配置:epoch >= newer.epoch 用 newer,否则 older;不知道当前 epoch 时按 newer */
1367
+ function toTransferFeeConfig(older: TransferFeeInfo, newer: TransferFeeInfo, epoch: bigint | null): TransferFeeConfig {
1368
+ const current = epoch === null || epoch >= newer.epoch ? newer : older
1369
+ return { ...toTransferFee(current), older: toTransferFee(older), newer: toTransferFee(newer) }
1370
+ }
1371
+
681
1372
  interface RawAccount {
682
1373
  lamports: number | string
683
1374
  owner: string
@@ -704,7 +1395,9 @@ function safeParseMint(account: AccountInfo): MintInfo | null {
704
1395
  return null
705
1396
  }
706
1397
  try {
707
- return parseMint(account.data)
1398
+ const mint = parseMint(account.data)
1399
+ // 未初始化的 mint(创建和初始化分在两笔交易里,恰好在中间读到):decimals 还是 0,不能当真、更不能永久缓存
1400
+ return mint.isInitialized ? mint : null
708
1401
  } catch {
709
1402
  return null
710
1403
  }
@@ -741,13 +1434,24 @@ function setMeta(scope: string, mint: string, meta: TokenMeta): void {
741
1434
  }
742
1435
  const key = `${scope}:${mint}`
743
1436
  const merged = { ...tokenMetaCache.get(key), ...defined }
1437
+ if ('tokenProgram' in defined && !defined.programUnverified) {
1438
+ delete merged.programUnverified // 从链上学到了所属程序
1439
+ }
744
1440
  tokenMetaCache.delete(key) // 重新插入,保持“最近写入在后”的顺序
745
1441
  tokenMetaCache.set(key, merged)
746
1442
  if (tokenMetaCache.size > MAX_CACHED_TOKENS) {
747
1443
  tokenMetaCache.delete(tokenMetaCache.keys().next().value as string)
748
1444
  }
1445
+ for (const listener of cacheWriteListeners) {
1446
+ listener()
1447
+ }
749
1448
  }
750
1449
 
751
1450
  function withSymbolField(balance: TokenBalance, withSymbol: boolean, symbol: string | null): TokenBalance {
752
1451
  return withSymbol ? { ...balance, symbol } : balance
753
1452
  }
1453
+
1454
+ /** 失败项:余额为 0,带原因 */
1455
+ function failedBalance(token: string, error: FailureReason, tokenProgram: string | null, withSymbol: boolean, symbol: string | null): TokenBalance {
1456
+ return { ...withSymbolField({ token, native: false, balance: '0', decimals: 0, formatted: '0', tokenProgram, success: false }, withSymbol, symbol), error }
1457
+ }