@w3lib/solana-batch-call 0.2.0 → 0.2.2
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/CHANGELOG.md +23 -0
- package/README.md +3 -2
- package/dist/address.d.ts +1 -1
- package/dist/address.d.ts.map +1 -1
- package/dist/address.js +10 -1
- package/dist/address.js.map +1 -1
- package/dist/client.d.ts +2 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +191 -78
- package/dist/client.js.map +1 -1
- package/dist/rpc.d.ts +8 -1
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +205 -30
- package/dist/rpc.js.map +1 -1
- package/dist/shortcuts.d.ts.map +1 -1
- package/dist/shortcuts.js +13 -1
- package/dist/shortcuts.js.map +1 -1
- package/package.json +1 -1
- package/src/address.ts +11 -1
- package/src/client.ts +239 -80
- package/src/rpc.ts +222 -30
- package/src/shortcuts.ts +14 -1
package/src/client.ts
CHANGED
|
@@ -241,6 +241,8 @@ const TOKEN_STANDARDS: readonly TokenStandard[] = [
|
|
|
241
241
|
interface TokenMeta {
|
|
242
242
|
decimals?: number
|
|
243
243
|
tokenProgram?: string
|
|
244
|
+
/** tokenProgram 来自导入的缓存、还没从链上确认过:ATA 模式第一次用时按程序未知处理(推导两个程序的 ATA、读 mint 确认) */
|
|
245
|
+
programUnverified?: boolean
|
|
244
246
|
/** null:确认没有元数据 */
|
|
245
247
|
name?: string | null
|
|
246
248
|
symbol?: string | null
|
|
@@ -257,6 +259,29 @@ const NAME_TTL = 60 * 60 * 1000
|
|
|
257
259
|
const MAX_CACHED_TOKENS = 50_000
|
|
258
260
|
const tokenMetaCache = new Map<string, CachedMeta>()
|
|
259
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
|
+
|
|
260
285
|
let clientIds = 0
|
|
261
286
|
|
|
262
287
|
/** 缓存写入监听(persistTokenMetaCache 用) */
|
|
@@ -264,6 +289,7 @@ const cacheWriteListeners = new Set<() => void>()
|
|
|
264
289
|
|
|
265
290
|
/** 测试用:清空代币信息缓存 */
|
|
266
291
|
export function resetTokenMetaCache(): void {
|
|
292
|
+
smallOwners.clear()
|
|
267
293
|
tokenMetaCache.clear()
|
|
268
294
|
}
|
|
269
295
|
|
|
@@ -285,7 +311,8 @@ export function exportTokenMetaCache(maxEntries = Number.POSITIVE_INFINITY): Tok
|
|
|
285
311
|
const entries: TokenMetaSnapshot['entries'] = []
|
|
286
312
|
for (const [key, meta] of tokenMetaCache) {
|
|
287
313
|
if (SHARED_SCOPES.has(key.slice(0, key.indexOf(':')))) {
|
|
288
|
-
|
|
314
|
+
const { programUnverified: _, ...exported } = meta
|
|
315
|
+
entries.push([key, exported])
|
|
289
316
|
}
|
|
290
317
|
}
|
|
291
318
|
return { version: 1, entries: entries.slice(Math.max(0, entries.length - maxEntries)) }
|
|
@@ -319,11 +346,20 @@ function validMeta(entry: unknown): CachedMeta | null {
|
|
|
319
346
|
}
|
|
320
347
|
const meta: CachedMeta = {}
|
|
321
348
|
if (typeof raw.decimals === 'number' && Number.isInteger(raw.decimals) && raw.decimals >= 0 && raw.decimals <= 255) meta.decimals = raw.decimals
|
|
322
|
-
if (raw.tokenProgram === TOKEN_PROGRAM_ID || raw.tokenProgram === TOKEN_2022_PROGRAM_ID)
|
|
323
|
-
|
|
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
|
+
) {
|
|
324
360
|
meta.name = raw.name
|
|
325
361
|
meta.symbol = raw.symbol
|
|
326
|
-
meta.namesAt = raw.namesAt
|
|
362
|
+
meta.namesAt = Math.min(raw.namesAt, Date.now())
|
|
327
363
|
}
|
|
328
364
|
return Object.keys(meta).length ? meta : null
|
|
329
365
|
}
|
|
@@ -395,7 +431,32 @@ interface Holding {
|
|
|
395
431
|
|
|
396
432
|
interface ParsedTokenAccount {
|
|
397
433
|
pubkey: string
|
|
398
|
-
account: {
|
|
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
|
+
}
|
|
399
460
|
}
|
|
400
461
|
|
|
401
462
|
/**
|
|
@@ -414,7 +475,8 @@ interface ParsedTokenAccount {
|
|
|
414
475
|
*/
|
|
415
476
|
/** ATA 模式下单个代币的查询计划(multiBalances 时多个钱包共用一次 getMultipleAccounts) */
|
|
416
477
|
type AtaItem =
|
|
417
|
-
|
|
478
|
+
/** owner:钱包地址(主币余额按地址从两组结果里取) */
|
|
479
|
+
| { kind: 'native'; mint: string; owner: string }
|
|
418
480
|
| { kind: 'invalid'; mint: string }
|
|
419
481
|
| { kind: 'excluded'; mint: string }
|
|
420
482
|
| { kind: 'token'; mint: string; mintIndex: number; metadataIndex: number; atas: Array<{ program: string; index: number }> }
|
|
@@ -497,58 +559,87 @@ export class SolanaClient {
|
|
|
497
559
|
}
|
|
498
560
|
|
|
499
561
|
/**
|
|
500
|
-
* getMultipleAccounts
|
|
501
|
-
*
|
|
562
|
+
* 只读 lamports(主币余额):getMultipleAccounts 带 dataSlice { offset: 0, length: 0 },不读回账户数据——
|
|
563
|
+
* 钱包可能是数据很大的程序账户(上百 KB),整份读回会拖慢甚至超时。结果与 addresses 一一对应,账户不存在或地址非法时为 null
|
|
502
564
|
*/
|
|
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 */
|
|
503
571
|
async #fetchAccounts(addresses: readonly string[], tolerant: boolean, ctx: QueryContext): Promise<Map<string, AccountInfo | null | typeof FAILED>> {
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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>())
|
|
510
595
|
let aborted = false
|
|
511
596
|
for (let i = 0; i < chunks.length; i += MAX_PARALLEL_ACCOUNT_REQUESTS) {
|
|
512
597
|
const wave = chunks.slice(i, i + MAX_PARALLEL_ACCOUNT_REQUESTS)
|
|
513
|
-
//
|
|
598
|
+
// 已取消的查询不再发后面的请求
|
|
514
599
|
if (ctx.signal?.aborted) {
|
|
515
600
|
throw ctx.signal.reason
|
|
516
601
|
}
|
|
517
602
|
if (aborted) {
|
|
518
|
-
wave.
|
|
603
|
+
wave.forEach((chunk) => chunk.addresses.forEach((address) => results[chunk.group]?.set(address, FAILED)))
|
|
519
604
|
continue
|
|
520
605
|
}
|
|
521
|
-
|
|
606
|
+
// 同一批里的请求同时发出(合并成一个 JSON-RPC 批量请求);一个失败时等这一批都结束再抛,后面的批次不再发
|
|
607
|
+
const settled = await Promise.allSettled(
|
|
522
608
|
wave.map(async (chunk) => {
|
|
609
|
+
const result = results[chunk.group] as Map<string, AccountInfo | null | typeof FAILED>
|
|
523
610
|
try {
|
|
524
611
|
const res = await this.#call<{ value: Array<RawAccount | null> }>(ctx, 'getMultipleAccounts', [
|
|
525
|
-
chunk,
|
|
526
|
-
{ encoding: 'base64', commitment: this.#commitment },
|
|
612
|
+
chunk.addresses,
|
|
613
|
+
{ encoding: 'base64', commitment: this.#commitment, ...(chunk.lamportsOnly ? { dataSlice: { offset: 0, length: 0 } } : {}) },
|
|
527
614
|
])
|
|
528
|
-
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)))
|
|
529
616
|
} catch (err) {
|
|
530
617
|
if (!tolerant || !isRecoverable(err)) {
|
|
531
618
|
throw err
|
|
532
619
|
}
|
|
533
620
|
aborted = true
|
|
534
|
-
chunk.forEach((address) => result.set(address, FAILED))
|
|
621
|
+
chunk.addresses.forEach((address) => result.set(address, FAILED))
|
|
535
622
|
}
|
|
536
623
|
}),
|
|
537
624
|
)
|
|
625
|
+
const failed = settled.find((r): r is PromiseRejectedResult => r.status === 'rejected')
|
|
626
|
+
if (failed) {
|
|
627
|
+
throw failed.reason
|
|
628
|
+
}
|
|
538
629
|
}
|
|
539
|
-
return
|
|
630
|
+
return results
|
|
540
631
|
}
|
|
541
632
|
|
|
542
|
-
/** 批量查多个地址的 SOL
|
|
633
|
+
/** 批量查多个地址的 SOL 余额(getMultipleAccounts 只读 lamports、不读回账户数据;账户不存在视为 0) */
|
|
543
634
|
solBalances(addresses: readonly string[], options: SolBalancesOptions = {}): Promise<SolBalance[]> {
|
|
544
635
|
return abortable(options.signal, async () => {
|
|
545
636
|
const ctx = this.#context(options)
|
|
546
|
-
const
|
|
637
|
+
const balances = await this.#lamports(addresses, ctx)
|
|
547
638
|
const list = addresses.map((address, i): SolBalance => {
|
|
548
639
|
if (!isAddress(address)) {
|
|
549
640
|
return { address, balance: '0', formatted: '0', success: false, error: 'invalid-address' }
|
|
550
641
|
}
|
|
551
|
-
const lamports =
|
|
642
|
+
const lamports = balances[i] ?? 0n
|
|
552
643
|
return { address, balance: lamports.toString(), formatted: formatUnits(lamports, SOL_DECIMALS), success: true }
|
|
553
644
|
})
|
|
554
645
|
return this.#attachSlot(list, ctx, options)
|
|
@@ -562,7 +653,7 @@ export class SolanaClient {
|
|
|
562
653
|
/**
|
|
563
654
|
* 批量查余额(主币 SOL + SPL Token + Token-2022),返回原始余额、decimals 和换算后的数值。
|
|
564
655
|
*
|
|
565
|
-
* - 传 mints:本地推导 ATA
|
|
656
|
+
* - 传 mints:本地推导 ATA,与 mint 账户一起用 getMultipleAccounts 读完,免费节点也能用;主币只读 lamports(dataSlice),另成一组同时发出
|
|
566
657
|
* - 传 mints 且 accounts: 'all':每个代币按 mint 过滤查 getTokenAccountsByOwner,统计全部代币账户(含非 ATA)
|
|
567
658
|
* - 不传 mints(或 scan: true):用 getTokenAccountsByOwner 扫描全部代币账户,返回所有余额大于 0 的代币
|
|
568
659
|
* - 失败项(success: false)带 error:invalid-address(mint 地址非法)/ not-found(mint 不存在)/ not-token(不是代币 mint)
|
|
@@ -621,26 +712,49 @@ export class SolanaClient {
|
|
|
621
712
|
/** ATA 模式:所有钱包的 主币账户 / mint / 元数据 / ATA 一起读,结果按钱包分组 */
|
|
622
713
|
async #ataBalances(ctx: QueryContext, queries: readonly BalanceQuery[], withSymbol: boolean, allowed: readonly string[]): Promise<TokenBalance[][]> {
|
|
623
714
|
const scope = this.#scope()
|
|
715
|
+
// 要数据的账户(mint、元数据、ATA)一组;钱包账户只要 lamports:已知是普通钱包(系统账户 / 不存在,数据为空)的
|
|
716
|
+
// 放进同一组(一个请求、同一个快照),其余(可能是数据很大的程序账户)单独一组带 dataSlice——同时发出,合并在同一个 JSON-RPC 批量请求里
|
|
624
717
|
const addresses: string[] = []
|
|
625
|
-
const
|
|
626
|
-
const
|
|
627
|
-
|
|
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)
|
|
734
|
+
}
|
|
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)))
|
|
628
739
|
}
|
|
629
740
|
|
|
630
|
-
#ataPlan(scope: string, owner: string, mint: string, withSymbol: boolean, allowed: readonly string[], addresses: string[]): AtaItem {
|
|
741
|
+
#ataPlan(scope: string, owner: string, mint: string, withSymbol: boolean, allowed: readonly string[], addresses: string[], owners: string[]): AtaItem {
|
|
631
742
|
if (this.#nativeMints.has(mint)) {
|
|
632
|
-
|
|
743
|
+
;(isSmallOwner(scope, owner) ? addresses : owners).push(owner)
|
|
744
|
+
return { kind: 'native', mint, owner }
|
|
633
745
|
}
|
|
634
746
|
if (!isAddress(mint)) {
|
|
635
747
|
return { kind: 'invalid', mint }
|
|
636
748
|
}
|
|
637
749
|
const meta = getMeta(scope, mint)
|
|
638
|
-
|
|
750
|
+
// 导入的缓存里的所属程序没确认过:按未知处理(记错时不会让余额恒为 0)
|
|
751
|
+
const program = meta.programUnverified ? undefined : meta.tokenProgram
|
|
752
|
+
if (isExcluded(program, allowed)) {
|
|
639
753
|
return { kind: 'excluded', mint }
|
|
640
754
|
}
|
|
641
|
-
const needMint = meta.decimals === undefined ||
|
|
755
|
+
const needMint = meta.decimals === undefined || program === undefined || (withSymbol && meta.symbol === undefined)
|
|
642
756
|
// 所属程序未知时只推导允许的程序的 ATA,读到 mint 账户后再确认
|
|
643
|
-
const programs =
|
|
757
|
+
const programs = program ? [program] : allowed
|
|
644
758
|
return {
|
|
645
759
|
kind: 'token',
|
|
646
760
|
mint,
|
|
@@ -650,9 +764,16 @@ export class SolanaClient {
|
|
|
650
764
|
}
|
|
651
765
|
}
|
|
652
766
|
|
|
653
|
-
#ataResult(
|
|
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[] {
|
|
654
775
|
if (item.kind === 'native') {
|
|
655
|
-
const lamports =
|
|
776
|
+
const lamports = lamportsOf(item.owner)
|
|
656
777
|
return [
|
|
657
778
|
withSymbolField(
|
|
658
779
|
{ token: item.mint, native: true, balance: lamports.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(lamports, SOL_DECIMALS), tokenProgram: null, success: true },
|
|
@@ -675,21 +796,24 @@ export class SolanaClient {
|
|
|
675
796
|
item.metadataIndex === -1 ? null : (accounts[item.metadataIndex] ?? null),
|
|
676
797
|
item.metadataIndex !== -1 && item.mintIndex !== -1,
|
|
677
798
|
)
|
|
678
|
-
// 代币账户由哪个程序持有就是哪个;mint 账户的 owner
|
|
799
|
+
// 代币账户由哪个程序持有就是哪个;mint 账户的 owner 也能确定程序。
|
|
800
|
+
// 所属程序未知时推导了两个程序的 ATA,但一个 mint 只属于一个程序,另一个程序下不可能有这个 mint 的代币账户:找到就结束
|
|
679
801
|
let amount = 0n
|
|
680
802
|
for (const ata of item.atas) {
|
|
681
803
|
const account = accounts[ata.index]
|
|
682
|
-
if (account
|
|
683
|
-
|
|
684
|
-
amount = parseTokenAccount(account.data).amount
|
|
685
|
-
if (meta.tokenProgram === undefined) {
|
|
686
|
-
setMeta(scope, item.mint, { tokenProgram: ata.program })
|
|
687
|
-
meta.tokenProgram = ata.program
|
|
688
|
-
}
|
|
689
|
-
} catch {
|
|
690
|
-
// 不是代币账户
|
|
691
|
-
}
|
|
804
|
+
if (!account || account.owner !== ata.program) {
|
|
805
|
+
continue
|
|
692
806
|
}
|
|
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
|
|
815
|
+
}
|
|
816
|
+
break
|
|
693
817
|
}
|
|
694
818
|
// 读到 mint 账户后才知道是不允许的程序
|
|
695
819
|
if (isExcluded(meta.tokenProgram, allowed)) {
|
|
@@ -717,7 +841,12 @@ export class SolanaClient {
|
|
|
717
841
|
}
|
|
718
842
|
|
|
719
843
|
/** 扫描持有人的 SOL 余额和全部代币账户(默认 Token + Token-2022,只请求 programs 里的程序),同一代币的多个账户合计 */
|
|
720
|
-
async #scanHoldings(
|
|
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> }> {
|
|
721
850
|
const scope = this.#scope()
|
|
722
851
|
const [lamports, ...results] = await Promise.all([
|
|
723
852
|
this.#call<{ value: number | string }>(ctx, 'getBalance', [owner, { commitment: this.#commitment }]),
|
|
@@ -725,22 +854,47 @@ export class SolanaClient {
|
|
|
725
854
|
this.#call<{ value: ParsedTokenAccount[] }>(ctx, 'getTokenAccountsByOwner', [owner, { programId }, { encoding: 'jsonParsed', commitment: this.#commitment }]),
|
|
726
855
|
),
|
|
727
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
|
+
}
|
|
728
886
|
const held = new Map<string, Holding>()
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
continue
|
|
736
|
-
}
|
|
737
|
-
const prev = held.get(mint)
|
|
738
|
-
const amount = BigInt(info?.tokenAmount?.amount ?? '0')
|
|
739
|
-
held.set(mint, { amount: (prev?.amount ?? 0n) + amount, decimals, program, accounts: (prev?.accounts ?? 0) + 1 })
|
|
740
|
-
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
|
|
741
893
|
}
|
|
742
|
-
|
|
743
|
-
|
|
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
|
|
744
898
|
}
|
|
745
899
|
|
|
746
900
|
/**
|
|
@@ -761,7 +915,7 @@ export class SolanaClient {
|
|
|
761
915
|
const { metadata = true, includeNative = true, includeZero = false, includeNfts = false } = options
|
|
762
916
|
const scope = this.#scope()
|
|
763
917
|
const ctx = this.#context(options)
|
|
764
|
-
const { sol, held } = await this.#scanHoldings(ctx, owner, resolvePrograms(options.tokenPrograms))
|
|
918
|
+
const { sol, held } = await this.#scanHoldings(ctx, owner, resolvePrograms(options.tokenPrograms), metadata)
|
|
765
919
|
const scanSlot = ctx.slot
|
|
766
920
|
// Wrapped SOL 是独立的代币账户(需要 unwrap 才是 SOL),资产列表里单独列出(native: false)
|
|
767
921
|
const tokens = [...held].filter(([, h]) => (includeZero || h.amount > 0n) && (includeNfts || !isNftHolding(h)))
|
|
@@ -797,7 +951,7 @@ export class SolanaClient {
|
|
|
797
951
|
async #scanBalances(ctx: QueryContext, owner: string, mints: readonly string[] | undefined, withSymbol: boolean, allowed: readonly string[]): Promise<TokenBalance[]> {
|
|
798
952
|
const scope = this.#scope()
|
|
799
953
|
// held 里只有允许的程序的代币
|
|
800
|
-
const { sol, held } = await this.#scanHoldings(ctx, owner, allowed)
|
|
954
|
+
const { sol, held } = await this.#scanHoldings(ctx, owner, allowed, withSymbol)
|
|
801
955
|
|
|
802
956
|
// Wrapped SOL(So111…112)默认按原生 SOL 处理:余额列表里只有一项 SOL,不再列出 wSOL 代币账户
|
|
803
957
|
// 第一项是主币:用 nativeMints 里的地址(nativeMints 不含 So111…112 时 wSOL 作为普通代币列出,不能再拿它当 SOL)
|
|
@@ -834,7 +988,15 @@ export class SolanaClient {
|
|
|
834
988
|
*/
|
|
835
989
|
async #mintAccountBalances(ctx: QueryContext, owner: string, mints: readonly string[], withSymbol: boolean, allowed: readonly string[]): Promise<TokenBalance[]> {
|
|
836
990
|
const scope = this.#scope()
|
|
837
|
-
|
|
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
|
+
]
|
|
838
1000
|
const [lamports, ...results] = await Promise.all([
|
|
839
1001
|
mints.some((mint) => this.#nativeMints.has(mint))
|
|
840
1002
|
? this.#call<{ value: number | string }>(ctx, 'getBalance', [owner, { commitment: this.#commitment }])
|
|
@@ -849,20 +1011,12 @@ export class SolanaClient {
|
|
|
849
1011
|
),
|
|
850
1012
|
),
|
|
851
1013
|
])
|
|
852
|
-
const held =
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
continue
|
|
859
|
-
}
|
|
860
|
-
const prev = held.get(mint)
|
|
861
|
-
const amount = BigInt(entry.account.data.parsed?.info?.tokenAmount?.amount ?? '0')
|
|
862
|
-
held.set(mint, { amount: (prev?.amount ?? 0n) + amount, decimals, program, accounts: (prev?.accounts ?? 0) + 1 })
|
|
863
|
-
setMeta(scope, mint, { decimals, tokenProgram: program })
|
|
864
|
-
}
|
|
865
|
-
})
|
|
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
|
+
)
|
|
866
1020
|
// 没持有的代币补 decimals(同时确认 mint 是否存在),需要时补 symbol
|
|
867
1021
|
const failures = await this.#loadMeta(ctx, scope, queried, withSymbol)
|
|
868
1022
|
const sol = BigInt(lamports?.value ?? 0)
|
|
@@ -1241,7 +1395,9 @@ function safeParseMint(account: AccountInfo): MintInfo | null {
|
|
|
1241
1395
|
return null
|
|
1242
1396
|
}
|
|
1243
1397
|
try {
|
|
1244
|
-
|
|
1398
|
+
const mint = parseMint(account.data)
|
|
1399
|
+
// 未初始化的 mint(创建和初始化分在两笔交易里,恰好在中间读到):decimals 还是 0,不能当真、更不能永久缓存
|
|
1400
|
+
return mint.isInitialized ? mint : null
|
|
1245
1401
|
} catch {
|
|
1246
1402
|
return null
|
|
1247
1403
|
}
|
|
@@ -1278,6 +1434,9 @@ function setMeta(scope: string, mint: string, meta: TokenMeta): void {
|
|
|
1278
1434
|
}
|
|
1279
1435
|
const key = `${scope}:${mint}`
|
|
1280
1436
|
const merged = { ...tokenMetaCache.get(key), ...defined }
|
|
1437
|
+
if ('tokenProgram' in defined && !defined.programUnverified) {
|
|
1438
|
+
delete merged.programUnverified // 从链上学到了所属程序
|
|
1439
|
+
}
|
|
1281
1440
|
tokenMetaCache.delete(key) // 重新插入,保持“最近写入在后”的顺序
|
|
1282
1441
|
tokenMetaCache.set(key, merged)
|
|
1283
1442
|
if (tokenMetaCache.size > MAX_CACHED_TOKENS) {
|