@w3lib/solana-batch-call 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +16 -8
- package/dist/accountReader.d.ts +57 -0
- package/dist/accountReader.d.ts.map +1 -0
- package/dist/accountReader.js +149 -0
- package/dist/accountReader.js.map +1 -0
- package/dist/address.d.ts +9 -0
- package/dist/address.d.ts.map +1 -1
- package/dist/address.js +36 -6
- package/dist/address.js.map +1 -1
- package/dist/approvals.d.ts +5 -3
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +25 -36
- package/dist/approvals.js.map +1 -1
- package/dist/balances.d.ts +178 -0
- package/dist/balances.d.ts.map +1 -0
- package/dist/balances.js +524 -0
- package/dist/balances.js.map +1 -0
- package/dist/batchPlan.d.ts +2 -2
- package/dist/batchPlan.d.ts.map +1 -1
- package/dist/batchPlan.js +5 -26
- package/dist/batchPlan.js.map +1 -1
- package/dist/bytes.d.ts +31 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +74 -0
- package/dist/bytes.js.map +1 -0
- package/dist/claim.d.ts +5 -3
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +10 -10
- package/dist/claim.js.map +1 -1
- package/dist/client.d.ts +26 -41
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +41 -986
- package/dist/client.js.map +1 -1
- package/dist/clientConfig.d.ts +11 -0
- package/dist/clientConfig.d.ts.map +1 -0
- package/dist/clientConfig.js +3 -0
- package/dist/clientConfig.js.map +1 -0
- package/dist/decode.d.ts.map +1 -1
- package/dist/decode.js +2 -29
- package/dist/decode.js.map +1 -1
- package/dist/fees.d.ts.map +1 -1
- package/dist/fees.js +2 -12
- package/dist/fees.js.map +1 -1
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +4 -15
- package/dist/history.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +21 -0
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +50 -69
- package/dist/layout.js.map +1 -1
- package/dist/liquidity/common.js +2 -2
- package/dist/liquidity/common.js.map +1 -1
- package/dist/lp.d.ts +9 -13
- package/dist/lp.d.ts.map +1 -1
- package/dist/lp.js +67 -55
- package/dist/lp.js.map +1 -1
- package/dist/portfolio.d.ts.map +1 -1
- package/dist/portfolio.js +3 -8
- package/dist/portfolio.js.map +1 -1
- package/dist/reclaim.d.ts +4 -2
- package/dist/reclaim.d.ts.map +1 -1
- package/dist/reclaim.js +16 -16
- package/dist/reclaim.js.map +1 -1
- package/dist/risk.js +2 -2
- package/dist/risk.js.map +1 -1
- package/dist/rpc.d.ts +30 -7
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +400 -109
- package/dist/rpc.js.map +1 -1
- package/dist/simulate.d.ts.map +1 -1
- package/dist/simulate.js +3 -26
- package/dist/simulate.js.map +1 -1
- package/dist/source.d.ts +8 -4
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +11 -4
- package/dist/source.js.map +1 -1
- package/dist/subpaths/approvals.d.ts +3 -3
- package/dist/subpaths/approvals.d.ts.map +1 -1
- package/dist/subpaths/approvals.js.map +1 -1
- package/dist/subpaths/claim.d.ts +3 -3
- package/dist/subpaths/claim.d.ts.map +1 -1
- package/dist/subpaths/claim.js.map +1 -1
- package/dist/subpaths/reclaim.d.ts +3 -3
- package/dist/subpaths/reclaim.d.ts.map +1 -1
- package/dist/subpaths/reclaim.js.map +1 -1
- package/dist/swap/index.js +3 -3
- package/dist/swap/index.js.map +1 -1
- package/dist/tokenDetails.d.ts +12 -0
- package/dist/tokenDetails.d.ts.map +1 -0
- package/dist/tokenDetails.js +196 -0
- package/dist/tokenDetails.js.map +1 -0
- package/dist/tokenMeta.d.ts +63 -0
- package/dist/tokenMeta.d.ts.map +1 -0
- package/dist/tokenMeta.js +226 -0
- package/dist/tokenMeta.js.map +1 -0
- package/dist/tradeCheck.js +3 -3
- package/dist/tradeCheck.js.map +1 -1
- package/dist/util.d.ts +22 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +107 -0
- package/dist/util.js.map +1 -0
- package/dist/write/chain.d.ts +51 -0
- package/dist/write/chain.d.ts.map +1 -0
- package/dist/write/chain.js +184 -0
- package/dist/write/chain.js.map +1 -0
- package/dist/write/collect.js +2 -2
- package/dist/write/collect.js.map +1 -1
- package/dist/write/errors.d.ts +10 -0
- package/dist/write/errors.d.ts.map +1 -0
- package/dist/write/errors.js +77 -0
- package/dist/write/errors.js.map +1 -0
- package/dist/write/pack.d.ts +28 -0
- package/dist/write/pack.d.ts.map +1 -0
- package/dist/write/pack.js +125 -0
- package/dist/write/pack.js.map +1 -0
- package/dist/write/send.d.ts +23 -9
- package/dist/write/send.d.ts.map +1 -1
- package/dist/write/send.js +53 -351
- package/dist/write/send.js.map +1 -1
- package/dist/write/tracker.d.ts +24 -0
- package/dist/write/tracker.d.ts.map +1 -0
- package/dist/write/tracker.js +129 -0
- package/dist/write/tracker.js.map +1 -0
- package/package.json +1 -1
- package/src/accountReader.ts +187 -0
- package/src/address.ts +41 -6
- package/src/approvals.ts +36 -44
- package/src/balances.ts +678 -0
- package/src/batchPlan.ts +6 -26
- package/src/bytes.ts +89 -0
- package/src/claim.ts +15 -11
- package/src/client.ts +59 -1239
- package/src/clientConfig.ts +12 -0
- package/src/decode.ts +4 -30
- package/src/fees.ts +2 -9
- package/src/history.ts +4 -15
- package/src/index.ts +13 -1
- package/src/layout.ts +63 -82
- package/src/liquidity/common.ts +2 -2
- package/src/lp.ts +86 -60
- package/src/portfolio.ts +8 -11
- package/src/reclaim.ts +20 -17
- package/src/risk.ts +2 -2
- package/src/rpc.ts +472 -116
- package/src/simulate.ts +3 -21
- package/src/source.ts +14 -5
- package/src/subpaths/approvals.ts +3 -2
- package/src/subpaths/claim.ts +3 -2
- package/src/subpaths/reclaim.ts +3 -2
- package/src/swap/index.ts +3 -3
- package/src/tokenDetails.ts +240 -0
- package/src/tokenMeta.ts +270 -0
- package/src/tradeCheck.ts +3 -3
- package/src/util.ts +110 -0
- package/src/write/chain.ts +218 -0
- package/src/write/collect.ts +2 -2
- package/src/write/errors.ts +82 -0
- package/src/write/pack.ts +142 -0
- package/src/write/send.ts +74 -407
- package/src/write/tracker.ts +152 -0
package/src/client.ts
CHANGED
|
@@ -1,46 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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'
|
|
25
|
-
import { NetworkMismatchError, resolveSource, type RpcSource, type SourceOptions } from './source.js'
|
|
26
|
-
import {
|
|
27
|
-
DEFAULT_TOKEN_FIELDS,
|
|
28
|
-
type Commitment,
|
|
29
|
-
type DefaultTokenField,
|
|
30
|
-
type FailureReason,
|
|
31
|
-
type NftDetails,
|
|
32
|
-
type NftOwner,
|
|
33
|
-
type OwnedToken,
|
|
34
|
-
type SolBalance,
|
|
35
|
-
type TokenBalance,
|
|
36
|
-
type TokenDetails,
|
|
37
|
-
type TokenField,
|
|
38
|
-
type TokenProgramOption,
|
|
39
|
-
type TokenStandard,
|
|
40
|
-
type TransferFee,
|
|
41
|
-
type TransferFeeConfig,
|
|
1
|
+
import { AccountReader, queryContext } from './accountReader.js'
|
|
2
|
+
import { balancesFor, multiBalancesOf, ownerNftsOf, ownerTokensOf, solBalancesOf, type ReadContext } from './balances.js'
|
|
3
|
+
import { GENESIS_HASHES, NATIVE_MINT, SYSTEM_PROGRAM_ID, clusterOfGenesis, type Cluster } from './constants.js'
|
|
4
|
+
import type { AccountInfo } from './layout.js'
|
|
5
|
+
import type { RpcTransport } from './rpc.js'
|
|
6
|
+
import { clientSourceConfigs, resolveSource, type RpcSource, type SourceOptions } from './source.js'
|
|
7
|
+
import { nftOwnersOf, nftsOf, tokensOf } from './tokenDetails.js'
|
|
8
|
+
import type {
|
|
9
|
+
Commitment,
|
|
10
|
+
DefaultTokenField,
|
|
11
|
+
NftDetails,
|
|
12
|
+
NftOwner,
|
|
13
|
+
OwnedToken,
|
|
14
|
+
SolBalance,
|
|
15
|
+
TokenBalance,
|
|
16
|
+
TokenDetails,
|
|
17
|
+
TokenField,
|
|
18
|
+
TokenProgramOption,
|
|
42
19
|
} from './types.js'
|
|
43
20
|
|
|
21
|
+
// 代币信息缓存在 tokenMeta.ts;这里继续导出(公开 API 和测试的引用路径不变)
|
|
22
|
+
export { exportTokenMetaCache, importTokenMetaCache, persistTokenMetaCache, resetTokenMetaCache, type PersistTokenMetaOptions, type TokenMetaSnapshot } from './tokenMeta.js'
|
|
23
|
+
|
|
44
24
|
export interface ClientConfig extends SourceOptions {
|
|
45
25
|
/** 网络。不传 provider 时用于选择内置节点(默认 mainnet);传了 provider 时用于校验节点所在网络 */
|
|
46
26
|
cluster?: Cluster
|
|
@@ -58,7 +38,8 @@ export interface ClientConfig extends SourceOptions {
|
|
|
58
38
|
export interface RequestOptions {
|
|
59
39
|
/**
|
|
60
40
|
* 取消查询:已取消时直接 reject(不发请求);中途取消时立即 reject(reason 为 signal.reason)。
|
|
61
|
-
*
|
|
41
|
+
* signal 会传到底层传输:还在排队的请求不再发出,已经发出的 HTTP 请求在合并在一起的所有调用方都取消后中止,
|
|
42
|
+
* 取消后也不再换节点重试(自定义传输可以处理 request 的第三个参数 `{ signal }`,不处理时请求照常完成、结果丢弃)
|
|
62
43
|
*/
|
|
63
44
|
signal?: AbortSignal
|
|
64
45
|
}
|
|
@@ -123,34 +104,18 @@ export interface AccountsOptions extends RequestOptions {
|
|
|
123
104
|
/** solBalances() 的参数 */
|
|
124
105
|
export interface SolBalancesOptions extends RequestOptions, SlotOptions {}
|
|
125
106
|
|
|
107
|
+
/** balances(owner, options) 新写法的参数:mints 放进选项里(旧写法 balances(owner, mints, options) 照常可用) */
|
|
108
|
+
export interface BalancesQueryOptions extends BalancesOptions {
|
|
109
|
+
/** 要查的代币;不传时扫描全部代币账户(同旧写法不传 mints) */
|
|
110
|
+
mints?: readonly string[]
|
|
111
|
+
}
|
|
112
|
+
|
|
126
113
|
/** multiBalances() 的单项:一个钱包和要查的代币 */
|
|
127
114
|
export interface BalanceQuery {
|
|
128
115
|
owner: string
|
|
129
116
|
mints: readonly string[]
|
|
130
117
|
}
|
|
131
118
|
|
|
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
|
-
|
|
143
|
-
const MAX_ACCOUNTS_PER_REQUEST = 100
|
|
144
|
-
/** 同时进行的 getMultipleAccounts 请求数上限 */
|
|
145
|
-
const MAX_PARALLEL_ACCOUNT_REQUESTS = 3
|
|
146
|
-
/** #fetchAccounts 里没能读到(节点问题)的账户 */
|
|
147
|
-
const FAILED = Symbol('failed')
|
|
148
|
-
|
|
149
|
-
/** 可以降级处理的错误:网络 / HTTP 错误、节点限制(限频、需要 Key 等)、节点不在期望的网络上、所有节点都失败;其他(参数错误、程序 bug)照常抛出 */
|
|
150
|
-
function isRecoverable(err: unknown): boolean {
|
|
151
|
-
return err instanceof HttpError || (err instanceof RpcError && err.nodeFault) || err instanceof NetworkMismatchError || err instanceof AllNodesFailedError
|
|
152
|
-
}
|
|
153
|
-
|
|
154
119
|
/** 查询带上取消:已取消时不执行;中途取消时立即 reject,查询本身继续(结果丢弃) */
|
|
155
120
|
function abortable<T>(signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T> {
|
|
156
121
|
if (!signal) {
|
|
@@ -168,297 +133,8 @@ function abortable<T>(signal: AbortSignal | undefined, run: () => Promise<T>): P
|
|
|
168
133
|
})
|
|
169
134
|
}
|
|
170
135
|
|
|
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)
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** 按 mint 汇总后精度 0、数量 1 的持仓视为 NFT(ownerTokens 与 ownerNfts 共用) */
|
|
228
|
-
function isNftHolding(holding: Holding): boolean {
|
|
229
|
-
return holding.decimals === 0 && holding.amount === 1n
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const TOKEN_STANDARDS: readonly TokenStandard[] = [
|
|
233
|
-
'NonFungible',
|
|
234
|
-
'FungibleAsset',
|
|
235
|
-
'Fungible',
|
|
236
|
-
'NonFungibleEdition',
|
|
237
|
-
'ProgrammableNonFungible',
|
|
238
|
-
'ProgrammableNonFungibleEdition',
|
|
239
|
-
]
|
|
240
|
-
|
|
241
|
-
interface TokenMeta {
|
|
242
|
-
decimals?: number
|
|
243
|
-
tokenProgram?: string
|
|
244
|
-
/** tokenProgram 来自导入的缓存、还没从链上确认过:ATA 模式第一次用时按程序未知处理(推导两个程序的 ATA、读 mint 确认) */
|
|
245
|
-
programUnverified?: boolean
|
|
246
|
-
/** null:确认没有元数据 */
|
|
247
|
-
name?: string | null
|
|
248
|
-
symbol?: string | null
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
interface CachedMeta extends TokenMeta {
|
|
252
|
-
/** name / symbol 的写入时间 */
|
|
253
|
-
namesAt?: number
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// 代币信息缓存(按 网络 + mint):decimals / 所属程序不会变,永久缓存;
|
|
257
|
-
// name / symbol 来自可修改的元数据,缓存 1 小时。条目数有上限,超出时淘汰最早写入的
|
|
258
|
-
const NAME_TTL = 60 * 60 * 1000
|
|
259
|
-
const MAX_CACHED_TOKENS = 50_000
|
|
260
|
-
const tokenMetaCache = new Map<string, CachedMeta>()
|
|
261
|
-
|
|
262
|
-
// 已知数据为空的钱包账户(系统账户或不存在):ATA 模式下和代币账户放在同一个请求里读,不必单独带 dataSlice 多发一个请求。
|
|
263
|
-
// 按 scope + 地址记,最多 1 万个,超出淘汰最早的;读到不是系统账户(如程序账户)时移除
|
|
264
|
-
const MAX_SMALL_OWNERS = 10_000
|
|
265
|
-
const smallOwners = new Set<string>()
|
|
266
|
-
|
|
267
|
-
function isSmallOwner(scope: string, owner: string): boolean {
|
|
268
|
-
return smallOwners.has(`${scope}:${owner}`)
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** 根据只读 lamports 的结果判断钱包账户是否“数据为空”(dataSlice 读不到数据长度,按所属程序判断:系统账户的数据为空) */
|
|
272
|
-
function learnOwner(scope: string, owner: string, account: AccountInfo | null): void {
|
|
273
|
-
const key = `${scope}:${owner}`
|
|
274
|
-
if (account === null || account.owner === SYSTEM_PROGRAM_ID) {
|
|
275
|
-
smallOwners.delete(key)
|
|
276
|
-
smallOwners.add(key)
|
|
277
|
-
if (smallOwners.size > MAX_SMALL_OWNERS) {
|
|
278
|
-
smallOwners.delete(smallOwners.values().next().value as string)
|
|
279
|
-
}
|
|
280
|
-
} else {
|
|
281
|
-
smallOwners.delete(key)
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
136
|
let clientIds = 0
|
|
286
137
|
|
|
287
|
-
/** 缓存写入监听(persistTokenMetaCache 用) */
|
|
288
|
-
const cacheWriteListeners = new Set<() => void>()
|
|
289
|
-
|
|
290
|
-
/** 测试用:清空代币信息缓存 */
|
|
291
|
-
export function resetTokenMetaCache(): void {
|
|
292
|
-
smallOwners.clear()
|
|
293
|
-
tokenMetaCache.clear()
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/** exportTokenMetaCache() 的结果,可以 JSON 序列化 */
|
|
297
|
-
export interface TokenMetaSnapshot {
|
|
298
|
-
version: 1
|
|
299
|
-
/** [网络:mint, 信息];namesAt 是 name / symbol 的写入时间(毫秒时间戳),导入后继续按 1 小时过期 */
|
|
300
|
-
entries: Array<[string, { decimals?: number; tokenProgram?: string; name?: string | null; symbol?: string | null; namesAt?: number }]>
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const SHARED_SCOPES = new Set<string>(['mainnet', 'devnet', 'testnet'])
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* 导出代币信息缓存(decimals / 所属程序 / name / symbol),用于持久化:刷新页面后导入,不必重查 decimals。
|
|
307
|
-
* 只导出按网络共享的条目(自定义节点且没指定 cluster 的客户端,缓存只在该客户端内,不导出)。
|
|
308
|
-
* maxEntries:只导出最近写入的这么多条
|
|
309
|
-
*/
|
|
310
|
-
export function exportTokenMetaCache(maxEntries = Number.POSITIVE_INFINITY): TokenMetaSnapshot {
|
|
311
|
-
const entries: TokenMetaSnapshot['entries'] = []
|
|
312
|
-
for (const [key, meta] of tokenMetaCache) {
|
|
313
|
-
if (SHARED_SCOPES.has(key.slice(0, key.indexOf(':')))) {
|
|
314
|
-
const { programUnverified: _, ...exported } = meta
|
|
315
|
-
entries.push([key, exported])
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
return { version: 1, entries: entries.slice(Math.max(0, entries.length - maxEntries)) }
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/** 导入 exportTokenMetaCache() 的结果;格式不对的条目跳过,内存里已有的条目不覆盖 */
|
|
322
|
-
export function importTokenMetaCache(snapshot: unknown): void {
|
|
323
|
-
const entries = (snapshot as Partial<TokenMetaSnapshot> | null)?.entries
|
|
324
|
-
if ((snapshot as Partial<TokenMetaSnapshot> | null)?.version !== 1 || !Array.isArray(entries)) {
|
|
325
|
-
return
|
|
326
|
-
}
|
|
327
|
-
for (const entry of entries) {
|
|
328
|
-
const meta = validMeta(entry)
|
|
329
|
-
if (meta && !tokenMetaCache.has(entry[0])) {
|
|
330
|
-
tokenMetaCache.set(entry[0], meta)
|
|
331
|
-
if (tokenMetaCache.size > MAX_CACHED_TOKENS) {
|
|
332
|
-
tokenMetaCache.delete(tokenMetaCache.keys().next().value as string)
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function validMeta(entry: unknown): CachedMeta | null {
|
|
339
|
-
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string') {
|
|
340
|
-
return null
|
|
341
|
-
}
|
|
342
|
-
const [key, raw] = entry as [string, Record<string, unknown>]
|
|
343
|
-
const colon = key.indexOf(':')
|
|
344
|
-
if (!SHARED_SCOPES.has(key.slice(0, colon)) || !isAddress(key.slice(colon + 1)) || !raw || typeof raw !== 'object') {
|
|
345
|
-
return null
|
|
346
|
-
}
|
|
347
|
-
const meta: CachedMeta = {}
|
|
348
|
-
if (typeof raw.decimals === 'number' && Number.isInteger(raw.decimals) && raw.decimals >= 0 && raw.decimals <= 255) meta.decimals = raw.decimals
|
|
349
|
-
if (raw.tokenProgram === TOKEN_PROGRAM_ID || raw.tokenProgram === TOKEN_2022_PROGRAM_ID) {
|
|
350
|
-
meta.tokenProgram = raw.tokenProgram
|
|
351
|
-
meta.programUnverified = true
|
|
352
|
-
}
|
|
353
|
-
// 写入时间只接受有限值、且不晚于现在(留 1 分钟时钟误差):未来的时间会让 name / symbol 永不过期,截到现在
|
|
354
|
-
if (
|
|
355
|
-
typeof raw.namesAt === 'number' &&
|
|
356
|
-
Number.isFinite(raw.namesAt) &&
|
|
357
|
-
(typeof raw.name === 'string' || raw.name === null) &&
|
|
358
|
-
(typeof raw.symbol === 'string' || raw.symbol === null)
|
|
359
|
-
) {
|
|
360
|
-
meta.name = raw.name
|
|
361
|
-
meta.symbol = raw.symbol
|
|
362
|
-
meta.namesAt = Math.min(raw.namesAt, Date.now())
|
|
363
|
-
}
|
|
364
|
-
return Object.keys(meta).length ? meta : null
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
export interface PersistTokenMetaOptions {
|
|
368
|
-
/** 存储的键,默认 'w3lib:solana-token-meta' */
|
|
369
|
-
key?: string
|
|
370
|
-
/** 最多保存多少条(最近写入的优先),默认 5000 */
|
|
371
|
-
maxEntries?: number
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* 把代币信息缓存持久化到同步的键值存储(如 localStorage):调用时读入,之后缓存有写入就防抖(约 1 秒)整体写回。
|
|
376
|
-
* 返回停止函数(会立即写回还没写的变化)。读到格式不对的数据会丢弃;写入失败(如存储已满)忽略。
|
|
377
|
-
*
|
|
378
|
-
* ```ts
|
|
379
|
-
* persistTokenMetaCache(localStorage) // 刷新页面后 decimals 不必重查
|
|
380
|
-
* ```
|
|
381
|
-
*/
|
|
382
|
-
export function persistTokenMetaCache(
|
|
383
|
-
store: { getItem(key: string): string | null; setItem(key: string, value: string): void },
|
|
384
|
-
options: PersistTokenMetaOptions = {},
|
|
385
|
-
): () => void {
|
|
386
|
-
const key = options.key ?? 'w3lib:solana-token-meta'
|
|
387
|
-
const maxEntries = options.maxEntries ?? 5000
|
|
388
|
-
try {
|
|
389
|
-
const raw = store.getItem(key)
|
|
390
|
-
if (raw) {
|
|
391
|
-
importTokenMetaCache(JSON.parse(raw))
|
|
392
|
-
}
|
|
393
|
-
} catch {
|
|
394
|
-
// 格式不对:丢弃
|
|
395
|
-
}
|
|
396
|
-
let timer: ReturnType<typeof setTimeout> | null = null
|
|
397
|
-
const write = () => {
|
|
398
|
-
timer = null
|
|
399
|
-
try {
|
|
400
|
-
store.setItem(key, JSON.stringify(exportTokenMetaCache(maxEntries)))
|
|
401
|
-
} catch {
|
|
402
|
-
// 存储已满等:忽略
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
const onWrite = () => {
|
|
406
|
-
if (!timer) {
|
|
407
|
-
timer = setTimeout(write, 1000)
|
|
408
|
-
;(timer as { unref?: () => void }).unref?.()
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
cacheWriteListeners.add(onWrite)
|
|
412
|
-
return () => {
|
|
413
|
-
cacheWriteListeners.delete(onWrite)
|
|
414
|
-
if (timer) {
|
|
415
|
-
clearTimeout(timer)
|
|
416
|
-
write()
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/** #loadMeta 没能补上 decimals 的原因;failed 为节点问题(tolerant 时) */
|
|
422
|
-
type MetaFailure = 'failed' | 'not-found' | 'not-token'
|
|
423
|
-
|
|
424
|
-
interface Holding {
|
|
425
|
-
amount: bigint
|
|
426
|
-
decimals: number
|
|
427
|
-
program: string
|
|
428
|
-
/** 代币账户数量 */
|
|
429
|
-
accounts: number
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
interface ParsedTokenAccount {
|
|
433
|
-
pubkey: string
|
|
434
|
-
account: {
|
|
435
|
-
owner: string
|
|
436
|
-
// 节点解析不了的账户(如 Token-2022 的新扩展)会退回 base64:["…", "base64"]
|
|
437
|
-
data: { parsed?: { info?: { mint?: string; owner?: string; tokenAmount?: { amount?: string; decimals?: number } } } } | [string, string] | string
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/** jsonParsed 结果里的一个代币账户:节点解析好的直接取;退回 base64 的自己解析(这时没有 decimals,要从 mint 账户补) */
|
|
442
|
-
function readTokenEntry(entry: ParsedTokenAccount): { mint: string; amount: bigint; decimals: number | undefined } | null {
|
|
443
|
-
const data = entry.account?.data
|
|
444
|
-
if (data && !Array.isArray(data) && typeof data === 'object') {
|
|
445
|
-
const info = data.parsed?.info
|
|
446
|
-
if (info?.mint && info.tokenAmount?.decimals !== undefined) {
|
|
447
|
-
return { mint: info.mint, amount: BigInt(info.tokenAmount.amount ?? '0'), decimals: info.tokenAmount.decimals }
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
const encoded = Array.isArray(data) ? (data[1] === 'base64' ? data[0] : undefined) : typeof data === 'string' ? data : undefined
|
|
451
|
-
if (encoded === undefined) {
|
|
452
|
-
return null
|
|
453
|
-
}
|
|
454
|
-
try {
|
|
455
|
-
const parsed = parseTokenAccount(base64.decode(encoded))
|
|
456
|
-
return { mint: parsed.mint, amount: parsed.amount, decimals: undefined }
|
|
457
|
-
} catch {
|
|
458
|
-
return null
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
138
|
/**
|
|
463
139
|
* Solana 批量读取客户端。
|
|
464
140
|
*
|
|
@@ -472,22 +148,16 @@ function readTokenEntry(entry: ParsedTokenAccount): { mint: string; amount: bigi
|
|
|
472
148
|
* ```
|
|
473
149
|
*
|
|
474
150
|
* 同一 tick 内发起的 RPC 调用会合并成一个 JSON-RPC 批量请求;账户读取按 100 个一组用 getMultipleAccounts。
|
|
151
|
+
* 这里只有薄的公开方法:账户读取管线在 accountReader.ts,余额在 balances.ts,代币详情 / NFT 在 tokenDetails.ts,代币信息缓存在 tokenMeta.ts
|
|
475
152
|
*/
|
|
476
|
-
/** ATA 模式下单个代币的查询计划(multiBalances 时多个钱包共用一次 getMultipleAccounts) */
|
|
477
|
-
type AtaItem =
|
|
478
|
-
/** owner:钱包地址(主币余额按地址从两组结果里取) */
|
|
479
|
-
| { kind: 'native'; mint: string; owner: string }
|
|
480
|
-
| { kind: 'invalid'; mint: string }
|
|
481
|
-
| { kind: 'excluded'; mint: string }
|
|
482
|
-
| { kind: 'token'; mint: string; mintIndex: number; metadataIndex: number; atas: Array<{ program: string; index: number }> }
|
|
483
|
-
|
|
484
153
|
export class SolanaClient {
|
|
485
154
|
readonly transport: RpcTransport
|
|
486
155
|
readonly #genesis: () => Promise<string>
|
|
487
156
|
readonly #cluster: Cluster | undefined
|
|
488
157
|
readonly #commitment: Commitment
|
|
489
158
|
readonly #nativeMints: Set<string>
|
|
490
|
-
readonly #
|
|
159
|
+
readonly #reader: AccountReader
|
|
160
|
+
readonly #rc: ReadContext
|
|
491
161
|
|
|
492
162
|
constructor(provider?: RpcSource | readonly RpcSource[], config: ClientConfig = {}) {
|
|
493
163
|
const resolved = resolveSource(provider, config.cluster ?? (provider === undefined ? 'mainnet' : undefined), config)
|
|
@@ -496,8 +166,12 @@ export class SolanaClient {
|
|
|
496
166
|
this.#cluster = config.cluster ?? (provider === undefined ? 'mainnet' : undefined)
|
|
497
167
|
this.#commitment = config.commitment ?? 'confirmed'
|
|
498
168
|
this.#nativeMints = new Set(config.nativeMints ?? [NATIVE_MINT, SYSTEM_PROGRAM_ID])
|
|
169
|
+
this.#reader = new AccountReader(this.transport, this.#commitment)
|
|
170
|
+
// 登记节点配置:写操作的 sendRpc 按同样的网络和节点选项解析
|
|
171
|
+
clientSourceConfigs.set(this, { cluster: this.#cluster, options: config, genesis: () => this.#cluster ? Promise.resolve(GENESIS_HASHES[this.#cluster]) : this.#genesis() })
|
|
499
172
|
// 网络已知时按网络共享缓存;未知(自定义节点且没指定 cluster)时缓存只在本客户端内使用,避免不同网络串数据
|
|
500
|
-
|
|
173
|
+
const scope = this.#cluster ?? `client-${++clientIds}`
|
|
174
|
+
this.#rc = { reader: this.#reader, scope, nativeMints: this.#nativeMints, commitment: this.#commitment }
|
|
501
175
|
}
|
|
502
176
|
|
|
503
177
|
/** 节点所在网络(按创世区块哈希识别);不是 mainnet / devnet / testnet 时为 null */
|
|
@@ -517,42 +191,8 @@ export class SolanaClient {
|
|
|
517
191
|
|
|
518
192
|
/** 直接发 JSON-RPC 调用(同样参与自动合并与故障切换) */
|
|
519
193
|
request<T = unknown>(method: string, params?: readonly unknown[], options: RequestOptions = {}): Promise<T> {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
/**
|
|
524
|
-
* 发一个读账户类的请求:params 最后一项是配置对象,传了 minContextSlot 时带上;
|
|
525
|
-
* 节点高度不够(所有节点都落后)时每 500ms 重试,最多约 10 秒;记录返回的 context.slot(取最小值)
|
|
526
|
-
*/
|
|
527
|
-
async #call<T>(ctx: QueryContext, method: string, params: unknown[]): Promise<T> {
|
|
528
|
-
const last = params[params.length - 1]
|
|
529
|
-
const sent =
|
|
530
|
-
ctx.minContextSlot === undefined ? params : [...params.slice(0, -1), { ...(last as object), minContextSlot: ctx.minContextSlot }]
|
|
531
|
-
const started = Date.now()
|
|
532
|
-
for (;;) {
|
|
533
|
-
try {
|
|
534
|
-
const res = await this.transport.request<T>(method, sent)
|
|
535
|
-
const slot = (res as { context?: { slot?: unknown } } | null)?.context?.slot
|
|
536
|
-
if (typeof slot === 'number') {
|
|
537
|
-
ctx.slot = ctx.slot === undefined ? slot : Math.min(ctx.slot, slot)
|
|
538
|
-
}
|
|
539
|
-
return res
|
|
540
|
-
} catch (err) {
|
|
541
|
-
if (ctx.minContextSlot === undefined || !isBehind(err) || Date.now() - started + BEHIND_RETRY_INTERVAL > BEHIND_RETRY_TIMEOUT) {
|
|
542
|
-
throw err
|
|
543
|
-
}
|
|
544
|
-
await sleep(BEHIND_RETRY_INTERVAL, ctx.signal)
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/** 结果项带上 slot(传了 withSlot / minContextSlot,且这次查询发了请求时) */
|
|
550
|
-
#attachSlot<T extends object>(items: T[], ctx: QueryContext, options: SlotOptions): T[] {
|
|
551
|
-
if ((!options.withSlot && options.minContextSlot === undefined) || ctx.slot === undefined) {
|
|
552
|
-
return items
|
|
553
|
-
}
|
|
554
|
-
const slot = ctx.slot
|
|
555
|
-
return items.map((item) => ({ ...item, slot }))
|
|
194
|
+
const { signal } = options
|
|
195
|
+
return abortable(signal, () => this.#reader.send<T>(method, params, signal))
|
|
556
196
|
}
|
|
557
197
|
|
|
558
198
|
/**
|
|
@@ -560,108 +200,17 @@ export class SolanaClient {
|
|
|
560
200
|
* 账户不存在或地址非法时为 null。
|
|
561
201
|
*/
|
|
562
202
|
accounts(addresses: readonly string[], options: AccountsOptions = {}): Promise<(AccountInfo | null)[]> {
|
|
563
|
-
return abortable(options.signal, () => this.#accounts(addresses,
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
async #accounts(addresses: readonly string[], ctx: QueryContext): Promise<(AccountInfo | null)[]> {
|
|
567
|
-
const fetched = await this.#fetchAccounts(addresses, false, ctx)
|
|
568
|
-
return addresses.map((address) => (fetched.get(address) as AccountInfo | null | undefined) ?? null)
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/**
|
|
572
|
-
* 只读 lamports(主币余额):getMultipleAccounts 带 dataSlice { offset: 0, length: 0 },不读回账户数据——
|
|
573
|
-
* 钱包可能是数据很大的程序账户(上百 KB),整份读回会拖慢甚至超时。结果与 addresses 一一对应,账户不存在或地址非法时为 null
|
|
574
|
-
*/
|
|
575
|
-
async #lamports(addresses: readonly string[], ctx: QueryContext): Promise<(bigint | null)[]> {
|
|
576
|
-
const [fetched] = await this.#fetchGroups([{ addresses, lamportsOnly: true }], false, ctx)
|
|
577
|
-
return addresses.map((address) => (fetched?.get(address) as AccountInfo | null | undefined)?.lamports ?? null)
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/** getMultipleAccounts(读账户数据):见 #fetchGroups */
|
|
581
|
-
async #fetchAccounts(addresses: readonly string[], tolerant: boolean, ctx: QueryContext): Promise<Map<string, AccountInfo | null | typeof FAILED>> {
|
|
582
|
-
const [fetched] = await this.#fetchGroups([{ addresses, lamportsOnly: false }], tolerant, ctx)
|
|
583
|
-
return fetched as Map<string, AccountInfo | null | typeof FAILED>
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
/**
|
|
587
|
-
* getMultipleAccounts:每组去重、按 100 个一组拆分,所有组的请求一起排队,最多同时 3 个(避免大批量时触发节点限频)。
|
|
588
|
-
* lamportsOnly 的组带 dataSlice(长度 0,对同一请求里的所有账户生效,所以只要 lamports 的账户要单独成组)。
|
|
589
|
-
* tolerant 为 true 时,某个请求因节点问题(网络、限频等)失败不抛错:该请求的账户标记为 FAILED,并且不再发后面的请求(节点已经在限频了);
|
|
590
|
-
* 否则第一个错误抛出,后面的请求也不再发。结果按组返回
|
|
591
|
-
*/
|
|
592
|
-
async #fetchGroups(
|
|
593
|
-
groups: ReadonlyArray<{ addresses: readonly string[]; lamportsOnly: boolean }>,
|
|
594
|
-
tolerant: boolean,
|
|
595
|
-
ctx: QueryContext,
|
|
596
|
-
): Promise<Array<Map<string, AccountInfo | null | typeof FAILED>>> {
|
|
597
|
-
const chunks: Array<{ group: number; addresses: string[]; lamportsOnly: boolean }> = []
|
|
598
|
-
groups.forEach(({ addresses, lamportsOnly }, group) => {
|
|
599
|
-
const unique = [...new Set(addresses.filter((a) => isAddress(a)))]
|
|
600
|
-
for (let i = 0; i < unique.length; i += MAX_ACCOUNTS_PER_REQUEST) {
|
|
601
|
-
chunks.push({ group, addresses: unique.slice(i, i + MAX_ACCOUNTS_PER_REQUEST), lamportsOnly })
|
|
602
|
-
}
|
|
603
|
-
})
|
|
604
|
-
const results = groups.map(() => new Map<string, AccountInfo | null | typeof FAILED>())
|
|
605
|
-
let aborted = false
|
|
606
|
-
for (let i = 0; i < chunks.length; i += MAX_PARALLEL_ACCOUNT_REQUESTS) {
|
|
607
|
-
const wave = chunks.slice(i, i + MAX_PARALLEL_ACCOUNT_REQUESTS)
|
|
608
|
-
// 已取消的查询不再发后面的请求
|
|
609
|
-
if (ctx.signal?.aborted) {
|
|
610
|
-
throw ctx.signal.reason
|
|
611
|
-
}
|
|
612
|
-
if (aborted) {
|
|
613
|
-
wave.forEach((chunk) => chunk.addresses.forEach((address) => results[chunk.group]?.set(address, FAILED)))
|
|
614
|
-
continue
|
|
615
|
-
}
|
|
616
|
-
// 同一批里的请求同时发出(合并成一个 JSON-RPC 批量请求);一个失败时等这一批都结束再抛,后面的批次不再发
|
|
617
|
-
const settled = await Promise.allSettled(
|
|
618
|
-
wave.map(async (chunk) => {
|
|
619
|
-
const result = results[chunk.group] as Map<string, AccountInfo | null | typeof FAILED>
|
|
620
|
-
try {
|
|
621
|
-
const res = await this.#call<{ value: Array<RawAccount | null> }>(ctx, 'getMultipleAccounts', [
|
|
622
|
-
chunk.addresses,
|
|
623
|
-
{ encoding: 'base64', commitment: this.#commitment, ...(chunk.lamportsOnly ? { dataSlice: { offset: 0, length: 0 } } : {}) },
|
|
624
|
-
])
|
|
625
|
-
chunk.addresses.forEach((address, j) => result.set(address, toAccountInfo(address, res?.value?.[j] ?? null)))
|
|
626
|
-
} catch (err) {
|
|
627
|
-
if (!tolerant || !isRecoverable(err)) {
|
|
628
|
-
throw err
|
|
629
|
-
}
|
|
630
|
-
aborted = true
|
|
631
|
-
chunk.addresses.forEach((address) => result.set(address, FAILED))
|
|
632
|
-
}
|
|
633
|
-
}),
|
|
634
|
-
)
|
|
635
|
-
const failed = settled.find((r): r is PromiseRejectedResult => r.status === 'rejected')
|
|
636
|
-
if (failed) {
|
|
637
|
-
throw failed.reason
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
return results
|
|
203
|
+
return abortable(options.signal, () => this.#reader.accounts(addresses, queryContext(options)))
|
|
641
204
|
}
|
|
642
205
|
|
|
643
206
|
/** 批量查多个地址的 SOL 余额(getMultipleAccounts 只读 lamports、不读回账户数据;账户不存在视为 0) */
|
|
644
207
|
solBalances(addresses: readonly string[], options: SolBalancesOptions = {}): Promise<SolBalance[]> {
|
|
645
|
-
return abortable(options.signal,
|
|
646
|
-
const ctx = this.#context(options)
|
|
647
|
-
const balances = await this.#lamports(addresses, ctx)
|
|
648
|
-
const list = addresses.map((address, i): SolBalance => {
|
|
649
|
-
if (!isAddress(address)) {
|
|
650
|
-
return { address, balance: '0', formatted: '0', success: false, error: 'invalid-address' }
|
|
651
|
-
}
|
|
652
|
-
const lamports = balances[i] ?? 0n
|
|
653
|
-
return { address, balance: lamports.toString(), formatted: formatUnits(lamports, SOL_DECIMALS), success: true }
|
|
654
|
-
})
|
|
655
|
-
return this.#attachSlot(list, ctx, options)
|
|
656
|
-
})
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
#context(options: RequestOptions & SlotOptions): QueryContext {
|
|
660
|
-
return { signal: options.signal, minContextSlot: options.minContextSlot }
|
|
208
|
+
return abortable(options.signal, () => solBalancesOf(this.#rc, addresses, options))
|
|
661
209
|
}
|
|
662
210
|
|
|
663
211
|
/**
|
|
664
|
-
* 批量查余额(主币 SOL + SPL Token + Token-2022),返回原始余额、decimals
|
|
212
|
+
* 批量查余额(主币 SOL + SPL Token + Token-2022),返回原始余额、decimals 和换算后的数值。两种写法:
|
|
213
|
+
* `balances(owner, mints?, options?)`,或 `balances(owner, { mints, ...options })`。
|
|
665
214
|
*
|
|
666
215
|
* - 传 mints:本地推导 ATA,与 mint 账户一起用 getMultipleAccounts 读完,免费节点也能用;主币只读 lamports(dataSlice),另成一组同时发出
|
|
667
216
|
* - 传 mints 且 accounts: 'all':每个代币按 mint 过滤查 getTokenAccountsByOwner,统计全部代币账户(含非 ATA)
|
|
@@ -669,242 +218,30 @@ export class SolanaClient {
|
|
|
669
218
|
* - 失败项(success: false)带 error:invalid-address(mint 地址非法)/ not-found(mint 不存在)/ not-token(不是代币 mint)
|
|
670
219
|
* - tokenPrograms:只返回这些类型的代币,如 ['spl'] 不含 Token-2022(传了 mints 时,结果里去掉不符合的代币)
|
|
671
220
|
* - minContextSlot / withSlot:要求节点高度、返回读取时的 slot,见 SlotOptions
|
|
221
|
+
* - owner 非法时抛 InvalidAddressError
|
|
672
222
|
*/
|
|
673
|
-
balances(owner: string, mints?: readonly string[], options
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
})
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
#balances(ctx: QueryContext, owner: string, mints: readonly string[] | undefined, options: BalancesOptions, programs: readonly string[]): Promise<TokenBalance[]> {
|
|
685
|
-
const withSymbol = options.symbol ?? false
|
|
686
|
-
if (mints === undefined || options.scan) {
|
|
687
|
-
return this.#scanBalances(ctx, owner, mints, withSymbol, programs)
|
|
688
|
-
}
|
|
689
|
-
if (options.accounts === 'all') {
|
|
690
|
-
return this.#mintAccountBalances(ctx, owner, mints, withSymbol, programs)
|
|
691
|
-
}
|
|
692
|
-
return this.#ataBalances(ctx, [{ owner, mints }], withSymbol, programs).then((lists) => lists[0] as TokenBalance[])
|
|
223
|
+
balances(owner: string, mints?: readonly string[], options?: BalancesOptions): Promise<TokenBalance[]>
|
|
224
|
+
balances(owner: string, options: BalancesQueryOptions): Promise<TokenBalance[]>
|
|
225
|
+
balances(owner: string, mintsOrOptions?: readonly string[] | BalancesQueryOptions, maybeOptions: BalancesOptions = {}): Promise<TokenBalance[]> {
|
|
226
|
+
// null / undefined 按旧写法的“不传 mints”(扫描模式)处理
|
|
227
|
+
const objectForm = mintsOrOptions != null && !Array.isArray(mintsOrOptions)
|
|
228
|
+
const options: BalancesOptions = objectForm ? (mintsOrOptions as BalancesQueryOptions) : (maybeOptions ?? {})
|
|
229
|
+
const mints = objectForm ? (mintsOrOptions as BalancesQueryOptions).mints : ((mintsOrOptions ?? undefined) as readonly string[] | undefined)
|
|
230
|
+
return abortable(options.signal, () => balancesFor(this.#rc, owner, mints, options))
|
|
693
231
|
}
|
|
694
232
|
|
|
695
233
|
/**
|
|
696
234
|
* 多个钱包一次查(如多钱包的持仓列表),结果与 queries 一一对应。
|
|
697
235
|
* ATA 模式(默认)下所有钱包的账户合并成一次(按 100 个一组的)getMultipleAccounts;
|
|
698
236
|
* scan / accounts: 'all' 时每个钱包各自查询(同一 tick 发出,合并成批量请求)。其他选项与 balances() 相同;
|
|
699
|
-
* withSlot 时所有钱包的结果带同一个 slot(整次查询的最小值)。任一 owner
|
|
237
|
+
* withSlot 时所有钱包的结果带同一个 slot(整次查询的最小值)。任一 owner 非法时抛 InvalidAddressError
|
|
700
238
|
*
|
|
701
239
|
* ```ts
|
|
702
240
|
* await sol.multiBalances([{ owner: a, mints: [NATIVE_MINT, USDC] }, { owner: b, mints: [USDC] }])
|
|
703
241
|
* ```
|
|
704
242
|
*/
|
|
705
243
|
multiBalances(queries: readonly BalanceQuery[], options: BalancesOptions = {}): Promise<TokenBalance[][]> {
|
|
706
|
-
return abortable(options.signal,
|
|
707
|
-
for (const { owner } of queries) {
|
|
708
|
-
if (!isAddress(owner)) {
|
|
709
|
-
throw new Error(`Invalid owner address: ${owner}`)
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
const programs = resolvePrograms(options.tokenPrograms)
|
|
713
|
-
const ctx = this.#context(options)
|
|
714
|
-
const lists =
|
|
715
|
-
options.scan || options.accounts === 'all'
|
|
716
|
-
? await Promise.all(queries.map(({ owner, mints }) => this.#balances(ctx, owner, mints, options, programs)))
|
|
717
|
-
: await this.#ataBalances(ctx, queries, options.symbol ?? false, programs)
|
|
718
|
-
return lists.map((list) => this.#attachSlot(list, ctx, options))
|
|
719
|
-
})
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
/** ATA 模式:所有钱包的 主币账户 / mint / 元数据 / ATA 一起读,结果按钱包分组 */
|
|
723
|
-
async #ataBalances(ctx: QueryContext, queries: readonly BalanceQuery[], withSymbol: boolean, allowed: readonly string[]): Promise<TokenBalance[][]> {
|
|
724
|
-
const scope = this.#scope()
|
|
725
|
-
// 要数据的账户(mint、元数据、ATA)一组;钱包账户只要 lamports:已知是普通钱包(系统账户 / 不存在,数据为空)的
|
|
726
|
-
// 放进同一组(一个请求、同一个快照),其余(可能是数据很大的程序账户)单独一组带 dataSlice——同时发出,合并在同一个 JSON-RPC 批量请求里
|
|
727
|
-
const addresses: string[] = []
|
|
728
|
-
const owners: string[] = []
|
|
729
|
-
const plans = queries.map(({ owner, mints }) => mints.map((mint) => this.#ataPlan(scope, owner, mint, withSymbol, allowed, addresses, owners)))
|
|
730
|
-
const [dataMap, ownerMap] = await this.#fetchGroups(
|
|
731
|
-
[
|
|
732
|
-
{ addresses, lamportsOnly: false },
|
|
733
|
-
{ addresses: owners, lamportsOnly: true },
|
|
734
|
-
],
|
|
735
|
-
false,
|
|
736
|
-
ctx,
|
|
737
|
-
)
|
|
738
|
-
// 记下钱包账户是否数据为空:单独读的按结果学习;已放进代币那组的,读到不再是系统账户(如被分配给程序)时移除,下次单独读
|
|
739
|
-
for (const owner of new Set(queries.map((q) => q.owner))) {
|
|
740
|
-
// null(账户不存在)也是结果,不能用 ?? 落到另一组
|
|
741
|
-
const account = ownerMap?.has(owner) ? ownerMap.get(owner) : dataMap?.get(owner)
|
|
742
|
-
if (account !== undefined && account !== FAILED) {
|
|
743
|
-
learnOwner(scope, owner, account)
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
const accounts = addresses.map((address) => (dataMap?.get(address) as AccountInfo | null | undefined) ?? null)
|
|
747
|
-
const lamportsOf = (owner: string) => ((ownerMap?.has(owner) ? ownerMap.get(owner) : dataMap?.get(owner)) as AccountInfo | null | undefined)?.lamports ?? 0n
|
|
748
|
-
return plans.map((plan) => plan.flatMap((item) => this.#ataResult(scope, item, accounts, lamportsOf, withSymbol, allowed)))
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
#ataPlan(scope: string, owner: string, mint: string, withSymbol: boolean, allowed: readonly string[], addresses: string[], owners: string[]): AtaItem {
|
|
752
|
-
if (this.#nativeMints.has(mint)) {
|
|
753
|
-
;(isSmallOwner(scope, owner) ? addresses : owners).push(owner)
|
|
754
|
-
return { kind: 'native', mint, owner }
|
|
755
|
-
}
|
|
756
|
-
if (!isAddress(mint)) {
|
|
757
|
-
return { kind: 'invalid', mint }
|
|
758
|
-
}
|
|
759
|
-
const meta = getMeta(scope, mint)
|
|
760
|
-
// 导入的缓存里的所属程序没确认过:按未知处理(记错时不会让余额恒为 0)
|
|
761
|
-
const program = meta.programUnverified ? undefined : meta.tokenProgram
|
|
762
|
-
if (isExcluded(program, allowed)) {
|
|
763
|
-
return { kind: 'excluded', mint }
|
|
764
|
-
}
|
|
765
|
-
const needMint = meta.decimals === undefined || program === undefined || (withSymbol && meta.symbol === undefined)
|
|
766
|
-
// 所属程序未知时只推导允许的程序的 ATA,读到 mint 账户后再确认
|
|
767
|
-
const programs = program ? [program] : allowed
|
|
768
|
-
return {
|
|
769
|
-
kind: 'token',
|
|
770
|
-
mint,
|
|
771
|
-
mintIndex: needMint ? addresses.push(mint) - 1 : -1,
|
|
772
|
-
metadataIndex: withSymbol && meta.symbol === undefined ? addresses.push(getMetadataAddress(mint)) - 1 : -1,
|
|
773
|
-
atas: programs.map((program) => ({ program, index: addresses.push(getAssociatedTokenAddress(owner, mint, program)) - 1 })),
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
#ataResult(
|
|
778
|
-
scope: string,
|
|
779
|
-
item: AtaItem,
|
|
780
|
-
accounts: readonly (AccountInfo | null)[],
|
|
781
|
-
lamportsOf: (owner: string) => bigint,
|
|
782
|
-
withSymbol: boolean,
|
|
783
|
-
allowed: readonly string[],
|
|
784
|
-
): TokenBalance[] {
|
|
785
|
-
if (item.kind === 'native') {
|
|
786
|
-
const lamports = lamportsOf(item.owner)
|
|
787
|
-
return [
|
|
788
|
-
withSymbolField(
|
|
789
|
-
{ token: item.mint, native: true, balance: lamports.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(lamports, SOL_DECIMALS), tokenProgram: null, success: true },
|
|
790
|
-
withSymbol,
|
|
791
|
-
'SOL',
|
|
792
|
-
),
|
|
793
|
-
]
|
|
794
|
-
}
|
|
795
|
-
if (item.kind === 'excluded') {
|
|
796
|
-
return []
|
|
797
|
-
}
|
|
798
|
-
if (item.kind === 'invalid') {
|
|
799
|
-
return [failedBalance(item.mint, 'invalid-address', null, withSymbol, null)]
|
|
800
|
-
}
|
|
801
|
-
const mintAccount = item.mintIndex === -1 ? null : (accounts[item.mintIndex] ?? null)
|
|
802
|
-
const meta = this.#learn(
|
|
803
|
-
scope,
|
|
804
|
-
item.mint,
|
|
805
|
-
mintAccount,
|
|
806
|
-
item.metadataIndex === -1 ? null : (accounts[item.metadataIndex] ?? null),
|
|
807
|
-
item.metadataIndex !== -1 && item.mintIndex !== -1,
|
|
808
|
-
)
|
|
809
|
-
// 代币账户由哪个程序持有就是哪个;mint 账户的 owner 也能确定程序。
|
|
810
|
-
// 所属程序未知时推导了两个程序的 ATA,但一个 mint 只属于一个程序,另一个程序下不可能有这个 mint 的代币账户:找到就结束
|
|
811
|
-
let amount = 0n
|
|
812
|
-
for (const ata of item.atas) {
|
|
813
|
-
const account = accounts[ata.index]
|
|
814
|
-
if (!account || account.owner !== ata.program) {
|
|
815
|
-
continue
|
|
816
|
-
}
|
|
817
|
-
try {
|
|
818
|
-
amount = parseTokenAccount(account.data).amount
|
|
819
|
-
} catch {
|
|
820
|
-
continue // 不是代币账户
|
|
821
|
-
}
|
|
822
|
-
if (meta.tokenProgram === undefined) {
|
|
823
|
-
setMeta(scope, item.mint, { tokenProgram: ata.program })
|
|
824
|
-
meta.tokenProgram = ata.program
|
|
825
|
-
}
|
|
826
|
-
break
|
|
827
|
-
}
|
|
828
|
-
// 读到 mint 账户后才知道是不允许的程序
|
|
829
|
-
if (isExcluded(meta.tokenProgram, allowed)) {
|
|
830
|
-
return []
|
|
831
|
-
}
|
|
832
|
-
if (meta.decimals === undefined) {
|
|
833
|
-
// decimals 未知时一定读了 mint 账户:不存在 / 存在但不是 mint
|
|
834
|
-
return [failedBalance(item.mint, mintAccount ? 'not-token' : 'not-found', meta.tokenProgram ?? null, withSymbol, meta.symbol ?? null)]
|
|
835
|
-
}
|
|
836
|
-
return [
|
|
837
|
-
withSymbolField(
|
|
838
|
-
{
|
|
839
|
-
token: item.mint,
|
|
840
|
-
native: false,
|
|
841
|
-
balance: amount.toString(),
|
|
842
|
-
decimals: meta.decimals,
|
|
843
|
-
formatted: formatUnits(amount, meta.decimals),
|
|
844
|
-
tokenProgram: meta.tokenProgram ?? null,
|
|
845
|
-
success: true,
|
|
846
|
-
},
|
|
847
|
-
withSymbol,
|
|
848
|
-
meta.symbol ?? null,
|
|
849
|
-
),
|
|
850
|
-
]
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
/** 扫描持有人的 SOL 余额和全部代币账户(默认 Token + Token-2022,只请求 programs 里的程序),同一代币的多个账户合计 */
|
|
854
|
-
async #scanHoldings(
|
|
855
|
-
ctx: QueryContext,
|
|
856
|
-
owner: string,
|
|
857
|
-
programs: readonly string[] = TOKEN_PROGRAMS,
|
|
858
|
-
withSymbol = false,
|
|
859
|
-
): Promise<{ sol: bigint; held: Map<string, Holding> }> {
|
|
860
|
-
const scope = this.#scope()
|
|
861
|
-
const [lamports, ...results] = await Promise.all([
|
|
862
|
-
this.#call<{ value: number | string }>(ctx, 'getBalance', [owner, { commitment: this.#commitment }]),
|
|
863
|
-
...programs.map((programId) =>
|
|
864
|
-
this.#call<{ value: ParsedTokenAccount[] }>(ctx, 'getTokenAccountsByOwner', [owner, { programId }, { encoding: 'jsonParsed', commitment: this.#commitment }]),
|
|
865
|
-
),
|
|
866
|
-
])
|
|
867
|
-
const entries = programs.flatMap((program, i) => (results[i]?.value ?? []).map((entry) => ({ program, entry })))
|
|
868
|
-
const held = await this.#collectHoldings(ctx, scope, entries, withSymbol)
|
|
869
|
-
return { sol: BigInt(lamports.value), held }
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
/**
|
|
873
|
-
* 把 getTokenAccountsByOwner(jsonParsed)的结果按 mint 汇总。节点解析不了、退回 base64 的账户自己解析,
|
|
874
|
-
* decimals 读 mint 账户补上(不丢弃,也不会把余额算成 0);mint 读不到 decimals 时才跳过
|
|
875
|
-
*/
|
|
876
|
-
async #collectHoldings(
|
|
877
|
-
ctx: QueryContext,
|
|
878
|
-
scope: string,
|
|
879
|
-
entries: ReadonlyArray<{ program: string | undefined; entry: ParsedTokenAccount }>,
|
|
880
|
-
withSymbol = false,
|
|
881
|
-
): Promise<Map<string, Holding>> {
|
|
882
|
-
const read = entries.flatMap(({ program, entry }) => {
|
|
883
|
-
const token = readTokenEntry(entry)
|
|
884
|
-
return token ? [{ ...token, program: entry.account?.owner || program }] : []
|
|
885
|
-
})
|
|
886
|
-
read.forEach(({ mint, decimals, program }) => decimals !== undefined && program !== undefined && setMeta(scope, mint, { decimals, tokenProgram: program }))
|
|
887
|
-
// 缺 decimals 或所属程序(节点没给账户 owner)的读 mint 账户补上;需要 symbol 时一起读,后面不必再读一遍
|
|
888
|
-
const unknown = [
|
|
889
|
-
...new Set(read.filter((t) => getMeta(scope, t.mint).decimals === undefined || (t.program === undefined && getMeta(scope, t.mint).tokenProgram === undefined)).map((t) => t.mint)),
|
|
890
|
-
]
|
|
891
|
-
if (unknown.length) {
|
|
892
|
-
// 补读 mint 不受 minContextSlot 约束、不计入扫描的 slot(decimals / 所属程序不会因为刚成交而变化);
|
|
893
|
-
// 节点问题时不让整次扫描失败,读不到的代币跳过
|
|
894
|
-
await this.#loadMeta({ signal: ctx.signal }, scope, unknown, withSymbol, true)
|
|
895
|
-
}
|
|
896
|
-
const held = new Map<string, Holding>()
|
|
897
|
-
for (const { mint, amount, program: entryProgram } of read) {
|
|
898
|
-
const meta = getMeta(scope, mint)
|
|
899
|
-
const decimals = meta.decimals
|
|
900
|
-
const program = entryProgram ?? (meta.programUnverified ? undefined : meta.tokenProgram)
|
|
901
|
-
if (decimals === undefined || program === undefined) {
|
|
902
|
-
continue
|
|
903
|
-
}
|
|
904
|
-
const prev = held.get(mint)
|
|
905
|
-
held.set(mint, { amount: (prev?.amount ?? 0n) + amount, decimals, program, accounts: (prev?.accounts ?? 0) + 1 })
|
|
906
|
-
}
|
|
907
|
-
return held
|
|
244
|
+
return abortable(options.signal, () => multiBalancesOf(this.#rc, queries, options))
|
|
908
245
|
}
|
|
909
246
|
|
|
910
247
|
/**
|
|
@@ -918,197 +255,7 @@ export class SolanaClient {
|
|
|
918
255
|
* - withSlot 的 slot 只算余额扫描(元数据读取不计入)
|
|
919
256
|
*/
|
|
920
257
|
ownerTokens(owner: string, options: OwnerTokensOptions = {}): Promise<OwnedToken[]> {
|
|
921
|
-
return abortable(options.signal,
|
|
922
|
-
if (!isAddress(owner)) {
|
|
923
|
-
throw new Error(`Invalid owner address: ${owner}`)
|
|
924
|
-
}
|
|
925
|
-
const { metadata = true, includeNative = true, includeZero = false, includeNfts = false } = options
|
|
926
|
-
const scope = this.#scope()
|
|
927
|
-
const ctx = this.#context(options)
|
|
928
|
-
const { sol, held } = await this.#scanHoldings(ctx, owner, resolvePrograms(options.tokenPrograms), metadata)
|
|
929
|
-
const scanSlot = ctx.slot
|
|
930
|
-
// Wrapped SOL 是独立的代币账户(需要 unwrap 才是 SOL),资产列表里单独列出(native: false)
|
|
931
|
-
const tokens = [...held].filter(([, h]) => (includeZero || h.amount > 0n) && (includeNfts || !isNftHolding(h)))
|
|
932
|
-
// 元数据不受 minContextSlot 约束(不会因为刚成交而变化)
|
|
933
|
-
const failures = metadata ? await this.#loadMeta({ signal: options.signal }, scope, tokens.map(([mint]) => mint), true, true) : new Map<string, MetaFailure>()
|
|
934
|
-
|
|
935
|
-
const list: OwnedToken[] = tokens.map(([mint, h]) => {
|
|
936
|
-
const item: OwnedToken = {
|
|
937
|
-
token: mint,
|
|
938
|
-
native: false,
|
|
939
|
-
balance: h.amount.toString(),
|
|
940
|
-
decimals: h.decimals,
|
|
941
|
-
formatted: formatUnits(h.amount, h.decimals),
|
|
942
|
-
tokenProgram: h.program,
|
|
943
|
-
accounts: h.accounts,
|
|
944
|
-
success: true,
|
|
945
|
-
}
|
|
946
|
-
if (!metadata) {
|
|
947
|
-
return item
|
|
948
|
-
}
|
|
949
|
-
const meta = getMeta(scope, mint)
|
|
950
|
-
const status = failures.get(mint) === 'failed' ? 'failed' : meta.name == null && meta.symbol == null ? 'missing' : 'ok'
|
|
951
|
-
return { ...item, name: meta.name ?? null, symbol: meta.symbol ?? null, metadataStatus: status }
|
|
952
|
-
})
|
|
953
|
-
if (includeNative) {
|
|
954
|
-
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 }
|
|
955
|
-
list.unshift(metadata ? { ...sol_, name: 'Solana', symbol: 'SOL', metadataStatus: 'ok' } : sol_)
|
|
956
|
-
}
|
|
957
|
-
return this.#attachSlot(list, { slot: scanSlot }, options)
|
|
958
|
-
})
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
async #scanBalances(ctx: QueryContext, owner: string, mints: readonly string[] | undefined, withSymbol: boolean, allowed: readonly string[]): Promise<TokenBalance[]> {
|
|
962
|
-
const scope = this.#scope()
|
|
963
|
-
// held 里只有允许的程序的代币
|
|
964
|
-
const { sol, held } = await this.#scanHoldings(ctx, owner, allowed, withSymbol)
|
|
965
|
-
|
|
966
|
-
// Wrapped SOL(So111…112)默认按原生 SOL 处理:余额列表里只有一项 SOL,不再列出 wSOL 代币账户
|
|
967
|
-
// 第一项是主币:用 nativeMints 里的地址(nativeMints 不含 So111…112 时 wSOL 作为普通代币列出,不能再拿它当 SOL)
|
|
968
|
-
const nativeLead = this.#nativeMints.has(NATIVE_MINT) ? NATIVE_MINT : this.#nativeMints.values().next().value
|
|
969
|
-
const targets = mints ?? [
|
|
970
|
-
...(nativeLead === undefined ? [] : [nativeLead]),
|
|
971
|
-
...[...held].filter(([mint, h]) => h.amount > 0n && !this.#nativeMints.has(mint)).map(([mint]) => mint),
|
|
972
|
-
]
|
|
973
|
-
// 没持有的代币(拿不到 decimals)和缺 symbol 的代币:只读 mint / 元数据账户补上,不需要 ATA
|
|
974
|
-
const failures = await this.#loadMeta(
|
|
975
|
-
ctx,
|
|
976
|
-
scope,
|
|
977
|
-
targets.filter((mint) => !this.#nativeMints.has(mint) && isAddress(mint)),
|
|
978
|
-
withSymbol,
|
|
979
|
-
)
|
|
980
|
-
|
|
981
|
-
return targets.flatMap((mint): TokenBalance[] => {
|
|
982
|
-
if (this.#nativeMints.has(mint)) {
|
|
983
|
-
return [
|
|
984
|
-
withSymbolField(
|
|
985
|
-
{ token: mint, native: true, balance: sol.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(sol, SOL_DECIMALS), tokenProgram: null, success: true },
|
|
986
|
-
withSymbol,
|
|
987
|
-
'SOL',
|
|
988
|
-
),
|
|
989
|
-
]
|
|
990
|
-
}
|
|
991
|
-
return this.#heldBalance(scope, mint, held.get(mint), failures, withSymbol, allowed)
|
|
992
|
-
})
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
/**
|
|
996
|
-
* 指定 mints、统计全部代币账户:每个代币一次按 mint 过滤的 getTokenAccountsByOwner(同一 tick 发出,自动合并)。
|
|
997
|
-
* mint 不存在 / 不是代币时节点返回参数错误,按“没持有”处理,再由 #loadMeta 确认原因
|
|
998
|
-
*/
|
|
999
|
-
async #mintAccountBalances(ctx: QueryContext, owner: string, mints: readonly string[], withSymbol: boolean, allowed: readonly string[]): Promise<TokenBalance[]> {
|
|
1000
|
-
const scope = this.#scope()
|
|
1001
|
-
// 导入的、未经链上确认的所属程序不用来排除代币(可能记错):照常查询,从链上学到后再按程序过滤
|
|
1002
|
-
const queried = [
|
|
1003
|
-
...new Set(
|
|
1004
|
-
mints.filter((mint) => {
|
|
1005
|
-
const meta = getMeta(scope, mint)
|
|
1006
|
-
return !this.#nativeMints.has(mint) && isAddress(mint) && (meta.programUnverified || !isExcluded(meta.tokenProgram, allowed))
|
|
1007
|
-
}),
|
|
1008
|
-
),
|
|
1009
|
-
]
|
|
1010
|
-
const [lamports, ...results] = await Promise.all([
|
|
1011
|
-
mints.some((mint) => this.#nativeMints.has(mint))
|
|
1012
|
-
? this.#call<{ value: number | string }>(ctx, 'getBalance', [owner, { commitment: this.#commitment }])
|
|
1013
|
-
: Promise.resolve(null),
|
|
1014
|
-
...queried.map((mint) =>
|
|
1015
|
-
this.#call<{ value: ParsedTokenAccount[] }>(ctx, 'getTokenAccountsByOwner', [owner, { mint }, { encoding: 'jsonParsed', commitment: this.#commitment }]).catch(
|
|
1016
|
-
(err: unknown) => {
|
|
1017
|
-
// "Invalid param: could not find mint" 等参数错误:mint 不存在或不是代币;节点问题照常抛出
|
|
1018
|
-
if (err instanceof RpcError && !err.nodeFault && err.code === -32602) return null
|
|
1019
|
-
throw err
|
|
1020
|
-
},
|
|
1021
|
-
),
|
|
1022
|
-
),
|
|
1023
|
-
])
|
|
1024
|
-
const held = await this.#collectHoldings(
|
|
1025
|
-
ctx,
|
|
1026
|
-
scope,
|
|
1027
|
-
queried.flatMap((_, i) => (results[i]?.value ?? []).map((entry) => ({ program: entry.account?.owner, entry }))),
|
|
1028
|
-
withSymbol,
|
|
1029
|
-
)
|
|
1030
|
-
// 没持有的代币补 decimals(同时确认 mint 是否存在),需要时补 symbol
|
|
1031
|
-
const failures = await this.#loadMeta(ctx, scope, queried, withSymbol)
|
|
1032
|
-
const sol = BigInt(lamports?.value ?? 0)
|
|
1033
|
-
|
|
1034
|
-
return mints.flatMap((mint): TokenBalance[] => {
|
|
1035
|
-
if (this.#nativeMints.has(mint)) {
|
|
1036
|
-
return [
|
|
1037
|
-
withSymbolField(
|
|
1038
|
-
{ token: mint, native: true, balance: sol.toString(), decimals: SOL_DECIMALS, formatted: formatUnits(sol, SOL_DECIMALS), tokenProgram: null, success: true },
|
|
1039
|
-
withSymbol,
|
|
1040
|
-
'SOL',
|
|
1041
|
-
),
|
|
1042
|
-
]
|
|
1043
|
-
}
|
|
1044
|
-
return this.#heldBalance(scope, mint, held.get(mint), failures, withSymbol, allowed)
|
|
1045
|
-
})
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
/** 扫描类查询(scan / accounts: 'all')里单个代币的结果;不允许的程序返回空数组 */
|
|
1049
|
-
#heldBalance(scope: string, mint: string, h: Holding | undefined, failures: Map<string, MetaFailure>, withSymbol: boolean, allowed: readonly string[]): TokenBalance[] {
|
|
1050
|
-
if (!isAddress(mint)) {
|
|
1051
|
-
return [failedBalance(mint, 'invalid-address', null, withSymbol, null)]
|
|
1052
|
-
}
|
|
1053
|
-
const meta = getMeta(scope, mint)
|
|
1054
|
-
if (!h) {
|
|
1055
|
-
// 没持有(或属于不允许的程序):余额为 0;decimals 来自 mint 账户
|
|
1056
|
-
if (isExcluded(meta.tokenProgram, allowed)) {
|
|
1057
|
-
return []
|
|
1058
|
-
}
|
|
1059
|
-
if (meta.decimals === undefined) {
|
|
1060
|
-
const failure = failures.get(mint)
|
|
1061
|
-
return [failedBalance(mint, failure === 'not-token' ? 'not-token' : 'not-found', meta.tokenProgram ?? null, withSymbol, meta.symbol ?? null)]
|
|
1062
|
-
}
|
|
1063
|
-
return [withSymbolField({ token: mint, native: false, balance: '0', decimals: meta.decimals, formatted: '0', tokenProgram: meta.tokenProgram ?? null, success: true }, withSymbol, meta.symbol ?? null)]
|
|
1064
|
-
}
|
|
1065
|
-
if (isExcluded(h.program, allowed)) {
|
|
1066
|
-
return []
|
|
1067
|
-
}
|
|
1068
|
-
return [
|
|
1069
|
-
withSymbolField(
|
|
1070
|
-
{ token: mint, native: false, balance: h.amount.toString(), decimals: h.decimals, formatted: formatUnits(h.amount, h.decimals), tokenProgram: h.program, success: true },
|
|
1071
|
-
withSymbol,
|
|
1072
|
-
meta.symbol ?? null,
|
|
1073
|
-
),
|
|
1074
|
-
]
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
/**
|
|
1078
|
-
* 补齐 decimals / 所属程序(以及需要时的 symbol / name):只读缺的 mint 账户和元数据账户。
|
|
1079
|
-
* 返回没能补上 decimals 的 mint 及原因:failed(tolerant 为 true 时的节点问题,信息保持未知、不写缓存)、not-found、not-token
|
|
1080
|
-
*/
|
|
1081
|
-
async #loadMeta(ctx: QueryContext, scope: string, mints: readonly string[], withSymbol: boolean, tolerant = false): Promise<Map<string, MetaFailure>> {
|
|
1082
|
-
const plan = [...new Set(mints)].flatMap((mint) => {
|
|
1083
|
-
const meta = getMeta(scope, mint)
|
|
1084
|
-
const needSymbol = withSymbol && meta.symbol === undefined
|
|
1085
|
-
if (meta.decimals !== undefined && meta.tokenProgram !== undefined && !needSymbol) {
|
|
1086
|
-
return []
|
|
1087
|
-
}
|
|
1088
|
-
return [{ mint, metadata: needSymbol ? getMetadataAddress(mint) : null }]
|
|
1089
|
-
})
|
|
1090
|
-
const failures = new Map<string, MetaFailure>()
|
|
1091
|
-
if (!plan.length) {
|
|
1092
|
-
return failures
|
|
1093
|
-
}
|
|
1094
|
-
const fetched = await this.#fetchAccounts(
|
|
1095
|
-
plan.flatMap(({ mint, metadata }) => (metadata ? [mint, metadata] : [mint])),
|
|
1096
|
-
tolerant,
|
|
1097
|
-
ctx,
|
|
1098
|
-
)
|
|
1099
|
-
for (const { mint, metadata } of plan) {
|
|
1100
|
-
const mintAccount = fetched.get(mint)
|
|
1101
|
-
const metadataAccount = metadata ? fetched.get(metadata) : null
|
|
1102
|
-
// mint 和元数据账户可能落在不同的请求里:任一个没读到都算失败,不能当成“没有元数据”缓存
|
|
1103
|
-
if (mintAccount === FAILED || metadataAccount === FAILED) {
|
|
1104
|
-
failures.set(mint, 'failed')
|
|
1105
|
-
continue
|
|
1106
|
-
}
|
|
1107
|
-
if (this.#learn(scope, mint, mintAccount ?? null, metadataAccount ?? null, metadata !== null).decimals === undefined) {
|
|
1108
|
-
failures.set(mint, mintAccount ? 'not-token' : 'not-found')
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
return failures
|
|
258
|
+
return abortable(options.signal, () => ownerTokensOf(this.#rc, owner, options))
|
|
1112
259
|
}
|
|
1113
260
|
|
|
1114
261
|
/**
|
|
@@ -1117,163 +264,12 @@ export class SolanaClient {
|
|
|
1117
264
|
* transferFee:Token-2022 转账手续费,请求时会顺带发一次 getEpochInfo(同一个批量请求里)选出当前生效的配置
|
|
1118
265
|
*/
|
|
1119
266
|
tokens<const F extends TokenField = DefaultTokenField>(mints: readonly string[], options: TokensOptions<F> = {}): Promise<TokenDetails<F>[]> {
|
|
1120
|
-
return abortable(options.signal, () =>
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
async #tokens<F extends TokenField>(mints: readonly string[], options: TokensOptions<F>): Promise<TokenDetails<F>[]> {
|
|
1124
|
-
const fields = (options.fields ?? DEFAULT_TOKEN_FIELDS) as readonly F[]
|
|
1125
|
-
const wanted = new Set<TokenField>(fields)
|
|
1126
|
-
const scope = this.#scope()
|
|
1127
|
-
const ctx: QueryContext = { signal: options.signal }
|
|
1128
|
-
const needsNames = wanted.has('name') || wanted.has('symbol') || wanted.has('uri')
|
|
1129
|
-
const addresses: string[] = []
|
|
1130
|
-
const plan = mints.map((mint) => {
|
|
1131
|
-
if (this.#nativeMints.has(mint) || !isAddress(mint)) {
|
|
1132
|
-
return { mint, mintIndex: -1, metadataIndex: -1 }
|
|
1133
|
-
}
|
|
1134
|
-
const meta = getMeta(scope, mint)
|
|
1135
|
-
// uri / supply / 权限 / 手续费不缓存,需要时总要读 mint;name / symbol 命中缓存时可以省掉元数据账户
|
|
1136
|
-
const cachedOnly =
|
|
1137
|
-
!wanted.has('uri') &&
|
|
1138
|
-
!wanted.has('supply') &&
|
|
1139
|
-
!wanted.has('mintAuthority') &&
|
|
1140
|
-
!wanted.has('freezeAuthority') &&
|
|
1141
|
-
!wanted.has('transferFee') &&
|
|
1142
|
-
(!wanted.has('decimals') || meta.decimals !== undefined) &&
|
|
1143
|
-
(!wanted.has('tokenProgram') || meta.tokenProgram !== undefined) &&
|
|
1144
|
-
(!wanted.has('name') || meta.name !== undefined) &&
|
|
1145
|
-
(!wanted.has('symbol') || meta.symbol !== undefined)
|
|
1146
|
-
if (cachedOnly) {
|
|
1147
|
-
return { mint, mintIndex: -1, metadataIndex: -1 }
|
|
1148
|
-
}
|
|
1149
|
-
const namesCached = (!wanted.has('name') || meta.name !== undefined) && (!wanted.has('symbol') || meta.symbol !== undefined) && !wanted.has('uri')
|
|
1150
|
-
return {
|
|
1151
|
-
mint,
|
|
1152
|
-
mintIndex: addresses.push(mint) - 1,
|
|
1153
|
-
metadataIndex: needsNames && !namesCached ? addresses.push(getMetadataAddress(mint)) - 1 : -1,
|
|
1154
|
-
}
|
|
1155
|
-
})
|
|
1156
|
-
const [accounts, epoch] = await Promise.all([
|
|
1157
|
-
addresses.length ? this.#accounts(addresses, ctx) : Promise.resolve([] as (AccountInfo | null)[]),
|
|
1158
|
-
// 手续费按当前 epoch 选新 / 旧配置;和账户读取在同一个批量请求里
|
|
1159
|
-
wanted.has('transferFee') && addresses.length
|
|
1160
|
-
? this.#call<{ epoch: number | string }>(ctx, 'getEpochInfo', [{ commitment: this.#commitment }]).then((res) => BigInt(res.epoch))
|
|
1161
|
-
: Promise.resolve(null),
|
|
1162
|
-
])
|
|
1163
|
-
|
|
1164
|
-
return plan.map(({ mint, mintIndex, metadataIndex }) => {
|
|
1165
|
-
const native = this.#nativeMints.has(mint)
|
|
1166
|
-
const values: Record<TokenField, unknown> & { exists: boolean } = native
|
|
1167
|
-
? { exists: true, name: 'Solana', symbol: 'SOL', uri: null, decimals: SOL_DECIMALS, supply: null, tokenProgram: null, mintAuthority: null, freezeAuthority: null, transferFee: null }
|
|
1168
|
-
: this.#tokenValues(
|
|
1169
|
-
scope,
|
|
1170
|
-
mint,
|
|
1171
|
-
mintIndex === -1 ? null : (accounts[mintIndex] ?? null),
|
|
1172
|
-
metadataIndex === -1 ? null : (accounts[metadataIndex] ?? null),
|
|
1173
|
-
mintIndex !== -1,
|
|
1174
|
-
mintIndex !== -1 && metadataIndex !== -1,
|
|
1175
|
-
epoch,
|
|
1176
|
-
)
|
|
1177
|
-
const out: Record<string, unknown> = { address: mint, native }
|
|
1178
|
-
let missing: F | undefined
|
|
1179
|
-
for (const field of fields) {
|
|
1180
|
-
out[field] = values[field] ?? null
|
|
1181
|
-
// 权限为 null 表示没有权限、手续费为 null 表示没有手续费,都是有效值;主币没有 uri / supply / tokenProgram
|
|
1182
|
-
const nullable =
|
|
1183
|
-
field === 'mintAuthority' || field === 'freezeAuthority' || field === 'transferFee' || (native && (field === 'uri' || field === 'supply' || field === 'tokenProgram'))
|
|
1184
|
-
if (out[field] === null && !nullable) {
|
|
1185
|
-
missing ??= field
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
if (wanted.has('supply')) {
|
|
1189
|
-
const supply = values.supply as string | null
|
|
1190
|
-
const decimals = values.decimals as number | null
|
|
1191
|
-
out.supplyFormatted = supply === null || decimals === null ? null : formatUnits(BigInt(supply), decimals)
|
|
1192
|
-
}
|
|
1193
|
-
const error: FailureReason | undefined = native
|
|
1194
|
-
? undefined
|
|
1195
|
-
: !isAddress(mint)
|
|
1196
|
-
? 'invalid-address'
|
|
1197
|
-
: !values.exists
|
|
1198
|
-
? accounts[mintIndex]
|
|
1199
|
-
? 'not-token'
|
|
1200
|
-
: 'not-found'
|
|
1201
|
-
: missing
|
|
1202
|
-
? 'missing-field'
|
|
1203
|
-
: undefined
|
|
1204
|
-
out.success = error === undefined
|
|
1205
|
-
if (error) {
|
|
1206
|
-
out.error = error
|
|
1207
|
-
if (error === 'missing-field') {
|
|
1208
|
-
out.errorField = missing
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
return out as TokenDetails<F>
|
|
1212
|
-
})
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
#tokenValues(
|
|
1216
|
-
scope: string,
|
|
1217
|
-
mint: string,
|
|
1218
|
-
mintAccount: AccountInfo | null,
|
|
1219
|
-
metadataAccount: AccountInfo | null,
|
|
1220
|
-
fetchedMint: boolean,
|
|
1221
|
-
lookedForNames: boolean,
|
|
1222
|
-
epoch: bigint | null,
|
|
1223
|
-
): Record<TokenField, unknown> & { exists: boolean } {
|
|
1224
|
-
const meta = this.#learn(scope, mint, mintAccount, metadataAccount, lookedForNames)
|
|
1225
|
-
const info = mintAccount ? safeParseMint(mintAccount) : null
|
|
1226
|
-
const uri = info?.metadata?.uri ?? (metadataAccount ? (safeParseMetaplex(metadataAccount)?.uri ?? null) : null)
|
|
1227
|
-
const feeConfig = info && mintAccount?.owner === TOKEN_2022_PROGRAM_ID ? parseTransferFeeConfig(mintAccount.data) : null
|
|
1228
|
-
return {
|
|
1229
|
-
// 读了 mint 账户就以它为准(不存在则 false);没读说明请求的字段全部命中缓存
|
|
1230
|
-
exists: fetchedMint ? info !== null : true,
|
|
1231
|
-
name: meta.name ?? null,
|
|
1232
|
-
symbol: meta.symbol ?? null,
|
|
1233
|
-
uri,
|
|
1234
|
-
decimals: meta.decimals ?? null,
|
|
1235
|
-
supply: info ? info.supply.toString() : null,
|
|
1236
|
-
tokenProgram: meta.tokenProgram ?? null,
|
|
1237
|
-
mintAuthority: info?.mintAuthority ?? null,
|
|
1238
|
-
freezeAuthority: info?.freezeAuthority ?? null,
|
|
1239
|
-
transferFee: feeConfig ? toTransferFeeConfig(feeConfig.older, feeConfig.newer, epoch) : null,
|
|
1240
|
-
}
|
|
267
|
+
return abortable(options.signal, () => tokensOf<F>(this.#rc, mints, options))
|
|
1241
268
|
}
|
|
1242
269
|
|
|
1243
270
|
/** 批量查 NFT 元数据(Metaplex;Token-2022 NFT 取 TokenMetadata 扩展),一次 getMultipleAccounts */
|
|
1244
271
|
nfts(mints: readonly string[], options: RequestOptions = {}): Promise<NftDetails[]> {
|
|
1245
|
-
return abortable(options.signal,
|
|
1246
|
-
const scope = this.#scope()
|
|
1247
|
-
const addresses: string[] = []
|
|
1248
|
-
const plan = mints.map((mint) =>
|
|
1249
|
-
isAddress(mint) ? { mint, metadataIndex: addresses.push(getMetadataAddress(mint)) - 1, mintIndex: addresses.push(mint) - 1 } : { mint, metadataIndex: -1, mintIndex: -1 },
|
|
1250
|
-
)
|
|
1251
|
-
const accounts = addresses.length ? await this.#accounts(addresses, { signal: options.signal }) : []
|
|
1252
|
-
return plan.map(({ mint, metadataIndex, mintIndex }): NftDetails => {
|
|
1253
|
-
const metadataAccount = metadataIndex === -1 ? null : (accounts[metadataIndex] ?? null)
|
|
1254
|
-
const mintAccount = mintIndex === -1 ? null : (accounts[mintIndex] ?? null)
|
|
1255
|
-
this.#learn(scope, mint, mintAccount, metadataAccount, mintIndex !== -1)
|
|
1256
|
-
const metaplex = metadataAccount ? safeParseMetaplex(metadataAccount) : null
|
|
1257
|
-
const ext = mintAccount ? (safeParseMint(mintAccount)?.metadata ?? null) : null
|
|
1258
|
-
if (!metaplex && !ext) {
|
|
1259
|
-
const error: FailureReason = mintIndex === -1 ? 'invalid-address' : !mintAccount ? 'not-found' : !safeParseMint(mintAccount) ? 'not-token' : 'no-metadata'
|
|
1260
|
-
return { mint, name: null, symbol: null, uri: null, collection: null, creators: [], sellerFeeBasisPoints: null, tokenStandard: null, isMutable: null, updateAuthority: null, success: false, error }
|
|
1261
|
-
}
|
|
1262
|
-
return {
|
|
1263
|
-
mint,
|
|
1264
|
-
name: ext?.name ?? metaplex?.name ?? null,
|
|
1265
|
-
symbol: ext?.symbol ?? metaplex?.symbol ?? null,
|
|
1266
|
-
uri: ext?.uri ?? metaplex?.uri ?? null,
|
|
1267
|
-
collection: metaplex?.collection ?? null,
|
|
1268
|
-
creators: metaplex?.creators ?? [],
|
|
1269
|
-
sellerFeeBasisPoints: metaplex?.sellerFeeBasisPoints ?? null,
|
|
1270
|
-
tokenStandard: metaplex?.tokenStandard == null ? null : (TOKEN_STANDARDS[metaplex.tokenStandard] ?? null),
|
|
1271
|
-
isMutable: metaplex?.isMutable ?? null,
|
|
1272
|
-
updateAuthority: metaplex?.updateAuthority ?? null,
|
|
1273
|
-
success: true,
|
|
1274
|
-
}
|
|
1275
|
-
})
|
|
1276
|
-
})
|
|
272
|
+
return abortable(options.signal, () => nftsOf(this.#rc, mints, options))
|
|
1277
273
|
}
|
|
1278
274
|
|
|
1279
275
|
/**
|
|
@@ -1281,187 +277,11 @@ export class SolanaClient {
|
|
|
1281
277
|
* 节点拒绝的 mint(不存在 / 不是代币)在同一次 getMultipleAccounts 里读 mint 账户区分原因
|
|
1282
278
|
*/
|
|
1283
279
|
nftOwners(mints: readonly string[], options: RequestOptions = {}): Promise<NftOwner[]> {
|
|
1284
|
-
return abortable(options.signal,
|
|
1285
|
-
const ctx: QueryContext = { signal: options.signal }
|
|
1286
|
-
const largest = await Promise.all(
|
|
1287
|
-
mints.map((mint): Promise<{ tokenAccount: string | null; rejected: boolean }> =>
|
|
1288
|
-
isAddress(mint)
|
|
1289
|
-
? this.#call<{ value: Array<{ address: string; amount: string }> }>(ctx, 'getTokenLargestAccounts', [mint, { commitment: this.#commitment }])
|
|
1290
|
-
.then((res) => ({ tokenAccount: res.value.find((a) => a.amount !== '0')?.address ?? null, rejected: false }))
|
|
1291
|
-
.catch((err: unknown) => {
|
|
1292
|
-
// mint 不存在 / 不是代币:该项失败;节点问题照常抛出
|
|
1293
|
-
if (err instanceof RpcError && !err.nodeFault) return { tokenAccount: null, rejected: true }
|
|
1294
|
-
throw err
|
|
1295
|
-
})
|
|
1296
|
-
: Promise.resolve({ tokenAccount: null, rejected: false }),
|
|
1297
|
-
),
|
|
1298
|
-
)
|
|
1299
|
-
const toRead = largest.flatMap(({ tokenAccount, rejected }, i) => (tokenAccount ? [tokenAccount] : rejected ? [mints[i] as string] : []))
|
|
1300
|
-
const accounts = toRead.length ? await this.#accounts(toRead, ctx) : []
|
|
1301
|
-
const byAddress = new Map(toRead.map((a, i) => [a, accounts[i] ?? null]))
|
|
1302
|
-
return mints.map((mint, i): NftOwner => {
|
|
1303
|
-
const { tokenAccount, rejected } = largest[i] ?? { tokenAccount: null, rejected: false }
|
|
1304
|
-
if (!isAddress(mint)) {
|
|
1305
|
-
return { mint, owner: null, tokenAccount: null, success: false, error: 'invalid-address' }
|
|
1306
|
-
}
|
|
1307
|
-
if (rejected) {
|
|
1308
|
-
return { mint, owner: null, tokenAccount: null, success: false, error: byAddress.get(mint) ? 'not-token' : 'not-found' }
|
|
1309
|
-
}
|
|
1310
|
-
const account = tokenAccount ? byAddress.get(tokenAccount) : null
|
|
1311
|
-
let owner: string | null = null
|
|
1312
|
-
if (account) {
|
|
1313
|
-
try {
|
|
1314
|
-
owner = parseTokenAccount(account.data).owner
|
|
1315
|
-
} catch {
|
|
1316
|
-
owner = null
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
// 没有余额大于 0 的代币账户(供应量为 0 / 已销毁),或代币账户刚被关闭
|
|
1320
|
-
return owner ? { mint, owner, tokenAccount, success: true } : { mint, owner: null, tokenAccount: null, success: false, error: 'no-holder' }
|
|
1321
|
-
})
|
|
1322
|
-
})
|
|
280
|
+
return abortable(options.signal, () => nftOwnersOf(this.#rc, mints, options))
|
|
1323
281
|
}
|
|
1324
282
|
|
|
1325
283
|
/** 查某地址持有的全部 NFT(扫描代币账户:数量 1、精度 0),再批量读元数据。需要支持 getTokenAccountsByOwner 的节点 */
|
|
1326
284
|
ownerNfts(owner: string, options: RequestOptions = {}): Promise<NftDetails[]> {
|
|
1327
|
-
return abortable(options.signal,
|
|
1328
|
-
if (!isAddress(owner)) {
|
|
1329
|
-
throw new Error(`Invalid owner address: ${owner}`)
|
|
1330
|
-
}
|
|
1331
|
-
const { held } = await this.#scanHoldings({ signal: options.signal }, owner)
|
|
1332
|
-
const mints = [...held].filter(([, h]) => isNftHolding(h)).map(([mint]) => mint)
|
|
1333
|
-
return mints.length ? (await this.nfts(mints, options)).filter((nft) => nft.success) : []
|
|
1334
|
-
})
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
#scope(): string {
|
|
1338
|
-
return this.#scopeId
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
/**
|
|
1342
|
-
* 从 mint 账户 / 元数据账户里学到的信息写入缓存,返回合并后的结果。
|
|
1343
|
-
* lookedForNames:本次同时读了 mint 和元数据账户,都没有 name / symbol 时记为“确认没有”(null),之后不再重查
|
|
1344
|
-
*/
|
|
1345
|
-
#learn(scope: string, mint: string, mintAccount: AccountInfo | null, metadataAccount: AccountInfo | null, lookedForNames = false): TokenMeta {
|
|
1346
|
-
const update: TokenMeta = {}
|
|
1347
|
-
const info = mintAccount ? safeParseMint(mintAccount) : null
|
|
1348
|
-
if (info && mintAccount) {
|
|
1349
|
-
update.decimals = info.decimals
|
|
1350
|
-
update.tokenProgram = mintAccount.owner
|
|
1351
|
-
if (info.metadata) {
|
|
1352
|
-
update.name = info.metadata.name
|
|
1353
|
-
update.symbol = info.metadata.symbol
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
if (metadataAccount && update.name === undefined) {
|
|
1357
|
-
const metaplex = safeParseMetaplex(metadataAccount)
|
|
1358
|
-
if (metaplex) {
|
|
1359
|
-
update.name = metaplex.name
|
|
1360
|
-
update.symbol = metaplex.symbol
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
if (lookedForNames && info && update.name === undefined) {
|
|
1364
|
-
update.name = null
|
|
1365
|
-
update.symbol = null
|
|
1366
|
-
}
|
|
1367
|
-
setMeta(scope, mint, update)
|
|
1368
|
-
return getMeta(scope, mint)
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
function toTransferFee(fee: TransferFeeInfo): TransferFee {
|
|
1373
|
-
return { basisPoints: fee.basisPoints, maximumFee: fee.maximumFee.toString(), epoch: fee.epoch.toString() }
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
/** 当前生效的配置:epoch >= newer.epoch 用 newer,否则 older;不知道当前 epoch 时按 newer */
|
|
1377
|
-
function toTransferFeeConfig(older: TransferFeeInfo, newer: TransferFeeInfo, epoch: bigint | null): TransferFeeConfig {
|
|
1378
|
-
const current = epoch === null || epoch >= newer.epoch ? newer : older
|
|
1379
|
-
return { ...toTransferFee(current), older: toTransferFee(older), newer: toTransferFee(newer) }
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
interface RawAccount {
|
|
1383
|
-
lamports: number | string
|
|
1384
|
-
owner: string
|
|
1385
|
-
data: [string, string] | string
|
|
1386
|
-
executable: boolean
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
function toAccountInfo(address: string, raw: RawAccount | null): AccountInfo | null {
|
|
1390
|
-
if (!raw) {
|
|
1391
|
-
return null
|
|
1392
|
-
}
|
|
1393
|
-
const encoded = Array.isArray(raw.data) ? raw.data[0] : raw.data
|
|
1394
|
-
return {
|
|
1395
|
-
address,
|
|
1396
|
-
lamports: BigInt(raw.lamports),
|
|
1397
|
-
owner: raw.owner,
|
|
1398
|
-
data: base64.decode(encoded),
|
|
1399
|
-
executable: raw.executable,
|
|
285
|
+
return abortable(options.signal, () => ownerNftsOf(this.#rc, owner, options))
|
|
1400
286
|
}
|
|
1401
287
|
}
|
|
1402
|
-
|
|
1403
|
-
function safeParseMint(account: AccountInfo): MintInfo | null {
|
|
1404
|
-
if (account.owner !== TOKEN_PROGRAM_ID && account.owner !== TOKEN_2022_PROGRAM_ID) {
|
|
1405
|
-
return null
|
|
1406
|
-
}
|
|
1407
|
-
try {
|
|
1408
|
-
const mint = parseMint(account.data)
|
|
1409
|
-
// 未初始化的 mint(创建和初始化分在两笔交易里,恰好在中间读到):decimals 还是 0,不能当真、更不能永久缓存
|
|
1410
|
-
return mint.isInitialized ? mint : null
|
|
1411
|
-
} catch {
|
|
1412
|
-
return null
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
function safeParseMetaplex(account: AccountInfo): MetaplexMetadata | null {
|
|
1417
|
-
try {
|
|
1418
|
-
return parseMetaplexMetadata(account.data)
|
|
1419
|
-
} catch {
|
|
1420
|
-
return null
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
function getMeta(scope: string, mint: string): TokenMeta {
|
|
1425
|
-
const cached = tokenMetaCache.get(`${scope}:${mint}`)
|
|
1426
|
-
if (!cached) {
|
|
1427
|
-
return {}
|
|
1428
|
-
}
|
|
1429
|
-
const { namesAt, ...meta } = cached
|
|
1430
|
-
if (namesAt === undefined || Date.now() - namesAt > NAME_TTL) {
|
|
1431
|
-
delete meta.name
|
|
1432
|
-
delete meta.symbol
|
|
1433
|
-
}
|
|
1434
|
-
return meta
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
function setMeta(scope: string, mint: string, meta: TokenMeta): void {
|
|
1438
|
-
const defined: CachedMeta = Object.fromEntries(Object.entries(meta).filter(([, v]) => v !== undefined))
|
|
1439
|
-
if (Object.keys(defined).length === 0) {
|
|
1440
|
-
return
|
|
1441
|
-
}
|
|
1442
|
-
if ('name' in defined || 'symbol' in defined) {
|
|
1443
|
-
defined.namesAt = Date.now()
|
|
1444
|
-
}
|
|
1445
|
-
const key = `${scope}:${mint}`
|
|
1446
|
-
const merged = { ...tokenMetaCache.get(key), ...defined }
|
|
1447
|
-
if ('tokenProgram' in defined && !defined.programUnverified) {
|
|
1448
|
-
delete merged.programUnverified // 从链上学到了所属程序
|
|
1449
|
-
}
|
|
1450
|
-
tokenMetaCache.delete(key) // 重新插入,保持“最近写入在后”的顺序
|
|
1451
|
-
tokenMetaCache.set(key, merged)
|
|
1452
|
-
if (tokenMetaCache.size > MAX_CACHED_TOKENS) {
|
|
1453
|
-
tokenMetaCache.delete(tokenMetaCache.keys().next().value as string)
|
|
1454
|
-
}
|
|
1455
|
-
for (const listener of cacheWriteListeners) {
|
|
1456
|
-
listener()
|
|
1457
|
-
}
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
function withSymbolField(balance: TokenBalance, withSymbol: boolean, symbol: string | null): TokenBalance {
|
|
1461
|
-
return withSymbol ? { ...balance, symbol } : balance
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
/** 失败项:余额为 0,带原因 */
|
|
1465
|
-
function failedBalance(token: string, error: FailureReason, tokenProgram: string | null, withSymbol: boolean, symbol: string | null): TokenBalance {
|
|
1466
|
-
return { ...withSymbolField({ token, native: false, balance: '0', decimals: 0, formatted: '0', tokenProgram, success: false }, withSymbol, symbol), error }
|
|
1467
|
-
}
|