@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/src/shortcuts.ts CHANGED
@@ -1,4 +1,13 @@
1
- import { SolanaClient, type BalancesOptions, type ClientConfig, type OwnerTokensOptions, type TokensOptions } from './client.js'
1
+ import {
2
+ SolanaClient,
3
+ type BalanceQuery,
4
+ type BalancesOptions,
5
+ type ClientConfig,
6
+ type OwnerTokensOptions,
7
+ type RequestOptions,
8
+ type SolBalancesOptions,
9
+ type TokensOptions,
10
+ } from './client.js'
2
11
  import type { RpcSource } from './source.js'
3
12
  import type { DefaultTokenField, NftDetails, NftOwner, OwnedToken, SolBalance, TokenBalance, TokenDetails, TokenField } from './types.js'
4
13
 
@@ -55,7 +64,10 @@ function sourceKey(source: ShortcutOptions['provider']): string | object | null
55
64
  return source as object
56
65
  }
57
66
 
58
- /** 拆出节点参数和函数自己的参数(ownKeys),其余作为客户端配置;值为 undefined 的参数忽略 */
67
+ /**
68
+ * 拆出节点参数和函数自己的参数(ownKeys),其余作为客户端配置;值为 undefined 的参数忽略。
69
+ * signal 总是作为函数自己的参数(放进客户端配置会让每次调用都新建客户端)
70
+ */
59
71
  function resolve<T extends ShortcutOptions, K extends keyof T & string = never>(
60
72
  options: T,
61
73
  ownKeys: readonly K[] = [],
@@ -65,7 +77,7 @@ function resolve<T extends ShortcutOptions, K extends keyof T & string = never>(
65
77
  const config: Record<string, unknown> = {}
66
78
  for (const [key, value] of Object.entries(others)) {
67
79
  if (value !== undefined) {
68
- ;((ownKeys as readonly string[]).includes(key) ? own : config)[key] = value
80
+ ;((ownKeys as readonly string[]).includes(key) ? own : key === 'signal' ? {} : config)[key] = value
69
81
  }
70
82
  }
71
83
  return { client: getClient(provider, config as ClientConfig), own: own as Pick<T, K> }
@@ -104,17 +116,36 @@ export interface GetBalancesOptions extends BalancesOptions, ShortcutOptions {}
104
116
  *
105
117
  * await getBalances(owner, [NATIVE_MINT, USDC]) // 指定代币:免费公共节点也能用
106
118
  * await getBalances(owner, [NATIVE_MINT, USDC], { symbol: true })
119
+ * await getBalances(owner, undefined, { tokenPrograms: ['spl'] }) // 不含 Token-2022 代币
120
+ * await getBalances(owner, [USDC], { accounts: 'all' }) // 算上非 ATA 账户:需要支持 getTokenAccountsByOwner 的节点
107
121
  * await getBalances(owner, undefined, { provider: 'https://my-rpc…' }) // 全部持仓:需要支持 getTokenAccountsByOwner 的节点
108
122
  * ```
109
123
  */
110
124
  export function getBalances(owner: string, mints?: readonly string[], options: GetBalancesOptions = {}): Promise<TokenBalance[]> {
111
- const { client, own } = resolve(options, ['symbol', 'scan'])
125
+ const { client, own } = resolve(options, BALANCE_KEYS)
112
126
  return client.balances(owner, mints, own)
113
127
  }
114
128
 
129
+ const BALANCE_KEYS = ['symbol', 'scan', 'accounts', 'tokenPrograms', 'signal', 'minContextSlot', 'withSlot'] as const
130
+
131
+ /**
132
+ * 多个钱包一次查,结果与 queries 一一对应;ATA 模式下所有钱包合并成一次 getMultipleAccounts。参数同 getBalances。
133
+ *
134
+ * ```ts
135
+ * await getMultiBalances([{ owner: a, mints: [NATIVE_MINT, USDC] }, { owner: b, mints: [USDC] }])
136
+ * ```
137
+ */
138
+ export function getMultiBalances(queries: readonly BalanceQuery[], options: GetBalancesOptions = {}): Promise<TokenBalance[][]> {
139
+ const { client, own } = resolve(options, BALANCE_KEYS)
140
+ return client.multiBalances(queries, own)
141
+ }
142
+
143
+ export interface GetSolBalancesOptions extends SolBalancesOptions, ShortcutOptions {}
144
+
115
145
  /** 批量查多个地址的 SOL 余额(一次 getMultipleAccounts) */
116
- export function getSolBalances(addresses: readonly string[], options: ShortcutOptions = {}): Promise<SolBalance[]> {
117
- return resolve(options).client.solBalances(addresses)
146
+ export function getSolBalances(addresses: readonly string[], options: GetSolBalancesOptions = {}): Promise<SolBalance[]> {
147
+ const { client, own } = resolve(options, ['signal', 'minContextSlot', 'withSlot'])
148
+ return client.solBalances(addresses, own)
118
149
  }
119
150
 
120
151
  export interface GetTokensOptions<F extends TokenField = DefaultTokenField> extends TokensOptions<F>, ShortcutOptions {}
@@ -127,23 +158,28 @@ export interface GetTokensOptions<F extends TokenField = DefaultTokenField> exte
127
158
  * ```
128
159
  */
129
160
  export function getTokens<const F extends TokenField = DefaultTokenField>(mints: readonly string[], options: GetTokensOptions<F> = {}): Promise<TokenDetails<F>[]> {
130
- const { client, own } = resolve(options, ['fields'])
131
- return client.tokens<F>(mints, { fields: own.fields })
161
+ const { client, own } = resolve(options, ['fields', 'signal'])
162
+ return client.tokens<F>(mints, own)
132
163
  }
133
164
 
165
+ export interface GetNftsOptions extends RequestOptions, ShortcutOptions {}
166
+
134
167
  /** 批量查 NFT 元数据(名称、uri、所属集合、版税、创作者等) */
135
- export function getNfts(mints: readonly string[], options: ShortcutOptions = {}): Promise<NftDetails[]> {
136
- return resolve(options).client.nfts(mints)
168
+ export function getNfts(mints: readonly string[], options: GetNftsOptions = {}): Promise<NftDetails[]> {
169
+ const { client, own } = resolve(options, ['signal'])
170
+ return client.nfts(mints, own)
137
171
  }
138
172
 
139
173
  /** 批量查 NFT 持有人 */
140
- export function getNftOwners(mints: readonly string[], options: ShortcutOptions = {}): Promise<NftOwner[]> {
141
- return resolve(options).client.nftOwners(mints)
174
+ export function getNftOwners(mints: readonly string[], options: GetNftsOptions = {}): Promise<NftOwner[]> {
175
+ const { client, own } = resolve(options, ['signal'])
176
+ return client.nftOwners(mints, own)
142
177
  }
143
178
 
144
179
  /** 查某地址持有的全部 NFT(需要支持 getTokenAccountsByOwner 的节点) */
145
- export function getOwnerNfts(owner: string, options: ShortcutOptions = {}): Promise<NftDetails[]> {
146
- return resolve(options).client.ownerNfts(owner)
180
+ export function getOwnerNfts(owner: string, options: GetNftsOptions = {}): Promise<NftDetails[]> {
181
+ const { client, own } = resolve(options, ['signal'])
182
+ return client.ownerNfts(owner, own)
147
183
  }
148
184
 
149
185
  export interface GetOwnerTokensOptions extends OwnerTokensOptions, ShortcutOptions {}
@@ -158,10 +194,168 @@ export interface GetOwnerTokensOptions extends OwnerTokensOptions, ShortcutOptio
158
194
  * ```
159
195
  */
160
196
  export function getOwnerTokens(owner: string, options: GetOwnerTokensOptions = {}): Promise<OwnedToken[]> {
161
- const { client, own } = resolve(options, ['metadata', 'includeNative', 'includeZero', 'includeNfts'])
197
+ const { client, own } = resolve(options, ['metadata', 'includeNative', 'includeZero', 'includeNfts', 'tokenPrograms', 'signal', 'minContextSlot', 'withSlot'])
162
198
  return client.ownerTokens(owner, own)
163
199
  }
164
200
 
201
+ export interface WatchBalancesOptions extends Omit<GetBalancesOptions, 'signal' | 'minContextSlot'> {
202
+ /** 轮询间隔(毫秒),默认 15000;多处订阅同一份时取最小的 */
203
+ interval?: number
204
+ /** 第一次拿到结果、以及之后余额(balance)变化时调用;previous 是上一次通知的结果(第一次为 null) */
205
+ onChange: (balances: TokenBalance[], previous: TokenBalance[] | null) => void
206
+ /** 某次轮询失败时调用(之后照常继续轮询) */
207
+ onError?: (err: unknown) => void
208
+ }
209
+
210
+ interface Watcher {
211
+ interval: number
212
+ onChange: WatchBalancesOptions['onChange']
213
+ onError?: WatchBalancesOptions['onError']
214
+ }
215
+
216
+ interface Watch {
217
+ client: SolanaClient
218
+ owner: string
219
+ mints: readonly string[] | undefined
220
+ options: BalancesOptions
221
+ watchers: Set<Watcher>
222
+ timer: ReturnType<typeof setTimeout> | null
223
+ running: boolean
224
+ stopped: boolean
225
+ last: TokenBalance[] | null
226
+ lastKey: string | null
227
+ }
228
+
229
+ // 共享轮询:按客户端(同样的节点配置会复用同一个客户端)+ owner + mints(顺序无关)+ 影响结果的选项
230
+ const watches = new WeakMap<SolanaClient, Map<string, Watch>>()
231
+
232
+ /**
233
+ * 轮询余额,多处订阅同一个钱包时合并成一份轮询,只在余额变化时通知。返回取消订阅的函数;最后一个订阅者取消时停止轮询。
234
+ * 参数同 getBalances(不支持 signal / minContextSlot);上一次请求没完成时不会发起下一次。
235
+ * 结果顺序按第一个订阅者传的 mints;新订阅者加入时如果已有结果,立即收到一次(previous 为 null)
236
+ *
237
+ * ```ts
238
+ * const stop = watchBalances(owner, [NATIVE_MINT, USDC], { interval: 10_000, onChange: (list) => render(list) })
239
+ * stop()
240
+ * ```
241
+ */
242
+ export function watchBalances(owner: string, mints: readonly string[] | undefined, options: WatchBalancesOptions): () => void {
243
+ const { onChange, onError, interval = 15_000, ...rest } = options
244
+ if (!(interval > 0)) {
245
+ throw new Error(`Invalid interval: ${interval}`)
246
+ }
247
+ const { client, own } = resolve(rest, ['symbol', 'scan', 'accounts', 'tokenPrograms', 'withSlot'])
248
+ const key = JSON.stringify([
249
+ owner,
250
+ mints === undefined ? null : [...new Set(mints)].sort(),
251
+ own.symbol ?? false,
252
+ own.scan ?? false,
253
+ own.accounts ?? 'ata',
254
+ own.tokenPrograms === undefined ? null : [...own.tokenPrograms].sort(),
255
+ own.withSlot ?? false,
256
+ ])
257
+ let group = watches.get(client)
258
+ if (!group) {
259
+ group = new Map()
260
+ watches.set(client, group)
261
+ }
262
+ let watch = group.get(key)
263
+ const watcher: Watcher = { interval, onChange, onError }
264
+ if (!watch) {
265
+ watch = { client, owner, mints, options: own, watchers: new Set([watcher]), timer: null, running: false, stopped: false, last: null, lastKey: null }
266
+ group.set(key, watch)
267
+ poll(watch)
268
+ } else {
269
+ watch.watchers.add(watcher)
270
+ const current = watch
271
+ if (current.last) {
272
+ const last = current.last
273
+ queueMicrotask(() => current.watchers.has(watcher) && safely(() => watcher.onChange(last, null)))
274
+ }
275
+ // 间隔变小:按新的间隔重新安排下一次
276
+ if (current.timer && interval < nextInterval(current, watcher)) {
277
+ clearTimeout(current.timer)
278
+ current.timer = null
279
+ schedule(current)
280
+ }
281
+ }
282
+ const owned = watch
283
+ const ownedGroup = group
284
+ return () => {
285
+ if (!owned.watchers.delete(watcher) || owned.watchers.size) {
286
+ return
287
+ }
288
+ owned.stopped = true
289
+ if (owned.timer) {
290
+ clearTimeout(owned.timer)
291
+ owned.timer = null
292
+ }
293
+ if (ownedGroup.get(key) === owned) {
294
+ ownedGroup.delete(key)
295
+ }
296
+ }
297
+ }
298
+
299
+ /** 除了 except 以外订阅者的最小间隔(用于判断新订阅者是否让间隔变小) */
300
+ function nextInterval(watch: Watch, except?: Watcher): number {
301
+ let min = Number.POSITIVE_INFINITY
302
+ for (const w of watch.watchers) {
303
+ if (w !== except) min = Math.min(min, w.interval)
304
+ }
305
+ return min
306
+ }
307
+
308
+ function schedule(watch: Watch): void {
309
+ if (watch.stopped || watch.timer || watch.running) {
310
+ return
311
+ }
312
+ watch.timer = setTimeout(() => poll(watch), nextInterval(watch))
313
+ }
314
+
315
+ function poll(watch: Watch): void {
316
+ watch.timer = null
317
+ if (watch.running || watch.stopped) {
318
+ return
319
+ }
320
+ watch.running = true
321
+ watch.client
322
+ .balances(watch.owner, watch.mints, watch.options)
323
+ .then(
324
+ (balances) => {
325
+ // 只比较余额(slot 每次都会变)
326
+ const key = JSON.stringify(balances.map((b) => [b.token, b.balance, b.success]))
327
+ if (watch.stopped || key === watch.lastKey) {
328
+ return
329
+ }
330
+ const previous = watch.last
331
+ watch.last = balances
332
+ watch.lastKey = key
333
+ for (const w of [...watch.watchers]) {
334
+ safely(() => w.onChange(balances, previous))
335
+ }
336
+ },
337
+ (err: unknown) => {
338
+ if (!watch.stopped) {
339
+ for (const w of [...watch.watchers]) {
340
+ safely(() => w.onError?.(err))
341
+ }
342
+ }
343
+ },
344
+ )
345
+ .finally(() => {
346
+ watch.running = false
347
+ schedule(watch)
348
+ })
349
+ }
350
+
351
+ function safely(fn: () => void): void {
352
+ try {
353
+ fn()
354
+ } catch {
355
+ // 订阅者的回调出错不影响轮询
356
+ }
357
+ }
358
+
165
359
  /** 测试用:按参数取(或创建)客户端,用于验证缓存复用(不在包的公开导出里) */
166
360
  export function resolveClientForTest(options: ShortcutOptions): SolanaClient {
167
361
  return resolve(options).client
package/src/source.ts CHANGED
@@ -144,8 +144,6 @@ export function resolveSource(
144
144
  // 内置节点本来就是按 cluster 选的,不校验;单个节点且没指定 cluster 时,期望值就是它自己,也不需要校验
145
145
  const check = !isDefault && (multiple || cluster !== undefined)
146
146
  const nodes = check ? raw.map((node) => new NetworkCheckedRpc(node, genesis)) : raw
147
- return {
148
- transport: multiple ? new FallbackRpc(nodes, options) : (nodes[0] as RpcTransport),
149
- genesis,
150
- }
147
+ // 单个节点也包一层 FallbackRpc:统一 onRequest 事件和健康状态(单节点失败时抛原始错误)
148
+ return { transport: new FallbackRpc(nodes, options), genesis }
151
149
  }
package/src/types.ts CHANGED
@@ -1,5 +1,24 @@
1
1
  export type Commitment = 'processed' | 'confirmed' | 'finalized'
2
2
 
3
+ /**
4
+ * tokenPrograms 参数的取值:简写 'spl'(SPL Token)/ 'token-2022',也可以直接传程序地址
5
+ * (TOKEN_PROGRAM_ID / TOKEN_2022_PROGRAM_ID)
6
+ */
7
+ export type TokenProgramName = 'spl' | 'token-2022'
8
+ // (string & {}):保留 'spl' / 'token-2022' 的自动补全
9
+ export type TokenProgramOption = TokenProgramName | (string & {})
10
+
11
+ /**
12
+ * 单项失败(success: false)的原因:
13
+ * - invalid-address:地址格式非法(不是 base58 的 32 字节)
14
+ * - not-found:地址合法,但链上没有这个账户(如 mint 不存在)
15
+ * - not-token:账户存在,但不是代币 mint(或代币账户)
16
+ * - no-metadata:代币存在,但没有元数据(nfts)
17
+ * - no-holder:没有持有人(nftOwners:供应量为 0 或已销毁)
18
+ * - missing-field:代币存在,但请求的字段读不到(tokens),见 errorField
19
+ */
20
+ export type FailureReason = 'invalid-address' | 'not-found' | 'not-token' | 'no-metadata' | 'no-holder' | 'missing-field'
21
+
3
22
  /** balances() 的单项结果,字段都是字符串 / 数字 / 布尔 / null,可以直接 JSON.stringify */
4
23
  export interface TokenBalance {
5
24
  /** 代币的 mint(原样) */
@@ -17,10 +36,14 @@ export interface TokenBalance {
17
36
  symbol?: string | null
18
37
  /** 查询失败(mint 不存在、地址非法等)时为 false,balance / formatted 为 "0" */
19
38
  success: boolean
39
+ /** 失败原因,仅 success 为 false 时返回 */
40
+ error?: FailureReason
41
+ /** 传了 withSlot / minContextSlot 时返回:读取时节点的 slot(一次查询发了多个请求时取最小的) */
42
+ slot?: number
20
43
  }
21
44
 
22
- /** ownerTokens() / getOwnerTokens() 的单项结果 */
23
- export interface OwnedToken extends Omit<TokenBalance, 'symbol'> {
45
+ /** ownerTokens() / getOwnerTokens() 的单项结果(总是 success: true) */
46
+ export interface OwnedToken extends Omit<TokenBalance, 'symbol' | 'error'> {
24
47
  /** 该代币的代币账户数量;大于 1 时 balance 为所有账户的合计 */
25
48
  accounts: number
26
49
  /** metadata 为 true(默认)时返回;没有元数据或读取失败时为 null(见 metadataStatus) */
@@ -37,10 +60,33 @@ export interface SolBalance {
37
60
  /** 按 9 位精度换算后的 SOL */
38
61
  formatted: string
39
62
  success: boolean
63
+ /** 失败原因(只有 invalid-address),仅 success 为 false 时返回 */
64
+ error?: FailureReason
65
+ /** 传了 withSlot / minContextSlot 时返回:读取时节点的 slot(多个请求时取最小的);没有发请求(地址全部非法)时不返回 */
66
+ slot?: number
40
67
  }
41
68
 
42
69
  /** tokens() / getTokens() 可选的字段 */
43
- export type TokenField = 'name' | 'symbol' | 'uri' | 'decimals' | 'supply' | 'tokenProgram' | 'mintAuthority' | 'freezeAuthority'
70
+ export type TokenField = 'name' | 'symbol' | 'uri' | 'decimals' | 'supply' | 'tokenProgram' | 'mintAuthority' | 'freezeAuthority' | 'transferFee'
71
+
72
+ /** Token-2022 转账手续费的一套配置 */
73
+ export interface TransferFee {
74
+ /** 费率,万分比(100 = 1%) */
75
+ basisPoints: number
76
+ /** 单笔最多收取的手续费(最小单位的十进制字符串) */
77
+ maximumFee: string
78
+ /** 从这个 epoch 开始生效 */
79
+ epoch: string
80
+ }
81
+
82
+ /**
83
+ * tokens() 的 transferFee 字段:顶层是**当前 epoch** 生效的配置(按 getEpochInfo 选 newer / older),
84
+ * older / newer 是扩展里的两套原始配置。收到的数量 = 转账数量 - min(数量 × basisPoints / 10000 向上取整, maximumFee)
85
+ */
86
+ export interface TransferFeeConfig extends TransferFee {
87
+ older: TransferFee
88
+ newer: TransferFee
89
+ }
44
90
 
45
91
  export const DEFAULT_TOKEN_FIELDS = ['name', 'symbol', 'decimals'] as const satisfies readonly TokenField[]
46
92
 
@@ -58,6 +104,8 @@ interface TokenFieldTypes {
58
104
  /** 没有 mint 权限(已放弃)时为 null */
59
105
  mintAuthority: string | null
60
106
  freezeAuthority: string | null
107
+ /** Token-2022 转账手续费;SPL Token、没有这个扩展、主币时为 null(不算失败) */
108
+ transferFee: TransferFeeConfig | null
61
109
  }
62
110
 
63
111
  /**
@@ -70,6 +118,10 @@ export type TokenDetails<F extends TokenField = DefaultTokenField> = {
70
118
  native: boolean
71
119
  /** mint 存在且请求的 name / symbol / uri / decimals / supply 都读到时为 true */
72
120
  success: boolean
121
+ /** 失败原因,仅 success 为 false 时返回 */
122
+ error?: FailureReason
123
+ /** error 为 missing-field 时:第一个读不到的字段 */
124
+ errorField?: F
73
125
  } & { [K in F]: TokenFieldTypes[K] } & ('supply' extends F ? { supplyFormatted: string | null } : unknown)
74
126
 
75
127
  export type TokenStandard =
@@ -96,6 +148,8 @@ export interface NftDetails {
96
148
  isMutable: boolean | null
97
149
  updateAuthority: string | null
98
150
  success: boolean
151
+ /** 失败原因,仅 success 为 false 时返回 */
152
+ error?: FailureReason
99
153
  }
100
154
 
101
155
  export interface NftOwner {
@@ -105,4 +159,6 @@ export interface NftOwner {
105
159
  /** 持有该 NFT 的代币账户 */
106
160
  tokenAccount: string | null
107
161
  success: boolean
162
+ /** 失败原因,仅 success 为 false 时返回 */
163
+ error?: FailureReason
108
164
  }