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