@w3lib/solana-batch-call 0.0.0-stage → 0.1.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +169 -2
  4. package/dist/address.d.ts +14 -0
  5. package/dist/address.d.ts.map +1 -0
  6. package/dist/address.js +81 -0
  7. package/dist/address.js.map +1 -0
  8. package/dist/client.d.ts +96 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +575 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/constants.d.ts +21 -0
  13. package/dist/constants.d.ts.map +1 -0
  14. package/dist/constants.js +35 -0
  15. package/dist/constants.js.map +1 -0
  16. package/dist/index.d.ts +9 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +9 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/layout.d.ts +55 -0
  21. package/dist/layout.d.ts.map +1 -0
  22. package/dist/layout.js +177 -0
  23. package/dist/layout.js.map +1 -0
  24. package/dist/rpc.d.ts +59 -0
  25. package/dist/rpc.d.ts.map +1 -0
  26. package/dist/rpc.js +295 -0
  27. package/dist/rpc.js.map +1 -0
  28. package/dist/shortcuts.d.ts +60 -0
  29. package/dist/shortcuts.d.ts.map +1 -0
  30. package/dist/shortcuts.js +136 -0
  31. package/dist/shortcuts.js.map +1 -0
  32. package/dist/source.d.ts +35 -0
  33. package/dist/source.d.ts.map +1 -0
  34. package/dist/source.js +126 -0
  35. package/dist/source.js.map +1 -0
  36. package/dist/types.d.ts +104 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +2 -0
  39. package/dist/types.js.map +1 -0
  40. package/package.json +56 -3
  41. package/src/address.ts +92 -0
  42. package/src/client.ts +753 -0
  43. package/src/constants.ts +41 -0
  44. package/src/index.ts +54 -0
  45. package/src/layout.ts +238 -0
  46. package/src/rpc.ts +349 -0
  47. package/src/shortcuts.ts +173 -0
  48. package/src/source.ts +151 -0
  49. package/src/types.ts +108 -0
