@w3lib/solana-batch-call 0.5.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.
Files changed (220) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +129 -9
  3. package/dist/accountReader.d.ts +57 -0
  4. package/dist/accountReader.d.ts.map +1 -0
  5. package/dist/accountReader.js +149 -0
  6. package/dist/accountReader.js.map +1 -0
  7. package/dist/address.d.ts +9 -0
  8. package/dist/address.d.ts.map +1 -1
  9. package/dist/address.js +36 -6
  10. package/dist/address.js.map +1 -1
  11. package/dist/approvals.d.ts +5 -3
  12. package/dist/approvals.d.ts.map +1 -1
  13. package/dist/approvals.js +25 -36
  14. package/dist/approvals.js.map +1 -1
  15. package/dist/balances.d.ts +178 -0
  16. package/dist/balances.d.ts.map +1 -0
  17. package/dist/balances.js +524 -0
  18. package/dist/balances.js.map +1 -0
  19. package/dist/batchPlan.d.ts +52 -0
  20. package/dist/batchPlan.d.ts.map +1 -0
  21. package/dist/batchPlan.js +64 -0
  22. package/dist/batchPlan.js.map +1 -0
  23. package/dist/bytes.d.ts +31 -0
  24. package/dist/bytes.d.ts.map +1 -0
  25. package/dist/bytes.js +74 -0
  26. package/dist/bytes.js.map +1 -0
  27. package/dist/claim.d.ts +163 -0
  28. package/dist/claim.d.ts.map +1 -0
  29. package/dist/claim.js +197 -0
  30. package/dist/claim.js.map +1 -0
  31. package/dist/client.d.ts +26 -41
  32. package/dist/client.d.ts.map +1 -1
  33. package/dist/client.js +41 -986
  34. package/dist/client.js.map +1 -1
  35. package/dist/clientConfig.d.ts +11 -0
  36. package/dist/clientConfig.d.ts.map +1 -0
  37. package/dist/clientConfig.js +3 -0
  38. package/dist/clientConfig.js.map +1 -0
  39. package/dist/decode.d.ts +22 -0
  40. package/dist/decode.d.ts.map +1 -0
  41. package/dist/decode.js +160 -0
  42. package/dist/decode.js.map +1 -0
  43. package/dist/describe.d.ts +26 -0
  44. package/dist/describe.d.ts.map +1 -0
  45. package/dist/describe.js +58 -0
  46. package/dist/describe.js.map +1 -0
  47. package/dist/fees.d.ts.map +1 -1
  48. package/dist/fees.js +2 -12
  49. package/dist/fees.js.map +1 -1
  50. package/dist/history.d.ts +125 -0
  51. package/dist/history.d.ts.map +1 -0
  52. package/dist/history.js +220 -0
  53. package/dist/history.js.map +1 -0
  54. package/dist/historyHelius.d.ts +52 -0
  55. package/dist/historyHelius.d.ts.map +1 -0
  56. package/dist/historyHelius.js +125 -0
  57. package/dist/historyHelius.js.map +1 -0
  58. package/dist/index.d.ts +3 -3
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +1 -1
  61. package/dist/index.js.map +1 -1
  62. package/dist/layout.d.ts +21 -0
  63. package/dist/layout.d.ts.map +1 -1
  64. package/dist/layout.js +50 -69
  65. package/dist/layout.js.map +1 -1
  66. package/dist/liquidity/common.js +2 -2
  67. package/dist/liquidity/common.js.map +1 -1
  68. package/dist/liquidity/orca.d.ts +6 -0
  69. package/dist/liquidity/orca.d.ts.map +1 -1
  70. package/dist/liquidity/orca.js +14 -3
  71. package/dist/liquidity/orca.js.map +1 -1
  72. package/dist/liquidity/raydiumClmm.d.ts +6 -0
  73. package/dist/liquidity/raydiumClmm.d.ts.map +1 -1
  74. package/dist/liquidity/raydiumClmm.js +12 -5
  75. package/dist/liquidity/raydiumClmm.js.map +1 -1
  76. package/dist/lp.d.ts +55 -12
  77. package/dist/lp.d.ts.map +1 -1
  78. package/dist/lp.js +158 -70
  79. package/dist/lp.js.map +1 -1
  80. package/dist/lpMath.d.ts +22 -0
  81. package/dist/lpMath.d.ts.map +1 -1
  82. package/dist/lpMath.js +21 -0
  83. package/dist/lpMath.js.map +1 -1
  84. package/dist/portfolio.d.ts.map +1 -1
  85. package/dist/portfolio.js +3 -8
  86. package/dist/portfolio.js.map +1 -1
  87. package/dist/programs.d.ts +19 -0
  88. package/dist/programs.d.ts.map +1 -0
  89. package/dist/programs.js +175 -0
  90. package/dist/programs.js.map +1 -0
  91. package/dist/reclaim.d.ts +192 -0
  92. package/dist/reclaim.d.ts.map +1 -0
  93. package/dist/reclaim.js +399 -0
  94. package/dist/reclaim.js.map +1 -0
  95. package/dist/risk.js +2 -2
  96. package/dist/risk.js.map +1 -1
  97. package/dist/rpc.d.ts +30 -7
  98. package/dist/rpc.d.ts.map +1 -1
  99. package/dist/rpc.js +404 -111
  100. package/dist/rpc.js.map +1 -1
  101. package/dist/simulate.d.ts +106 -0
  102. package/dist/simulate.d.ts.map +1 -0
  103. package/dist/simulate.js +395 -0
  104. package/dist/simulate.js.map +1 -0
  105. package/dist/source.d.ts +8 -4
  106. package/dist/source.d.ts.map +1 -1
  107. package/dist/source.js +11 -4
  108. package/dist/source.js.map +1 -1
  109. package/dist/subpaths/approvals.d.ts +3 -3
  110. package/dist/subpaths/approvals.d.ts.map +1 -1
  111. package/dist/subpaths/approvals.js.map +1 -1
  112. package/dist/subpaths/claim.d.ts +31 -0
  113. package/dist/subpaths/claim.d.ts.map +1 -0
  114. package/dist/subpaths/claim.js +31 -0
  115. package/dist/subpaths/claim.js.map +1 -0
  116. package/dist/subpaths/history.d.ts +21 -0
  117. package/dist/subpaths/history.d.ts.map +1 -0
  118. package/dist/subpaths/history.js +22 -0
  119. package/dist/subpaths/history.js.map +1 -0
  120. package/dist/subpaths/lp.d.ts +2 -2
  121. package/dist/subpaths/lp.d.ts.map +1 -1
  122. package/dist/subpaths/lp.js +2 -2
  123. package/dist/subpaths/lp.js.map +1 -1
  124. package/dist/subpaths/reclaim.d.ts +34 -0
  125. package/dist/subpaths/reclaim.d.ts.map +1 -0
  126. package/dist/subpaths/reclaim.js +34 -0
  127. package/dist/subpaths/reclaim.js.map +1 -0
  128. package/dist/subpaths/simulate.d.ts +22 -0
  129. package/dist/subpaths/simulate.d.ts.map +1 -0
  130. package/dist/subpaths/simulate.js +23 -0
  131. package/dist/subpaths/simulate.js.map +1 -0
  132. package/dist/swap/index.js +3 -3
  133. package/dist/swap/index.js.map +1 -1
  134. package/dist/tokenDetails.d.ts +12 -0
  135. package/dist/tokenDetails.d.ts.map +1 -0
  136. package/dist/tokenDetails.js +196 -0
  137. package/dist/tokenDetails.js.map +1 -0
  138. package/dist/tokenMeta.d.ts +63 -0
  139. package/dist/tokenMeta.d.ts.map +1 -0
  140. package/dist/tokenMeta.js +226 -0
  141. package/dist/tokenMeta.js.map +1 -0
  142. package/dist/tradeCheck.js +3 -3
  143. package/dist/tradeCheck.js.map +1 -1
  144. package/dist/util.d.ts +22 -0
  145. package/dist/util.d.ts.map +1 -0
  146. package/dist/util.js +107 -0
  147. package/dist/util.js.map +1 -0
  148. package/dist/write/chain.d.ts +51 -0
  149. package/dist/write/chain.d.ts.map +1 -0
  150. package/dist/write/chain.js +184 -0
  151. package/dist/write/chain.js.map +1 -0
  152. package/dist/write/collect.js +2 -2
  153. package/dist/write/collect.js.map +1 -1
  154. package/dist/write/errors.d.ts +10 -0
  155. package/dist/write/errors.d.ts.map +1 -0
  156. package/dist/write/errors.js +77 -0
  157. package/dist/write/errors.js.map +1 -0
  158. package/dist/write/instructions.d.ts +2 -0
  159. package/dist/write/instructions.d.ts.map +1 -1
  160. package/dist/write/instructions.js +16 -0
  161. package/dist/write/instructions.js.map +1 -1
  162. package/dist/write/pack.d.ts +28 -0
  163. package/dist/write/pack.d.ts.map +1 -0
  164. package/dist/write/pack.js +125 -0
  165. package/dist/write/pack.js.map +1 -0
  166. package/dist/write/send.d.ts +23 -9
  167. package/dist/write/send.d.ts.map +1 -1
  168. package/dist/write/send.js +53 -351
  169. package/dist/write/send.js.map +1 -1
  170. package/dist/write/tracker.d.ts +24 -0
  171. package/dist/write/tracker.d.ts.map +1 -0
  172. package/dist/write/tracker.js +129 -0
  173. package/dist/write/tracker.js.map +1 -0
  174. package/package.json +21 -1
  175. package/src/accountReader.ts +187 -0
  176. package/src/address.ts +41 -6
  177. package/src/approvals.ts +36 -44
  178. package/src/balances.ts +678 -0
  179. package/src/batchPlan.ts +117 -0
  180. package/src/bytes.ts +89 -0
  181. package/src/claim.ts +327 -0
  182. package/src/client.ts +59 -1239
  183. package/src/clientConfig.ts +12 -0
  184. package/src/decode.ts +176 -0
  185. package/src/describe.ts +77 -0
  186. package/src/fees.ts +2 -9
  187. package/src/history.ts +325 -0
  188. package/src/historyHelius.ts +136 -0
  189. package/src/index.ts +13 -1
  190. package/src/layout.ts +63 -82
  191. package/src/liquidity/common.ts +2 -2
  192. package/src/liquidity/orca.ts +15 -5
  193. package/src/liquidity/raydiumClmm.ts +13 -5
  194. package/src/lp.ts +226 -75
  195. package/src/lpMath.ts +38 -0
  196. package/src/portfolio.ts +8 -11
  197. package/src/programs.ts +216 -0
  198. package/src/reclaim.ts +550 -0
  199. package/src/risk.ts +2 -2
  200. package/src/rpc.ts +476 -118
  201. package/src/simulate.ts +517 -0
  202. package/src/source.ts +14 -5
  203. package/src/subpaths/approvals.ts +3 -2
  204. package/src/subpaths/claim.ts +54 -0
  205. package/src/subpaths/history.ts +38 -0
  206. package/src/subpaths/lp.ts +5 -1
  207. package/src/subpaths/reclaim.ts +58 -0
  208. package/src/subpaths/simulate.ts +37 -0
  209. package/src/swap/index.ts +3 -3
  210. package/src/tokenDetails.ts +240 -0
  211. package/src/tokenMeta.ts +270 -0
  212. package/src/tradeCheck.ts +3 -3
  213. package/src/util.ts +110 -0
  214. package/src/write/chain.ts +218 -0
  215. package/src/write/collect.ts +2 -2
  216. package/src/write/errors.ts +82 -0
  217. package/src/write/instructions.ts +17 -0
  218. package/src/write/pack.ts +142 -0
  219. package/src/write/send.ts +74 -407
  220. package/src/write/tracker.ts +152 -0
@@ -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
+ }