@w3lib/solana-batch-call 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +16 -8
- package/dist/accountReader.d.ts +57 -0
- package/dist/accountReader.d.ts.map +1 -0
- package/dist/accountReader.js +149 -0
- package/dist/accountReader.js.map +1 -0
- package/dist/address.d.ts +9 -0
- package/dist/address.d.ts.map +1 -1
- package/dist/address.js +36 -6
- package/dist/address.js.map +1 -1
- package/dist/approvals.d.ts +5 -3
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +25 -36
- package/dist/approvals.js.map +1 -1
- package/dist/balances.d.ts +178 -0
- package/dist/balances.d.ts.map +1 -0
- package/dist/balances.js +524 -0
- package/dist/balances.js.map +1 -0
- package/dist/batchPlan.d.ts +2 -2
- package/dist/batchPlan.d.ts.map +1 -1
- package/dist/batchPlan.js +5 -26
- package/dist/batchPlan.js.map +1 -1
- package/dist/bytes.d.ts +31 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +74 -0
- package/dist/bytes.js.map +1 -0
- package/dist/claim.d.ts +5 -3
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +10 -10
- package/dist/claim.js.map +1 -1
- package/dist/client.d.ts +26 -41
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +41 -986
- package/dist/client.js.map +1 -1
- package/dist/clientConfig.d.ts +11 -0
- package/dist/clientConfig.d.ts.map +1 -0
- package/dist/clientConfig.js +3 -0
- package/dist/clientConfig.js.map +1 -0
- package/dist/decode.d.ts.map +1 -1
- package/dist/decode.js +2 -29
- package/dist/decode.js.map +1 -1
- package/dist/fees.d.ts.map +1 -1
- package/dist/fees.js +2 -12
- package/dist/fees.js.map +1 -1
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +4 -15
- package/dist/history.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +21 -0
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +50 -69
- package/dist/layout.js.map +1 -1
- package/dist/liquidity/common.js +2 -2
- package/dist/liquidity/common.js.map +1 -1
- package/dist/lp.d.ts +9 -13
- package/dist/lp.d.ts.map +1 -1
- package/dist/lp.js +67 -55
- package/dist/lp.js.map +1 -1
- package/dist/portfolio.d.ts.map +1 -1
- package/dist/portfolio.js +3 -8
- package/dist/portfolio.js.map +1 -1
- package/dist/reclaim.d.ts +4 -2
- package/dist/reclaim.d.ts.map +1 -1
- package/dist/reclaim.js +16 -16
- package/dist/reclaim.js.map +1 -1
- package/dist/risk.js +2 -2
- package/dist/risk.js.map +1 -1
- package/dist/rpc.d.ts +30 -7
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +400 -109
- package/dist/rpc.js.map +1 -1
- package/dist/simulate.d.ts.map +1 -1
- package/dist/simulate.js +3 -26
- package/dist/simulate.js.map +1 -1
- package/dist/source.d.ts +8 -4
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +11 -4
- package/dist/source.js.map +1 -1
- package/dist/subpaths/approvals.d.ts +3 -3
- package/dist/subpaths/approvals.d.ts.map +1 -1
- package/dist/subpaths/approvals.js.map +1 -1
- package/dist/subpaths/claim.d.ts +3 -3
- package/dist/subpaths/claim.d.ts.map +1 -1
- package/dist/subpaths/claim.js.map +1 -1
- package/dist/subpaths/reclaim.d.ts +3 -3
- package/dist/subpaths/reclaim.d.ts.map +1 -1
- package/dist/subpaths/reclaim.js.map +1 -1
- package/dist/swap/index.js +3 -3
- package/dist/swap/index.js.map +1 -1
- package/dist/tokenDetails.d.ts +12 -0
- package/dist/tokenDetails.d.ts.map +1 -0
- package/dist/tokenDetails.js +196 -0
- package/dist/tokenDetails.js.map +1 -0
- package/dist/tokenMeta.d.ts +63 -0
- package/dist/tokenMeta.d.ts.map +1 -0
- package/dist/tokenMeta.js +226 -0
- package/dist/tokenMeta.js.map +1 -0
- package/dist/tradeCheck.js +3 -3
- package/dist/tradeCheck.js.map +1 -1
- package/dist/util.d.ts +22 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +107 -0
- package/dist/util.js.map +1 -0
- package/dist/write/chain.d.ts +51 -0
- package/dist/write/chain.d.ts.map +1 -0
- package/dist/write/chain.js +184 -0
- package/dist/write/chain.js.map +1 -0
- package/dist/write/collect.js +2 -2
- package/dist/write/collect.js.map +1 -1
- package/dist/write/errors.d.ts +10 -0
- package/dist/write/errors.d.ts.map +1 -0
- package/dist/write/errors.js +77 -0
- package/dist/write/errors.js.map +1 -0
- package/dist/write/pack.d.ts +28 -0
- package/dist/write/pack.d.ts.map +1 -0
- package/dist/write/pack.js +125 -0
- package/dist/write/pack.js.map +1 -0
- package/dist/write/send.d.ts +23 -9
- package/dist/write/send.d.ts.map +1 -1
- package/dist/write/send.js +53 -351
- package/dist/write/send.js.map +1 -1
- package/dist/write/tracker.d.ts +24 -0
- package/dist/write/tracker.d.ts.map +1 -0
- package/dist/write/tracker.js +129 -0
- package/dist/write/tracker.js.map +1 -0
- package/package.json +1 -1
- package/src/accountReader.ts +187 -0
- package/src/address.ts +41 -6
- package/src/approvals.ts +36 -44
- package/src/balances.ts +678 -0
- package/src/batchPlan.ts +6 -26
- package/src/bytes.ts +89 -0
- package/src/claim.ts +15 -11
- package/src/client.ts +59 -1239
- package/src/clientConfig.ts +12 -0
- package/src/decode.ts +4 -30
- package/src/fees.ts +2 -9
- package/src/history.ts +4 -15
- package/src/index.ts +13 -1
- package/src/layout.ts +63 -82
- package/src/liquidity/common.ts +2 -2
- package/src/lp.ts +86 -60
- package/src/portfolio.ts +8 -11
- package/src/reclaim.ts +20 -17
- package/src/risk.ts +2 -2
- package/src/rpc.ts +472 -116
- package/src/simulate.ts +3 -21
- package/src/source.ts +14 -5
- package/src/subpaths/approvals.ts +3 -2
- package/src/subpaths/claim.ts +3 -2
- package/src/subpaths/reclaim.ts +3 -2
- package/src/swap/index.ts +3 -3
- package/src/tokenDetails.ts +240 -0
- package/src/tokenMeta.ts +270 -0
- package/src/tradeCheck.ts +3 -3
- package/src/util.ts +110 -0
- package/src/write/chain.ts +218 -0
- package/src/write/collect.ts +2 -2
- package/src/write/errors.ts +82 -0
- package/src/write/pack.ts +142 -0
- package/src/write/send.ts +74 -407
- package/src/write/tracker.ts +152 -0
package/src/balances.ts
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
// 余额的三种查法(ATA、扫描全部代币账户、按 mint 查全部代币账户)、持仓列表和补读代币信息
|
|
2
|
+
|
|
3
|
+
import { base64 } from '@scure/base'
|
|
4
|
+
|
|
5
|
+
import { attachSlot, FAILED, queryContext, type AccountReader, type QueryContext } from './accountReader.js'
|
|
6
|
+
import { getAssociatedTokenAddress, getMetadataAddress, InvalidAddressError, isAddress } from './address.js'
|
|
7
|
+
import type { BalanceQuery, BalancesOptions, OwnerTokensOptions, RequestOptions, SolBalancesOptions } from './client.js'
|
|
8
|
+
import { NATIVE_MINT, SOL_DECIMALS, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from './constants.js'
|
|
9
|
+
import { formatUnits, parseTokenAccount, type AccountInfo } from './layout.js'
|
|
10
|
+
import { RpcError } from './rpc.js'
|
|
11
|
+
import { nftsOf } from './tokenDetails.js'
|
|
12
|
+
import { getMeta, isSmallOwner, learnMeta, learnOwner, setMeta } from './tokenMeta.js'
|
|
13
|
+
import type { Commitment, FailureReason, NftDetails, OwnedToken, SolBalance, TokenBalance, TokenProgramOption } from './types.js'
|
|
14
|
+
import { SharedAbort } from './util.js'
|
|
15
|
+
|
|
16
|
+
/** 查询共用的上下文:账户读取、缓存范围(网络)、按原生 SOL 处理的 mint、commitment */
|
|
17
|
+
export interface ReadContext {
|
|
18
|
+
reader: AccountReader
|
|
19
|
+
scope: string
|
|
20
|
+
nativeMints: ReadonlySet<string>
|
|
21
|
+
commitment: Commitment
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const TOKEN_PROGRAMS: readonly string[] = [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]
|
|
25
|
+
|
|
26
|
+
// 用 Map:普通对象会让 'constructor' 等原型上的名字通过校验
|
|
27
|
+
const PROGRAM_ALIASES = new Map<string, string>([
|
|
28
|
+
['spl', TOKEN_PROGRAM_ID],
|
|
29
|
+
['token-2022', TOKEN_2022_PROGRAM_ID],
|
|
30
|
+
[TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID],
|
|
31
|
+
[TOKEN_2022_PROGRAM_ID, TOKEN_2022_PROGRAM_ID],
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 简写 / 程序地址转成程序地址(顺序固定为 Token、Token-2022)。
|
|
36
|
+
* 不认识的值和空数组报错,避免拼错或漏选时悄悄过滤掉全部代币
|
|
37
|
+
*/
|
|
38
|
+
export function resolvePrograms(tokenPrograms: readonly TokenProgramOption[] | undefined): readonly string[] {
|
|
39
|
+
if (tokenPrograms === undefined) {
|
|
40
|
+
return TOKEN_PROGRAMS
|
|
41
|
+
}
|
|
42
|
+
if (tokenPrograms.length === 0) {
|
|
43
|
+
throw new Error(`tokenPrograms is empty (expected 'spl' and / or 'token-2022'; omit it to include both)`)
|
|
44
|
+
}
|
|
45
|
+
const wanted = new Set(
|
|
46
|
+
tokenPrograms.map((value) => {
|
|
47
|
+
const program = PROGRAM_ALIASES.get(value)
|
|
48
|
+
if (!program) {
|
|
49
|
+
throw new Error(`Unknown token program: ${value} (expected 'spl', 'token-2022' or a token program id)`)
|
|
50
|
+
}
|
|
51
|
+
return program
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
54
|
+
return TOKEN_PROGRAMS.filter((program) => wanted.has(program))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 所属程序已知且不在允许范围内 */
|
|
58
|
+
export function isExcluded(tokenProgram: string | undefined, allowed: readonly string[]): boolean {
|
|
59
|
+
return tokenProgram !== undefined && !allowed.includes(tokenProgram)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 按 mint 汇总后精度 0、数量 1 的持仓视为 NFT(ownerTokens 与 ownerNfts 共用) */
|
|
63
|
+
export function isNftHolding(holding: Holding): boolean {
|
|
64
|
+
return holding.decimals === 0 && holding.amount === 1n
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** #loadMeta 没能补上 decimals 的原因;failed 为节点问题(tolerant 时) */
|
|
68
|
+
export type MetaFailure = 'failed' | 'not-found' | 'not-token'
|
|
69
|
+
|
|
70
|
+
/** 一个 mint 的读取结果:ok、确认不存在 / 不是代币,或节点问题(带错误,非 tolerant 的调用方抛出) */
|
|
71
|
+
export type MetaOutcome = 'ok' | 'not-found' | 'not-token' | { error: unknown }
|
|
72
|
+
|
|
73
|
+
/** 正在读取的代币信息(按 scope:mint):同一 mint 正在查时共用同一个读取(在途去重) */
|
|
74
|
+
export interface MetaLoad {
|
|
75
|
+
/** 这次读取包含元数据账户(能补上 name / symbol) */
|
|
76
|
+
withSymbol: boolean
|
|
77
|
+
/** 发起方的 minContextSlot:只有相同的查询才能加入(否则会继承对方的 slot 约束和因此产生的失败) */
|
|
78
|
+
minContextSlot: number | undefined
|
|
79
|
+
/** 所有调用方都取消后才中止;null 表示发起方没传 signal(不会中止) */
|
|
80
|
+
abort: SharedAbort | null
|
|
81
|
+
promise: Promise<MetaOutcome>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const metaLoads = new Map<string, MetaLoad>()
|
|
85
|
+
|
|
86
|
+
export interface Holding {
|
|
87
|
+
amount: bigint
|
|
88
|
+
decimals: number
|
|
89
|
+
program: string
|
|
90
|
+
/** 代币账户数量 */
|
|
91
|
+
accounts: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ParsedTokenAccount {
|
|
95
|
+
pubkey: string
|
|
96
|
+
account: {
|
|
97
|
+
owner: string
|
|
98
|
+
// 节点解析不了的账户(如 Token-2022 的新扩展)会退回 base64:["…", "base64"]
|
|
99
|
+
data: { parsed?: { info?: { mint?: string; owner?: string; tokenAmount?: { amount?: string; decimals?: number } } } } | [string, string] | string
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** jsonParsed 结果里的一个代币账户:节点解析好的直接取;退回 base64 的自己解析(这时没有 decimals,要从 mint 账户补) */
|
|
104
|
+
export function readTokenEntry(entry: ParsedTokenAccount): { mint: string; amount: bigint; decimals: number | undefined } | null {
|
|
105
|
+
const data = entry.account?.data
|
|
106
|
+
if (data && !Array.isArray(data) && typeof data === 'object') {
|
|
107
|
+
const info = data.parsed?.info
|
|
108
|
+
if (info?.mint && info.tokenAmount?.decimals !== undefined) {
|
|
109
|
+
return { mint: info.mint, amount: BigInt(info.tokenAmount.amount ?? '0'), decimals: info.tokenAmount.decimals }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const encoded = Array.isArray(data) ? (data[1] === 'base64' ? data[0] : undefined) : typeof data === 'string' ? data : undefined
|
|
113
|
+
if (encoded === undefined) {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const parsed = parseTokenAccount(base64.decode(encoded))
|
|
118
|
+
return { mint: parsed.mint, amount: parsed.amount, decimals: undefined }
|
|
119
|
+
} catch {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** ATA 模式下单个代币的查询计划(multiBalances 时多个钱包共用一次 getMultipleAccounts) */
|
|
125
|
+
type AtaItem =
|
|
126
|
+
/** owner:钱包地址(主币余额按地址从两组结果里取) */
|
|
127
|
+
| { kind: 'native'; mint: string; owner: string }
|
|
128
|
+
| { kind: 'invalid'; mint: string }
|
|
129
|
+
| { kind: 'excluded'; mint: string }
|
|
130
|
+
| { kind: 'token'; mint: string; mintIndex: number; metadataIndex: number; atas: Array<{ program: string; index: number }> }
|
|
131
|
+
|
|
132
|
+
export function balancesOf(rc: ReadContext, ctx: QueryContext, owner: string, mints: readonly string[] | undefined, options: BalancesOptions, programs: readonly string[]): Promise<TokenBalance[]> {
|
|
133
|
+
const withSymbol = options.symbol ?? false
|
|
134
|
+
if (mints === undefined || options.scan) {
|
|
135
|
+
return scanBalances(rc, ctx, owner, mints, withSymbol, programs)
|
|
136
|
+
}
|
|
137
|
+
if (options.accounts === 'all') {
|
|
138
|
+
return mintAccountBalances(rc, ctx, owner, mints, withSymbol, programs)
|
|
139
|
+
}
|
|
140
|
+
return ataBalances(rc, ctx, [{ owner, mints }], withSymbol, programs).then((lists) => lists[0] as TokenBalance[])
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** ATA 模式:所有钱包的 主币账户 / mint / 元数据 / ATA 一起读,结果按钱包分组 */
|
|
144
|
+
export async function ataBalances(rc: ReadContext, ctx: QueryContext, queries: readonly BalanceQuery[], withSymbol: boolean, allowed: readonly string[]): Promise<TokenBalance[][]> {
|
|
145
|
+
const scope = rc.scope
|
|
146
|
+
// 要数据的账户(mint、元数据、ATA)一组;钱包账户只要 lamports:已知是普通钱包(系统账户 / 不存在,数据为空)的
|
|
147
|
+
// 放进同一组(一个请求、同一个快照),其余(可能是数据很大的程序账户)单独一组带 dataSlice——同时发出,合并在同一个 JSON-RPC 批量请求里
|
|
148
|
+
const addresses: string[] = []
|
|
149
|
+
const owners: string[] = []
|
|
150
|
+
const plans = queries.map(({ owner, mints }) => mints.map((mint) => ataPlan(rc, owner, mint, withSymbol, allowed, addresses, owners)))
|
|
151
|
+
const [dataMap, ownerMap] = await rc.reader.fetchGroups(
|
|
152
|
+
[
|
|
153
|
+
{ addresses, lamportsOnly: false },
|
|
154
|
+
{ addresses: owners, lamportsOnly: true },
|
|
155
|
+
],
|
|
156
|
+
false,
|
|
157
|
+
ctx,
|
|
158
|
+
)
|
|
159
|
+
// 记下钱包账户是否数据为空:单独读的按结果学习;已放进代币那组的,读到不再是系统账户(如被分配给程序)时移除,下次单独读
|
|
160
|
+
for (const owner of new Set(queries.map((q) => q.owner))) {
|
|
161
|
+
// null(账户不存在)也是结果,不能用 ?? 落到另一组
|
|
162
|
+
const account = ownerMap?.has(owner) ? ownerMap.get(owner) : dataMap?.get(owner)
|
|
163
|
+
if (account !== undefined && account !== FAILED) {
|
|
164
|
+
learnOwner(scope, owner, account)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const accounts = addresses.map((address) => (dataMap?.get(address) as AccountInfo | null | undefined) ?? null)
|
|
168
|
+
const lamportsOf = (owner: string) => ((ownerMap?.has(owner) ? ownerMap.get(owner) : dataMap?.get(owner)) as AccountInfo | null | undefined)?.lamports ?? 0n
|
|
169
|
+
return plans.map((plan) => plan.flatMap((item) => ataResult(rc, item, accounts, lamportsOf, withSymbol, allowed)))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function ataPlan(rc: ReadContext, owner: string, mint: string, withSymbol: boolean, allowed: readonly string[], addresses: string[], owners: string[]): AtaItem {
|
|
173
|
+
const scope = rc.scope
|
|
174
|
+
if (rc.nativeMints.has(mint)) {
|
|
175
|
+
;(isSmallOwner(scope, owner) ? addresses : owners).push(owner)
|
|
176
|
+
return { kind: 'native', mint, owner }
|
|
177
|
+
}
|
|
178
|
+
if (!isAddress(mint)) {
|
|
179
|
+
return { kind: 'invalid', mint }
|
|
180
|
+
}
|
|
181
|
+
const meta = getMeta(scope, mint)
|
|
182
|
+
// 导入的缓存里的所属程序没确认过:按未知处理(记错时不会让余额恒为 0)
|
|
183
|
+
const program = meta.programUnverified ? undefined : meta.tokenProgram
|
|
184
|
+
if (isExcluded(program, allowed)) {
|
|
185
|
+
return { kind: 'excluded', mint }
|
|
186
|
+
}
|
|
187
|
+
const needMint = meta.decimals === undefined || program === undefined || (withSymbol && meta.symbol === undefined)
|
|
188
|
+
// 所属程序未知时只推导允许的程序的 ATA,读到 mint 账户后再确认
|
|
189
|
+
const programs = program ? [program] : allowed
|
|
190
|
+
return {
|
|
191
|
+
kind: 'token',
|
|
192
|
+
mint,
|
|
193
|
+
mintIndex: needMint ? addresses.push(mint) - 1 : -1,
|
|
194
|
+
metadataIndex: withSymbol && meta.symbol === undefined ? addresses.push(getMetadataAddress(mint)) - 1 : -1,
|
|
195
|
+
atas: programs.map((program) => ({ program, index: addresses.push(getAssociatedTokenAddress(owner, mint, program)) - 1 })),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function ataResult(
|
|
200
|
+
rc: ReadContext,
|
|
201
|
+
item: AtaItem,
|
|
202
|
+
accounts: readonly (AccountInfo | null)[],
|
|
203
|
+
lamportsOf: (owner: string) => bigint,
|
|
204
|
+
withSymbol: boolean,
|
|
205
|
+
allowed: readonly string[],
|
|
206
|
+
): TokenBalance[] {
|
|
207
|
+
const scope = rc.scope
|
|
208
|
+
if (item.kind === 'native') {
|
|
209
|
+
const lamports = lamportsOf(item.owner)
|
|
210
|
+
return [
|
|
211
|
+
withSymbolField(
|
|
212
|
+
{ token: item.mint, native: true, balance: lamports.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(lamports, SOL_DECIMALS), tokenProgram: null, success: true },
|
|
213
|
+
withSymbol,
|
|
214
|
+
'SOL',
|
|
215
|
+
),
|
|
216
|
+
]
|
|
217
|
+
}
|
|
218
|
+
if (item.kind === 'excluded') {
|
|
219
|
+
return []
|
|
220
|
+
}
|
|
221
|
+
if (item.kind === 'invalid') {
|
|
222
|
+
return [failedBalance(item.mint, 'invalid-address', null, withSymbol, null)]
|
|
223
|
+
}
|
|
224
|
+
const mintAccount = item.mintIndex === -1 ? null : (accounts[item.mintIndex] ?? null)
|
|
225
|
+
const meta = learnMeta(
|
|
226
|
+
scope,
|
|
227
|
+
item.mint,
|
|
228
|
+
mintAccount,
|
|
229
|
+
item.metadataIndex === -1 ? null : (accounts[item.metadataIndex] ?? null),
|
|
230
|
+
item.metadataIndex !== -1 && item.mintIndex !== -1,
|
|
231
|
+
)
|
|
232
|
+
// 代币账户由哪个程序持有就是哪个;mint 账户的 owner 也能确定程序。
|
|
233
|
+
// 所属程序未知时推导了两个程序的 ATA,但一个 mint 只属于一个程序,另一个程序下不可能有这个 mint 的代币账户:找到就结束
|
|
234
|
+
let amount = 0n
|
|
235
|
+
for (const ata of item.atas) {
|
|
236
|
+
const account = accounts[ata.index]
|
|
237
|
+
if (!account || account.owner !== ata.program) {
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
amount = parseTokenAccount(account.data).amount
|
|
242
|
+
} catch {
|
|
243
|
+
continue // 不是代币账户
|
|
244
|
+
}
|
|
245
|
+
if (meta.tokenProgram === undefined) {
|
|
246
|
+
setMeta(scope, item.mint, { tokenProgram: ata.program })
|
|
247
|
+
meta.tokenProgram = ata.program
|
|
248
|
+
}
|
|
249
|
+
break
|
|
250
|
+
}
|
|
251
|
+
// 读到 mint 账户后才知道是不允许的程序
|
|
252
|
+
if (isExcluded(meta.tokenProgram, allowed)) {
|
|
253
|
+
return []
|
|
254
|
+
}
|
|
255
|
+
if (meta.decimals === undefined) {
|
|
256
|
+
// decimals 未知时一定读了 mint 账户:不存在 / 存在但不是 mint
|
|
257
|
+
return [failedBalance(item.mint, mintAccount ? 'not-token' : 'not-found', meta.tokenProgram ?? null, withSymbol, meta.symbol ?? null)]
|
|
258
|
+
}
|
|
259
|
+
return [
|
|
260
|
+
withSymbolField(
|
|
261
|
+
{
|
|
262
|
+
token: item.mint,
|
|
263
|
+
native: false,
|
|
264
|
+
balance: amount.toString(),
|
|
265
|
+
decimals: meta.decimals,
|
|
266
|
+
formatted: formatUnits(amount, meta.decimals),
|
|
267
|
+
tokenProgram: meta.tokenProgram ?? null,
|
|
268
|
+
success: true,
|
|
269
|
+
},
|
|
270
|
+
withSymbol,
|
|
271
|
+
meta.symbol ?? null,
|
|
272
|
+
),
|
|
273
|
+
]
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 扫描持有人的 SOL 余额和全部代币账户(默认 Token + Token-2022,只请求 programs 里的程序),同一代币的多个账户合计 */
|
|
277
|
+
export async function scanHoldings(
|
|
278
|
+
rc: ReadContext,
|
|
279
|
+
ctx: QueryContext,
|
|
280
|
+
owner: string,
|
|
281
|
+
programs: readonly string[] = TOKEN_PROGRAMS,
|
|
282
|
+
withSymbol = false,
|
|
283
|
+
): Promise<{ sol: bigint; held: Map<string, Holding> }> {
|
|
284
|
+
const [lamports, ...results] = await Promise.all([
|
|
285
|
+
rc.reader.call<{ value: number | string }>(ctx, 'getBalance', [owner, { commitment: rc.commitment }]),
|
|
286
|
+
...programs.map((programId) =>
|
|
287
|
+
rc.reader.call<{ value: ParsedTokenAccount[] }>(ctx, 'getTokenAccountsByOwner', [owner, { programId }, { encoding: 'jsonParsed', commitment: rc.commitment }]),
|
|
288
|
+
),
|
|
289
|
+
])
|
|
290
|
+
const entries = programs.flatMap((program, i) => (results[i]?.value ?? []).map((entry) => ({ program, entry })))
|
|
291
|
+
const held = await collectHoldings(rc, ctx, entries, withSymbol)
|
|
292
|
+
return { sol: BigInt(lamports.value), held }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 把 getTokenAccountsByOwner(jsonParsed)的结果按 mint 汇总。节点解析不了、退回 base64 的账户自己解析,
|
|
297
|
+
* decimals 读 mint 账户补上(不丢弃,也不会把余额算成 0);mint 读不到 decimals 时才跳过
|
|
298
|
+
*/
|
|
299
|
+
export async function collectHoldings(
|
|
300
|
+
rc: ReadContext,
|
|
301
|
+
ctx: QueryContext,
|
|
302
|
+
entries: ReadonlyArray<{ program: string | undefined; entry: ParsedTokenAccount }>,
|
|
303
|
+
withSymbol = false,
|
|
304
|
+
): Promise<Map<string, Holding>> {
|
|
305
|
+
const scope = rc.scope
|
|
306
|
+
const read = entries.flatMap(({ program, entry }) => {
|
|
307
|
+
const token = readTokenEntry(entry)
|
|
308
|
+
return token ? [{ ...token, program: entry.account?.owner || program }] : []
|
|
309
|
+
})
|
|
310
|
+
read.forEach(({ mint, decimals, program }) => decimals !== undefined && program !== undefined && setMeta(scope, mint, { decimals, tokenProgram: program }))
|
|
311
|
+
// 缺 decimals 或所属程序(节点没给账户 owner)的读 mint 账户补上;需要 symbol 时一起读,后面不必再读一遍
|
|
312
|
+
const unknown = [
|
|
313
|
+
...new Set(read.filter((t) => getMeta(scope, t.mint).decimals === undefined || (t.program === undefined && getMeta(scope, t.mint).tokenProgram === undefined)).map((t) => t.mint)),
|
|
314
|
+
]
|
|
315
|
+
if (unknown.length) {
|
|
316
|
+
// 补读 mint 不受 minContextSlot 约束、不计入扫描的 slot(decimals / 所属程序不会因为刚成交而变化);
|
|
317
|
+
// 节点问题时不让整次扫描失败,读不到的代币跳过
|
|
318
|
+
await loadMeta(rc, { signal: ctx.signal }, unknown, withSymbol, true)
|
|
319
|
+
}
|
|
320
|
+
const held = new Map<string, Holding>()
|
|
321
|
+
for (const { mint, amount, program: entryProgram } of read) {
|
|
322
|
+
const meta = getMeta(scope, mint)
|
|
323
|
+
const decimals = meta.decimals
|
|
324
|
+
const program = entryProgram ?? (meta.programUnverified ? undefined : meta.tokenProgram)
|
|
325
|
+
if (decimals === undefined || program === undefined) {
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
const prev = held.get(mint)
|
|
329
|
+
held.set(mint, { amount: (prev?.amount ?? 0n) + amount, decimals, program, accounts: (prev?.accounts ?? 0) + 1 })
|
|
330
|
+
}
|
|
331
|
+
return held
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function scanBalances(rc: ReadContext, ctx: QueryContext, owner: string, mints: readonly string[] | undefined, withSymbol: boolean, allowed: readonly string[]): Promise<TokenBalance[]> {
|
|
335
|
+
// held 里只有允许的程序的代币
|
|
336
|
+
const { sol, held } = await scanHoldings(rc, ctx, owner, allowed, withSymbol)
|
|
337
|
+
|
|
338
|
+
// Wrapped SOL(So111…112)默认按原生 SOL 处理:余额列表里只有一项 SOL,不再列出 wSOL 代币账户
|
|
339
|
+
// 第一项是主币:用 nativeMints 里的地址(nativeMints 不含 So111…112 时 wSOL 作为普通代币列出,不能再拿它当 SOL)
|
|
340
|
+
const nativeLead = rc.nativeMints.has(NATIVE_MINT) ? NATIVE_MINT : rc.nativeMints.values().next().value
|
|
341
|
+
const targets = mints ?? [
|
|
342
|
+
...(nativeLead === undefined ? [] : [nativeLead]),
|
|
343
|
+
...[...held].filter(([mint, h]) => h.amount > 0n && !rc.nativeMints.has(mint)).map(([mint]) => mint),
|
|
344
|
+
]
|
|
345
|
+
// 没持有的代币(拿不到 decimals)和缺 symbol 的代币:只读 mint / 元数据账户补上,不需要 ATA
|
|
346
|
+
const failures = await loadMeta(
|
|
347
|
+
rc,
|
|
348
|
+
ctx,
|
|
349
|
+
targets.filter((mint) => !rc.nativeMints.has(mint) && isAddress(mint)),
|
|
350
|
+
withSymbol,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return targets.flatMap((mint): TokenBalance[] => {
|
|
354
|
+
if (rc.nativeMints.has(mint)) {
|
|
355
|
+
return [
|
|
356
|
+
withSymbolField(
|
|
357
|
+
{ token: mint, native: true, balance: sol.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(sol, SOL_DECIMALS), tokenProgram: null, success: true },
|
|
358
|
+
withSymbol,
|
|
359
|
+
'SOL',
|
|
360
|
+
),
|
|
361
|
+
]
|
|
362
|
+
}
|
|
363
|
+
return heldBalance(rc, mint, held.get(mint), failures, withSymbol, allowed)
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 指定 mints、统计全部代币账户:每个代币一次按 mint 过滤的 getTokenAccountsByOwner(同一 tick 发出,自动合并)。
|
|
369
|
+
* mint 不存在 / 不是代币时节点返回参数错误,按“没持有”处理,再由 #loadMeta 确认原因
|
|
370
|
+
*/
|
|
371
|
+
export async function mintAccountBalances(rc: ReadContext, ctx: QueryContext, owner: string, mints: readonly string[], withSymbol: boolean, allowed: readonly string[]): Promise<TokenBalance[]> {
|
|
372
|
+
const scope = rc.scope
|
|
373
|
+
// 导入的、未经链上确认的所属程序不用来排除代币(可能记错):照常查询,从链上学到后再按程序过滤
|
|
374
|
+
const queried = [
|
|
375
|
+
...new Set(
|
|
376
|
+
mints.filter((mint) => {
|
|
377
|
+
const meta = getMeta(scope, mint)
|
|
378
|
+
return !rc.nativeMints.has(mint) && isAddress(mint) && (meta.programUnverified || !isExcluded(meta.tokenProgram, allowed))
|
|
379
|
+
}),
|
|
380
|
+
),
|
|
381
|
+
]
|
|
382
|
+
const [lamports, ...results] = await Promise.all([
|
|
383
|
+
mints.some((mint) => rc.nativeMints.has(mint))
|
|
384
|
+
? rc.reader.call<{ value: number | string }>(ctx, 'getBalance', [owner, { commitment: rc.commitment }])
|
|
385
|
+
: Promise.resolve(null),
|
|
386
|
+
...queried.map((mint) =>
|
|
387
|
+
rc.reader.call<{ value: ParsedTokenAccount[] }>(ctx, 'getTokenAccountsByOwner', [owner, { mint }, { encoding: 'jsonParsed', commitment: rc.commitment }]).catch(
|
|
388
|
+
(err: unknown) => {
|
|
389
|
+
// "Invalid param: could not find mint" 等参数错误:mint 不存在或不是代币;节点问题照常抛出
|
|
390
|
+
if (err instanceof RpcError && !err.nodeFault && err.code === -32602) return null
|
|
391
|
+
throw err
|
|
392
|
+
},
|
|
393
|
+
),
|
|
394
|
+
),
|
|
395
|
+
])
|
|
396
|
+
const held = await collectHoldings(
|
|
397
|
+
rc,
|
|
398
|
+
ctx,
|
|
399
|
+
queried.flatMap((_, i) => (results[i]?.value ?? []).map((entry) => ({ program: entry.account?.owner, entry }))),
|
|
400
|
+
withSymbol,
|
|
401
|
+
)
|
|
402
|
+
// 没持有的代币补 decimals(同时确认 mint 是否存在),需要时补 symbol
|
|
403
|
+
const failures = await loadMeta(rc, ctx, queried, withSymbol)
|
|
404
|
+
const sol = BigInt(lamports?.value ?? 0)
|
|
405
|
+
|
|
406
|
+
return mints.flatMap((mint): TokenBalance[] => {
|
|
407
|
+
if (rc.nativeMints.has(mint)) {
|
|
408
|
+
return [
|
|
409
|
+
withSymbolField(
|
|
410
|
+
{ token: mint, native: true, balance: sol.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(sol, SOL_DECIMALS), tokenProgram: null, success: true },
|
|
411
|
+
withSymbol,
|
|
412
|
+
'SOL',
|
|
413
|
+
),
|
|
414
|
+
]
|
|
415
|
+
}
|
|
416
|
+
return heldBalance(rc, mint, held.get(mint), failures, withSymbol, allowed)
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 扫描类查询(scan / accounts: 'all')里单个代币的结果;不允许的程序返回空数组 */
|
|
421
|
+
export function heldBalance(rc: ReadContext, mint: string, h: Holding | undefined, failures: Map<string, MetaFailure>, withSymbol: boolean, allowed: readonly string[]): TokenBalance[] {
|
|
422
|
+
const scope = rc.scope
|
|
423
|
+
if (!isAddress(mint)) {
|
|
424
|
+
return [failedBalance(mint, 'invalid-address', null, withSymbol, null)]
|
|
425
|
+
}
|
|
426
|
+
const meta = getMeta(scope, mint)
|
|
427
|
+
if (!h) {
|
|
428
|
+
// 没持有(或属于不允许的程序):余额为 0;decimals 来自 mint 账户
|
|
429
|
+
if (isExcluded(meta.tokenProgram, allowed)) {
|
|
430
|
+
return []
|
|
431
|
+
}
|
|
432
|
+
if (meta.decimals === undefined) {
|
|
433
|
+
const failure = failures.get(mint)
|
|
434
|
+
return [failedBalance(mint, failure === 'not-token' ? 'not-token' : 'not-found', meta.tokenProgram ?? null, withSymbol, meta.symbol ?? null)]
|
|
435
|
+
}
|
|
436
|
+
return [withSymbolField({ token: mint, native: false, balance: '0', decimals: meta.decimals, formatted: '0', tokenProgram: meta.tokenProgram ?? null, success: true }, withSymbol, meta.symbol ?? null)]
|
|
437
|
+
}
|
|
438
|
+
if (isExcluded(h.program, allowed)) {
|
|
439
|
+
return []
|
|
440
|
+
}
|
|
441
|
+
return [
|
|
442
|
+
withSymbolField(
|
|
443
|
+
{ token: mint, native: false, balance: h.amount.toString(), decimals: h.decimals, formatted: formatUnits(h.amount, h.decimals), tokenProgram: h.program, success: true },
|
|
444
|
+
withSymbol,
|
|
445
|
+
meta.symbol ?? null,
|
|
446
|
+
),
|
|
447
|
+
]
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* 补齐 decimals / 所属程序(以及需要时的 symbol / name):只读缺的 mint 账户和元数据账户。
|
|
452
|
+
* 同一 mint 正在被别的查询读取时共用那次读取(不重复发请求)。
|
|
453
|
+
* 返回没能补上 decimals 的 mint 及原因:failed(tolerant 为 true 时的节点问题,信息保持未知、不写缓存)、not-found、not-token;
|
|
454
|
+
* tolerant 为 false 时节点问题直接抛出
|
|
455
|
+
*/
|
|
456
|
+
export async function loadMeta(rc: ReadContext, ctx: QueryContext, mints: readonly string[], withSymbol: boolean, tolerant = false): Promise<Map<string, MetaFailure>> {
|
|
457
|
+
const scope = rc.scope
|
|
458
|
+
// 查询已取消(外层已经 reject):不再发起或加入读取
|
|
459
|
+
if (ctx.signal?.aborted) {
|
|
460
|
+
throw ctx.signal.reason
|
|
461
|
+
}
|
|
462
|
+
const waits: Array<{ mint: string; promise: Promise<MetaOutcome> }> = []
|
|
463
|
+
const plan: Array<{ mint: string; metadata: string | null }> = []
|
|
464
|
+
for (const mint of new Set(mints)) {
|
|
465
|
+
const meta = getMeta(scope, mint)
|
|
466
|
+
const needSymbol = withSymbol && meta.symbol === undefined
|
|
467
|
+
if (meta.decimals !== undefined && meta.tokenProgram !== undefined && !needSymbol) {
|
|
468
|
+
continue
|
|
469
|
+
}
|
|
470
|
+
// 正在读、slot 约束相同、且这次读取能补上需要的信息(要 symbol 时得包含元数据账户):加入它
|
|
471
|
+
const running = metaLoads.get(`${scope}:${mint}`)
|
|
472
|
+
if (running && running.minContextSlot === ctx.minContextSlot && (running.withSymbol || !needSymbol) && (!running.abort || running.abort.join(ctx.signal))) {
|
|
473
|
+
waits.push({ mint, promise: running.promise })
|
|
474
|
+
} else {
|
|
475
|
+
plan.push({ mint, metadata: needSymbol ? getMetadataAddress(mint) : null })
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (plan.length) {
|
|
479
|
+
waits.push(...startMetaLoad(rc, ctx, plan))
|
|
480
|
+
}
|
|
481
|
+
const failures = new Map<string, MetaFailure>()
|
|
482
|
+
const outcomes = await Promise.all(waits.map((w) => w.promise))
|
|
483
|
+
outcomes.forEach((outcome, i) => {
|
|
484
|
+
const mint = (waits[i] as { mint: string }).mint
|
|
485
|
+
if (outcome === 'ok') {
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
if (typeof outcome === 'object') {
|
|
489
|
+
if (!tolerant) {
|
|
490
|
+
throw outcome.error
|
|
491
|
+
}
|
|
492
|
+
failures.set(mint, 'failed')
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
failures.set(mint, outcome)
|
|
496
|
+
})
|
|
497
|
+
return failures
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* 发起一次代币信息读取并登记为在途(结束后移除)。总是按 tolerant 读:节点问题时把错误带在结果里,
|
|
502
|
+
* 由各调用方按自己的 tolerant 决定抛出还是标记 failed。所有加入的调用方都取消后才中止
|
|
503
|
+
*/
|
|
504
|
+
export function startMetaLoad(rc: ReadContext, ctx: QueryContext, plan: ReadonlyArray<{ mint: string; metadata: string | null }>): Array<{ mint: string; promise: Promise<MetaOutcome> }> {
|
|
505
|
+
const scope = rc.scope
|
|
506
|
+
const shared = ctx.signal ? new SharedAbort() : null
|
|
507
|
+
shared?.join(ctx.signal)
|
|
508
|
+
const loadCtx: QueryContext = { ...ctx, signal: shared?.signal }
|
|
509
|
+
const failure: { error?: unknown } = {}
|
|
510
|
+
const loaded = rc.reader.fetchAccounts(
|
|
511
|
+
plan.flatMap(({ mint, metadata }) => (metadata ? [mint, metadata] : [mint])),
|
|
512
|
+
true,
|
|
513
|
+
loadCtx,
|
|
514
|
+
failure,
|
|
515
|
+
).then((fetched) => {
|
|
516
|
+
// 读到的 slot 记回发起方的查询
|
|
517
|
+
if (loadCtx.slot !== undefined) {
|
|
518
|
+
ctx.slot = ctx.slot === undefined ? loadCtx.slot : Math.min(ctx.slot, loadCtx.slot)
|
|
519
|
+
}
|
|
520
|
+
const outcomes = new Map<string, MetaOutcome>()
|
|
521
|
+
for (const { mint, metadata } of plan) {
|
|
522
|
+
const mintAccount = fetched.get(mint)
|
|
523
|
+
const metadataAccount = metadata ? fetched.get(metadata) : null
|
|
524
|
+
// mint 和元数据账户可能落在不同的请求里:任一个没读到都算失败,不能当成“没有元数据”缓存
|
|
525
|
+
if (mintAccount === FAILED || metadataAccount === FAILED) {
|
|
526
|
+
outcomes.set(mint, { error: failure.error })
|
|
527
|
+
continue
|
|
528
|
+
}
|
|
529
|
+
const learned = learnMeta(scope, mint, mintAccount ?? null, metadataAccount ?? null, metadata !== null)
|
|
530
|
+
outcomes.set(mint, learned.decimals === undefined ? (mintAccount ? 'not-token' : 'not-found') : 'ok')
|
|
531
|
+
}
|
|
532
|
+
return outcomes
|
|
533
|
+
})
|
|
534
|
+
const entries = plan.map(({ mint, metadata }) => {
|
|
535
|
+
const key = `${scope}:${mint}`
|
|
536
|
+
const promise = loaded.then((outcomes) => outcomes.get(mint) as MetaOutcome)
|
|
537
|
+
const load: MetaLoad = { withSymbol: metadata !== null, minContextSlot: ctx.minContextSlot, abort: shared, promise }
|
|
538
|
+
metaLoads.set(key, load)
|
|
539
|
+
const remove = () => {
|
|
540
|
+
if (metaLoads.get(key) === load) {
|
|
541
|
+
metaLoads.delete(key)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
promise.then(remove, remove)
|
|
545
|
+
return { mint, promise }
|
|
546
|
+
})
|
|
547
|
+
loaded.then(
|
|
548
|
+
() => shared?.done(),
|
|
549
|
+
() => shared?.done(),
|
|
550
|
+
)
|
|
551
|
+
return entries
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function withSymbolField(balance: TokenBalance, withSymbol: boolean, symbol: string | null): TokenBalance {
|
|
555
|
+
return withSymbol ? { ...balance, symbol } : balance
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** 失败项:余额为 0,带原因 */
|
|
559
|
+
export function failedBalance(token: string, error: FailureReason, tokenProgram: string | null, withSymbol: boolean, symbol: string | null): TokenBalance {
|
|
560
|
+
return { ...withSymbolField({ token, native: false, balance: '0', decimals: 0, formatted: '0', tokenProgram, success: false }, withSymbol, symbol), error }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** 批量查多个地址的 SOL 余额(getMultipleAccounts 只读 lamports、不读回账户数据;账户不存在视为 0) */
|
|
564
|
+
export async function solBalancesOf(rc: ReadContext, addresses: readonly string[], options: SolBalancesOptions): Promise<SolBalance[]> {
|
|
565
|
+
const ctx = queryContext(options)
|
|
566
|
+
const balances = await rc.reader.lamports(addresses, ctx)
|
|
567
|
+
const list = addresses.map((address, i): SolBalance => {
|
|
568
|
+
if (!isAddress(address)) {
|
|
569
|
+
return { address, balance: '0', formatted: '0', success: false, error: 'invalid-address' }
|
|
570
|
+
}
|
|
571
|
+
const lamports = balances[i] ?? 0n
|
|
572
|
+
return { address, balance: lamports.toString(), formatted: formatUnits(lamports, SOL_DECIMALS), success: true }
|
|
573
|
+
})
|
|
574
|
+
return attachSlot(list, ctx, options)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* 批量查余额(主币 SOL + SPL Token + Token-2022),返回原始余额、decimals 和换算后的数值。
|
|
579
|
+
*
|
|
580
|
+
* - 传 mints:本地推导 ATA,与 mint 账户一起用 getMultipleAccounts 读完,免费节点也能用;主币只读 lamports(dataSlice),另成一组同时发出
|
|
581
|
+
* - 传 mints 且 accounts: 'all':每个代币按 mint 过滤查 getTokenAccountsByOwner,统计全部代币账户(含非 ATA)
|
|
582
|
+
* - 不传 mints(或 scan: true):用 getTokenAccountsByOwner 扫描全部代币账户,返回所有余额大于 0 的代币
|
|
583
|
+
* - 失败项(success: false)带 error:invalid-address(mint 地址非法)/ not-found(mint 不存在)/ not-token(不是代币 mint)
|
|
584
|
+
* - tokenPrograms:只返回这些类型的代币,如 ['spl'] 不含 Token-2022(传了 mints 时,结果里去掉不符合的代币)
|
|
585
|
+
* - minContextSlot / withSlot:要求节点高度、返回读取时的 slot,见 SlotOptions
|
|
586
|
+
*/
|
|
587
|
+
export async function balancesFor(rc: ReadContext, owner: string, mints: readonly string[] | undefined, options: BalancesOptions): Promise<TokenBalance[]> {
|
|
588
|
+
if (!isAddress(owner)) {
|
|
589
|
+
throw new InvalidAddressError(`Invalid owner address: ${owner}`, owner)
|
|
590
|
+
}
|
|
591
|
+
const ctx = queryContext(options)
|
|
592
|
+
const list = await balancesOf(rc, ctx, owner, mints, options, resolvePrograms(options.tokenPrograms))
|
|
593
|
+
return attachSlot(list, ctx, options)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* 多个钱包一次查(如多钱包的持仓列表),结果与 queries 一一对应。
|
|
598
|
+
* ATA 模式(默认)下所有钱包的账户合并成一次(按 100 个一组的)getMultipleAccounts;
|
|
599
|
+
* scan / accounts: 'all' 时每个钱包各自查询(同一 tick 发出,合并成批量请求)。其他选项与 balances() 相同;
|
|
600
|
+
* withSlot 时所有钱包的结果带同一个 slot(整次查询的最小值)。任一 owner 非法时报错
|
|
601
|
+
*
|
|
602
|
+
* ```ts
|
|
603
|
+
* await sol.multiBalances([{ owner: a, mints: [NATIVE_MINT, USDC] }, { owner: b, mints: [USDC] }])
|
|
604
|
+
* ```
|
|
605
|
+
*/
|
|
606
|
+
export async function multiBalancesOf(rc: ReadContext, queries: readonly BalanceQuery[], options: BalancesOptions): Promise<TokenBalance[][]> {
|
|
607
|
+
for (const { owner } of queries) {
|
|
608
|
+
if (!isAddress(owner)) {
|
|
609
|
+
throw new InvalidAddressError(`Invalid owner address: ${owner}`, owner)
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const programs = resolvePrograms(options.tokenPrograms)
|
|
613
|
+
const ctx = queryContext(options)
|
|
614
|
+
const lists =
|
|
615
|
+
options.scan || options.accounts === 'all'
|
|
616
|
+
? await Promise.all(queries.map(({ owner, mints }) => balancesOf(rc, ctx, owner, mints, options, programs)))
|
|
617
|
+
: await ataBalances(rc, ctx, queries, options.symbol ?? false, programs)
|
|
618
|
+
return lists.map((list) => attachSlot(list, ctx, options))
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* 列出持有人拥有的全部代币(SOL 在第一位),带 name / symbol,适合做资产列表。
|
|
623
|
+
* 需要支持 getTokenAccountsByOwner 的节点(免费公共节点大多不支持)。
|
|
624
|
+
*
|
|
625
|
+
* - 默认不含 NFT(精度 0 且数量 1)和余额为 0 的代币账户(如已清空的 ATA),可用 includeNfts / includeZero 打开
|
|
626
|
+
* - accounts 是该代币的代币账户数量;大于 1 时余额为所有账户的合计
|
|
627
|
+
* - 顺序:SOL,然后 SPL Token 的代币,再然后 Token-2022 的代币(各自按节点返回的顺序;没有价格信息,不按价值排序)
|
|
628
|
+
* - tokenPrograms: ['spl'] 时不含 Token-2022 代币
|
|
629
|
+
* - withSlot 的 slot 只算余额扫描(元数据读取不计入)
|
|
630
|
+
*/
|
|
631
|
+
export async function ownerTokensOf(rc: ReadContext, owner: string, options: OwnerTokensOptions): Promise<OwnedToken[]> {
|
|
632
|
+
if (!isAddress(owner)) {
|
|
633
|
+
throw new InvalidAddressError(`Invalid owner address: ${owner}`, owner)
|
|
634
|
+
}
|
|
635
|
+
const { metadata = true, includeNative = true, includeZero = false, includeNfts = false } = options
|
|
636
|
+
const scope = rc.scope
|
|
637
|
+
const ctx = queryContext(options)
|
|
638
|
+
const { sol, held } = await scanHoldings(rc, ctx, owner, resolvePrograms(options.tokenPrograms), metadata)
|
|
639
|
+
const scanSlot = ctx.slot
|
|
640
|
+
// Wrapped SOL 是独立的代币账户(需要 unwrap 才是 SOL),资产列表里单独列出(native: false)
|
|
641
|
+
const tokens = [...held].filter(([, h]) => (includeZero || h.amount > 0n) && (includeNfts || !isNftHolding(h)))
|
|
642
|
+
// 元数据不受 minContextSlot 约束(不会因为刚成交而变化)
|
|
643
|
+
const failures = metadata ? await loadMeta(rc, { signal: options.signal }, tokens.map(([mint]) => mint), true, true) : new Map<string, MetaFailure>()
|
|
644
|
+
|
|
645
|
+
const list: OwnedToken[] = tokens.map(([mint, h]) => {
|
|
646
|
+
const item: OwnedToken = {
|
|
647
|
+
token: mint,
|
|
648
|
+
native: false,
|
|
649
|
+
balance: h.amount.toString(),
|
|
650
|
+
decimals: h.decimals,
|
|
651
|
+
formatted: formatUnits(h.amount, h.decimals),
|
|
652
|
+
tokenProgram: h.program,
|
|
653
|
+
accounts: h.accounts,
|
|
654
|
+
success: true,
|
|
655
|
+
}
|
|
656
|
+
if (!metadata) {
|
|
657
|
+
return item
|
|
658
|
+
}
|
|
659
|
+
const meta = getMeta(scope, mint)
|
|
660
|
+
const status = failures.get(mint) === 'failed' ? 'failed' : meta.name == null && meta.symbol == null ? 'missing' : 'ok'
|
|
661
|
+
return { ...item, name: meta.name ?? null, symbol: meta.symbol ?? null, metadataStatus: status }
|
|
662
|
+
})
|
|
663
|
+
if (includeNative) {
|
|
664
|
+
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 }
|
|
665
|
+
list.unshift(metadata ? { ...sol_, name: 'Solana', symbol: 'SOL', metadataStatus: 'ok' } : sol_)
|
|
666
|
+
}
|
|
667
|
+
return attachSlot(list, { slot: scanSlot }, options)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** 查某地址持有的全部 NFT(扫描代币账户:数量 1、精度 0),再批量读元数据。需要支持 getTokenAccountsByOwner 的节点 */
|
|
671
|
+
export async function ownerNftsOf(rc: ReadContext, owner: string, options: RequestOptions): Promise<NftDetails[]> {
|
|
672
|
+
if (!isAddress(owner)) {
|
|
673
|
+
throw new InvalidAddressError(`Invalid owner address: ${owner}`, owner)
|
|
674
|
+
}
|
|
675
|
+
const { held } = await scanHoldings(rc, { signal: options.signal }, owner)
|
|
676
|
+
const mints = [...held].filter(([, h]) => isNftHolding(h)).map(([mint]) => mint)
|
|
677
|
+
return mints.length ? (await nftsOf(rc, mints, options)).filter((nft) => nft.success) : []
|
|
678
|
+
}
|