package/src/client.ts ADDED
@@ -0,0 +1,753 @@
1
+ import { base64 } from '@scure/base'
2
+
3
+ import { getAssociatedTokenAddress, getMetadataAddress, isAddress } from './address.js'
4
+ import {
5
+ NATIVE_MINT,
6
+ SOL_DECIMALS,
7
+ SYSTEM_PROGRAM_ID,
8
+ TOKEN_2022_PROGRAM_ID,
9
+ TOKEN_PROGRAM_ID,
10
+ clusterOfGenesis,
11
+ type Cluster,
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'
15
+ import { NetworkMismatchError, resolveSource, type RpcSource, type SourceOptions } from './source.js'
16
+ import {
17
+ DEFAULT_TOKEN_FIELDS,
18
+ type Commitment,
19
+ type DefaultTokenField,
20
+ type NftDetails,
21
+ type NftOwner,
22
+ type OwnedToken,
23
+ type SolBalance,
24
+ type TokenBalance,
25
+ type TokenDetails,
26
+ type TokenField,
27
+ type TokenStandard,
28
+ } from './types.js'
29
+
30
+ export interface ClientConfig extends SourceOptions {
31
+ /** 网络。不传 provider 时用于选择内置节点(默认 mainnet);传了 provider 时用于校验节点所在网络 */
32
+ cluster?: Cluster
33
+ /** 默认 'confirmed' */
34
+ commitment?: Commitment
35
+ /** 视为原生 SOL 的 mint,默认 [So111…112(Wrapped SOL), 11111…1(System Program)] */
36
+ nativeMints?: readonly string[]
37
+ }
38
+
39
+ export interface BalancesOptions {
40
+ /** 同时返回 symbol(代币查一次后缓存,主币为 SOL)。默认 false */
41
+ symbol?: boolean
42
+ /**
43
+ * 用 getTokenAccountsByOwner 扫描持有人的全部代币账户(含非 ATA 账户、同一代币的多个账户合计)。
44
+ * 不传 mints 时总是扫描。注意:免费公共节点大多不支持这个方法,需要自己的节点。
45
+ */
46
+ scan?: boolean
47
+ }
48
+
49
+ export interface OwnerTokensOptions {
50
+ /** 返回 name / symbol(代币查一次后缓存)。默认 true */
51
+ metadata?: boolean
52
+ /** 第一项返回 SOL。默认 true */
53
+ includeNative?: boolean
54
+ /** 包含余额为 0 的代币账户(如已清空但未关闭的 ATA)。默认 false */
55
+ includeZero?: boolean
56
+ /** 包含 NFT(精度 0 且数量 1)。默认 false */
57
+ includeNfts?: boolean
58
+ }
59
+
60
+ export interface TokensOptions<F extends TokenField = DefaultTokenField> {
61
+ /** 要返回的字段,默认 ['name', 'symbol', 'decimals'] */
62
+ fields?: readonly F[]
63
+ }
64
+
65
+ const MAX_ACCOUNTS_PER_REQUEST = 100
66
+ /** 同时进行的 getMultipleAccounts 请求数上限 */
67
+ const MAX_PARALLEL_ACCOUNT_REQUESTS = 3
68
+ /** #fetchAccounts 里没能读到(节点问题)的账户 */
69
+ const FAILED = Symbol('failed')
70
+
71
+ /** 可以降级处理的错误:网络 / HTTP 错误、节点限制(限频、需要 Key 等)、节点不在期望的网络上;其他(参数错误、程序 bug)照常抛出 */
72
+ function isRecoverable(err: unknown): boolean {
73
+ return err instanceof HttpError || (err instanceof RpcError && err.nodeFault) || err instanceof NetworkMismatchError
74
+ }
75
+
76
+ /** 按 mint 汇总后精度 0、数量 1 的持仓视为 NFT(ownerTokens 与 ownerNfts 共用) */
77
+ function isNftHolding(holding: Holding): boolean {
78
+ return holding.decimals === 0 && holding.amount === 1n
79
+ }
80
+
81
+ const TOKEN_STANDARDS: readonly TokenStandard[] = [
82
+ 'NonFungible',
83
+ 'FungibleAsset',
84
+ 'Fungible',
85
+ 'NonFungibleEdition',
86
+ 'ProgrammableNonFungible',
87
+ 'ProgrammableNonFungibleEdition',
88
+ ]
89
+
90
+ interface TokenMeta {
91
+ decimals?: number
92
+ tokenProgram?: string
93
+ /** null:确认没有元数据 */
94
+ name?: string | null
95
+ symbol?: string | null
96
+ }
97
+
98
+ interface CachedMeta extends TokenMeta {
99
+ /** name / symbol 的写入时间 */
100
+ namesAt?: number
101
+ }
102
+
103
+ // 代币信息缓存(按 网络 + mint):decimals / 所属程序不会变,永久缓存;
104
+ // name / symbol 来自可修改的元数据,缓存 1 小时。条目数有上限,超出时淘汰最早写入的
105
+ const NAME_TTL = 60 * 60 * 1000
106
+ const MAX_CACHED_TOKENS = 50_000
107
+ const tokenMetaCache = new Map<string, CachedMeta>()
108
+
109
+ let clientIds = 0
110
+
111
+ /** 测试用:清空代币信息缓存 */
112
+ export function resetTokenMetaCache(): void {
113
+ tokenMetaCache.clear()
114
+ }
115
+
116
+ interface Holding {
117
+ amount: bigint
118
+ decimals: number
119
+ program: string
120
+ /** 代币账户数量 */
121
+ accounts: number
122
+ }
123
+
124
+ interface ParsedTokenAccount {
125
+ pubkey: string
126
+ account: { owner: string; data: { parsed?: { info?: { mint?: string; owner?: string; tokenAmount?: { amount?: string; decimals?: number } } } } }
127
+ }
128
+
129
+ /**
130
+ * Solana 批量读取客户端。
131
+ *
132
+ * ```ts
133
+ * const sol = new SolanaClient() // 内置公共节点(mainnet)
134
+ * const sol = new SolanaClient('https://my-rpc.example') // 自己的节点
135
+ * const sol = new SolanaClient([connection, 'https://backup…']) // web3.js Connection + 备用节点
136
+ *
137
+ * await sol.balances(owner, [NATIVE_MINT, USDC])
138
+ * await sol.tokens([USDC, PYUSD], { fields: ['name', 'symbol', 'decimals', 'supply'] })
139
+ * ```
140
+ *
141
+ * 同一 tick 内发起的 RPC 调用会合并成一个 JSON-RPC 批量请求;账户读取按 100 个一组用 getMultipleAccounts。
142
+ */
143
+ export class SolanaClient {
144
+ readonly transport: RpcTransport
145
+ readonly #genesis: () => Promise<string>
146
+ readonly #cluster: Cluster | undefined
147
+ readonly #commitment: Commitment
148
+ readonly #nativeMints: Set<string>
149
+ readonly #scopeId: string
150
+
151
+ constructor(provider?: RpcSource | readonly RpcSource[], config: ClientConfig = {}) {
152
+ const resolved = resolveSource(provider, config.cluster ?? (provider === undefined ? 'mainnet' : undefined), config)
153
+ this.transport = resolved.transport
154
+ this.#genesis = resolved.genesis
155
+ this.#cluster = config.cluster ?? (provider === undefined ? 'mainnet' : undefined)
156
+ this.#commitment = config.commitment ?? 'confirmed'
157
+ this.#nativeMints = new Set(config.nativeMints ?? [NATIVE_MINT, SYSTEM_PROGRAM_ID])
158
+ // 网络已知时按网络共享缓存;未知(自定义节点且没指定 cluster)时缓存只在本客户端内使用,避免不同网络串数据
159
+ this.#scopeId = this.#cluster ?? `client-${++clientIds}`
160
+ }
161
+
162
+ /** 节点所在网络(按创世区块哈希识别);不是 mainnet / devnet / testnet 时为 null */
163
+ async getCluster(): Promise<Cluster | null> {
164
+ return this.#cluster ?? clusterOfGenesis(await this.#genesis())
165
+ }
166
+
167
+ /** 直接发 JSON-RPC 调用(同样参与自动合并与故障切换) */
168
+ request<T = unknown>(method: string, params?: readonly unknown[]): Promise<T> {
169
+ return this.transport.request<T>(method, params)
170
+ }
171
+
172
+ /**
173
+ * 批量读取账户(getMultipleAccounts,按 100 个一组,去重),结果与 addresses 一一对应。
174
+ * 账户不存在或地址非法时为 null。
175
+ */
176
+ async accounts(addresses: readonly string[]): Promise<(AccountInfo | null)[]> {
177
+ const fetched = await this.#fetchAccounts(addresses, false)
178
+ return addresses.map((address) => (fetched.get(address) as AccountInfo | null | undefined) ?? null)
179
+ }
180
+
181
+ /**
182
+ * getMultipleAccounts:去重、按 100 个一组,最多同时 3 组(避免大批量时触发节点限频)。
183
+ * tolerant 为 true 时,某组因节点问题(网络、限频等)失败不抛错:该组标记为 FAILED,并且不再发后面的组(节点已经在限频了)。
184
+ */
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>()
192
+ let aborted = false
193
+ for (let i = 0; i < chunks.length; i += MAX_PARALLEL_ACCOUNT_REQUESTS) {
194
+ const wave = chunks.slice(i, i + MAX_PARALLEL_ACCOUNT_REQUESTS)
195
+ if (aborted) {
196
+ wave.flat().forEach((address) => result.set(address, FAILED))
197
+ continue
198
+ }
199
+ await Promise.all(
200
+ wave.map(async (chunk) => {
201
+ try {
202
+ const res = await this.transport.request<{ value: Array<RawAccount | null> }>('getMultipleAccounts', [
203
+ chunk,
204
+ { encoding: 'base64', commitment: this.#commitment },
205
+ ])
206
+ chunk.forEach((address, j) => result.set(address, toAccountInfo(address, res?.value?.[j] ?? null)))
207
+ } catch (err) {
208
+ if (!tolerant || !isRecoverable(err)) {
209
+ throw err
210
+ }
211
+ aborted = true
212
+ chunk.forEach((address) => result.set(address, FAILED))
213
+ }
214
+ }),
215
+ )
216
+ }
217
+ return result
218
+ }
219
+
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 }
229
+ })
230
+ }
231
+
232
+ /**
233
+ * 批量查余额(主币 SOL + SPL Token + Token-2022),返回原始余额、decimals 和换算后的数值。
234
+ *
235
+ * - 传 mints:本地推导 ATA,与主币账户、mint 账户一起用 getMultipleAccounts 一次读完,免费节点也能用
236
+ * - 不传 mints(或 scan: true):用 getTokenAccountsByOwner 扫描全部代币账户,返回所有余额大于 0 的代币
237
+ */
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
+ }
242
+ if (mints === undefined || options.scan) {
243
+ return this.#scanBalances(owner, mints, options.symbol ?? false)
244
+ }
245
+ return this.#ataBalances(owner, mints, options.symbol ?? false)
246
+ }
247
+
248
+ async #ataBalances(owner: string, mints: readonly string[], withSymbol: boolean): Promise<TokenBalance[]> {
249
+ const scope = this.#scope()
250
+ 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 })),
268
+ }
269
+ })
270
+ const accounts = await this.accounts(addresses)
271
+
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 },
277
+ withSymbol,
278
+ '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
+ }
307
+ }
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)
310
+ }
311
+ return withSymbolField(
312
+ {
313
+ token: item.mint,
314
+ native: false,
315
+ balance: amount.toString(),
316
+ decimals: meta.decimals,
317
+ formatted: formatAmount(amount, meta.decimals),
318
+ tokenProgram: meta.tokenProgram ?? null,
319
+ success: true,
320
+ },
321
+ withSymbol,
322
+ meta.symbol ?? null,
323
+ )
324
+ })
325
+ }
326
+
327
+ /** 扫描持有人的 SOL 余额和全部代币账户(Token + Token-2022),同一代币的多个账户合计 */
328
+ async #scanHoldings(owner: string): Promise<{ sol: bigint; held: Map<string, Holding> }> {
329
+ 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
+ ]),
338
+ ),
339
+ ])
340
+ 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 })
353
+ }
354
+ })
355
+ return { sol: BigInt(lamports.value), held }
356
+ }
357
+
358
+ /**
359
+ * 列出持有人拥有的全部代币(SOL 在第一位),带 name / symbol,适合做资产列表。
360
+ * 需要支持 getTokenAccountsByOwner 的节点(免费公共节点大多不支持)。
361
+ *
362
+ * - 默认不含 NFT(精度 0 且数量 1)和余额为 0 的代币账户(如已清空的 ATA),可用 includeNfts / includeZero 打开
363
+ * - accounts 是该代币的代币账户数量;大于 1 时余额为所有账户的合计
364
+ * - 顺序:SOL,然后 SPL Token 的代币,再然后 Token-2022 的代币(各自按节点返回的顺序;没有价格信息,不按价值排序)
365
+ */
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
390
+ }
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 }
394
+ })
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
+ }
401
+
402
+ async #scanBalances(owner: string, mints: readonly string[] | undefined, withSymbol: boolean): Promise<TokenBalance[]> {
403
+ const scope = this.#scope()
404
+ const { sol, held } = await this.#scanHoldings(owner)
405
+
406
+ // 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)]
408
+ // 没持有的代币(拿不到 decimals)和缺 symbol 的代币:只读 mint / 元数据账户补上,不需要 ATA
409
+ await this.#loadMeta(
410
+ scope,
411
+ targets.filter((mint) => !this.#nativeMints.has(mint) && isAddress(mint)),
412
+ withSymbol,
413
+ )
414
+
415
+ return targets.map((mint): TokenBalance => {
416
+ 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
+ )
422
+ }
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
+ )
432
+ }
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
+ )
438
+ })
439
+ }
440
+
441
+ /**
442
+ * 补齐 decimals / 所属程序(以及需要时的 symbol / name):只读缺的 mint 账户和元数据账户。
443
+ * tolerant 为 true 时节点问题不抛错,返回没能读到的 mint(这些代币的信息保持未知,不写缓存)。
444
+ */
445
+ async #loadMeta(scope: string, mints: readonly string[], withSymbol: boolean, tolerant = false): Promise<Set<string>> {
446
+ const plan = [...new Set(mints)].flatMap((mint) => {
447
+ const meta = getMeta(scope, mint)
448
+ const needSymbol = withSymbol && meta.symbol === undefined
449
+ if (meta.decimals !== undefined && meta.tokenProgram !== undefined && !needSymbol) {
450
+ return []
451
+ }
452
+ return [{ mint, metadata: needSymbol ? getMetadataAddress(mint) : null }]
453
+ })
454
+ const failed = new Set<string>()
455
+ if (!plan.length) {
456
+ return failed
457
+ }
458
+ const fetched = await this.#fetchAccounts(
459
+ plan.flatMap(({ mint, metadata }) => (metadata ? [mint, metadata] : [mint])),
460
+ tolerant,
461
+ )
462
+ for (const { mint, metadata } of plan) {
463
+ const mintAccount = fetched.get(mint)
464
+ const metadataAccount = metadata ? fetched.get(metadata) : null
465
+ // mint 和元数据账户可能落在不同的请求里:任一个没读到都算失败,不能当成“没有元数据”缓存
466
+ if (mintAccount === FAILED || metadataAccount === FAILED) {
467
+ failed.add(mint)
468
+ continue
469
+ }
470
+ this.#learn(scope, mint, mintAccount ?? null, metadataAccount ?? null, metadata !== null)
471
+ }
472
+ return failed
473
+ }
474
+
475
+ /**
476
+ * 批量查代币详情(一次 getMultipleAccounts),可以选择返回哪些字段。
477
+ * name / symbol / uri 优先取 Token-2022 TokenMetadata 扩展,其次 Metaplex 元数据。
478
+ */
479
+ async tokens<const F extends TokenField = DefaultTokenField>(mints: readonly string[], options: TokensOptions<F> = {}): Promise<TokenDetails<F>[]> {
480
+ const fields = (options.fields ?? DEFAULT_TOKEN_FIELDS) as readonly F[]
481
+ const wanted = new Set<TokenField>(fields)
482
+ const scope = this.#scope()
483
+ const needsNames = wanted.has('name') || wanted.has('symbol') || wanted.has('uri')
484
+ const addresses: string[] = []
485
+ const plan = mints.map((mint) => {
486
+ if (this.#nativeMints.has(mint) || !isAddress(mint)) {
487
+ return { mint, mintIndex: -1, metadataIndex: -1 }
488
+ }
489
+ const meta = getMeta(scope, mint)
490
+ // uri / supply / 权限不缓存,需要时总要读 mint;name / symbol 命中缓存时可以省掉元数据账户
491
+ const cachedOnly =
492
+ !wanted.has('uri') &&
493
+ !wanted.has('supply') &&
494
+ !wanted.has('mintAuthority') &&
495
+ !wanted.has('freezeAuthority') &&
496
+ (!wanted.has('decimals') || meta.decimals !== undefined) &&
497
+ (!wanted.has('tokenProgram') || meta.tokenProgram !== undefined) &&
498
+ (!wanted.has('name') || meta.name !== undefined) &&
499
+ (!wanted.has('symbol') || meta.symbol !== undefined)
500
+ if (cachedOnly) {
501
+ return { mint, mintIndex: -1, metadataIndex: -1 }
502
+ }
503
+ const namesCached = (!wanted.has('name') || meta.name !== undefined) && (!wanted.has('symbol') || meta.symbol !== undefined) && !wanted.has('uri')
504
+ return {
505
+ mint,
506
+ mintIndex: addresses.push(mint) - 1,
507
+ metadataIndex: needsNames && !namesCached ? addresses.push(getMetadataAddress(mint)) - 1 : -1,
508
+ }
509
+ })
510
+ const accounts = addresses.length ? await this.accounts(addresses) : []
511
+
512
+ return plan.map(({ mint, mintIndex, metadataIndex }) => {
513
+ const native = this.#nativeMints.has(mint)
514
+ 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 }
516
+ : this.#tokenValues(
517
+ scope,
518
+ mint,
519
+ mintIndex === -1 ? null : (accounts[mintIndex] ?? null),
520
+ metadataIndex === -1 ? null : (accounts[metadataIndex] ?? null),
521
+ mintIndex !== -1,
522
+ mintIndex !== -1 && metadataIndex !== -1,
523
+ )
524
+ const out: Record<string, unknown> = { address: mint, native }
525
+ let success = values.exists
526
+ for (const field of fields) {
527
+ out[field] = values[field] ?? null
528
+ // 权限为 null 表示没有权限,是有效值;主币没有 uri / supply / tokenProgram
529
+ const nullable = field === 'mintAuthority' || field === 'freezeAuthority' || (native && (field === 'uri' || field === 'supply' || field === 'tokenProgram'))
530
+ if (out[field] === null && !nullable) {
531
+ success = false
532
+ }
533
+ }
534
+ if (wanted.has('supply')) {
535
+ const supply = values.supply as string | null
536
+ const decimals = values.decimals as number | null
537
+ out.supplyFormatted = supply === null || decimals === null ? null : formatAmount(BigInt(supply), decimals)
538
+ }
539
+ out.success = success
540
+ return out as TokenDetails<F>
541
+ })
542
+ }
543
+
544
+ #tokenValues(
545
+ scope: string,
546
+ mint: string,
547
+ mintAccount: AccountInfo | null,
548
+ metadataAccount: AccountInfo | null,
549
+ fetchedMint: boolean,
550
+ lookedForNames = false,
551
+ ): Record<TokenField, unknown> & { exists: boolean } {
552
+ const meta = this.#learn(scope, mint, mintAccount, metadataAccount, lookedForNames)
553
+ const info = mintAccount ? safeParseMint(mintAccount) : null
554
+ const uri = info?.metadata?.uri ?? (metadataAccount ? (safeParseMetaplex(metadataAccount)?.uri ?? null) : null)
555
+ return {
556
+ // 读了 mint 账户就以它为准(不存在则 false);没读说明请求的字段全部命中缓存
557
+ exists: fetchedMint ? info !== null : true,
558
+ name: meta.name ?? null,
559
+ symbol: meta.symbol ?? null,
560
+ uri,
561
+ decimals: meta.decimals ?? null,
562
+ supply: info ? info.supply.toString() : null,
563
+ tokenProgram: meta.tokenProgram ?? null,
564
+ mintAuthority: info?.mintAuthority ?? null,
565
+ freezeAuthority: info?.freezeAuthority ?? null,
566
+ }
567
+ }
568
+
569
+ /** 批量查 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
+ }
599
+ })
600
+ }
601
+
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
630
+ }
631
+ }
632
+ return { mint, owner, tokenAccount: owner ? tokenAccount : null, success: owner !== null }
633
+ })
634
+ }
635
+
636
+ /** 查某地址持有的全部 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) : []
644
+ }
645
+
646
+ #scope(): string {
647
+ return this.#scopeId
648
+ }
649
+
650
+ /**
651
+ * 从 mint 账户 / 元数据账户里学到的信息写入缓存,返回合并后的结果。
652
+ * lookedForNames:本次同时读了 mint 和元数据账户,都没有 name / symbol 时记为“确认没有”(null),之后不再重查
653
+ */
654
+ #learn(scope: string, mint: string, mintAccount: AccountInfo | null, metadataAccount: AccountInfo | null, lookedForNames = false): TokenMeta {
655
+ const update: TokenMeta = {}
656
+ const info = mintAccount ? safeParseMint(mintAccount) : null
657
+ if (info && mintAccount) {
658
+ update.decimals = info.decimals
659
+ update.tokenProgram = mintAccount.owner
660
+ if (info.metadata) {
661
+ update.name = info.metadata.name
662
+ update.symbol = info.metadata.symbol
663
+ }
664
+ }
665
+ if (metadataAccount && update.name === undefined) {
666
+ const metaplex = safeParseMetaplex(metadataAccount)
667
+ if (metaplex) {
668
+ update.name = metaplex.name
669
+ update.symbol = metaplex.symbol
670
+ }
671
+ }
672
+ if (lookedForNames && info && update.name === undefined) {
673
+ update.name = null
674
+ update.symbol = null
675
+ }
676
+ setMeta(scope, mint, update)
677
+ return getMeta(scope, mint)
678
+ }
679
+ }
680
+
681
+ interface RawAccount {
682
+ lamports: number | string
683
+ owner: string
684
+ data: [string, string] | string
685
+ executable: boolean
686
+ }
687
+
688
+ function toAccountInfo(address: string, raw: RawAccount | null): AccountInfo | null {
689
+ if (!raw) {
690
+ return null
691
+ }
692
+ const encoded = Array.isArray(raw.data) ? raw.data[0] : raw.data
693
+ return {
694
+ address,
695
+ lamports: BigInt(raw.lamports),
696
+ owner: raw.owner,
697
+ data: base64.decode(encoded),
698
+ executable: raw.executable,
699
+ }
700
+ }
701
+
702
+ function safeParseMint(account: AccountInfo): MintInfo | null {
703
+ if (account.owner !== TOKEN_PROGRAM_ID && account.owner !== TOKEN_2022_PROGRAM_ID) {
704
+ return null
705
+ }
706
+ try {
707
+ return parseMint(account.data)
708
+ } catch {
709
+ return null
710
+ }
711
+ }
712
+
713
+ function safeParseMetaplex(account: AccountInfo): MetaplexMetadata | null {
714
+ try {
715
+ return parseMetaplexMetadata(account.data)
716
+ } catch {
717
+ return null
718
+ }
719
+ }
720
+
721
+ function getMeta(scope: string, mint: string): TokenMeta {
722
+ const cached = tokenMetaCache.get(`${scope}:${mint}`)
723
+ if (!cached) {
724
+ return {}
725
+ }
726
+ const { namesAt, ...meta } = cached
727
+ if (namesAt === undefined || Date.now() - namesAt > NAME_TTL) {
728
+ delete meta.name
729
+ delete meta.symbol
730
+ }
731
+ return meta
732
+ }
733
+
734
+ function setMeta(scope: string, mint: string, meta: TokenMeta): void {
735
+ const defined: CachedMeta = Object.fromEntries(Object.entries(meta).filter(([, v]) => v !== undefined))
736
+ if (Object.keys(defined).length === 0) {
737
+ return
738
+ }
739
+ if ('name' in defined || 'symbol' in defined) {
740
+ defined.namesAt = Date.now()
741
+ }
742
+ const key = `${scope}:${mint}`
743
+ const merged = { ...tokenMetaCache.get(key), ...defined }
744
+ tokenMetaCache.delete(key) // 重新插入,保持“最近写入在后”的顺序
745
+ tokenMetaCache.set(key, merged)
746
+ if (tokenMetaCache.size > MAX_CACHED_TOKENS) {
747
+ tokenMetaCache.delete(tokenMetaCache.keys().next().value as string)
748
+ }
749
+ }
750
+
751
+ function withSymbolField(balance: TokenBalance, withSymbol: boolean, symbol: string | null): TokenBalance {
752
+ return withSymbol ? { ...balance, symbol } : balance
753
+ }