@w3lib/solana-batch-call 0.1.0 → 0.2.1

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
 
@@ -29,11 +38,24 @@ function setBounded<K, V>(map: Map<K, V>, key: K, value: V): void {
29
38
  }
30
39
  }
31
40
 
41
+ // 配置里的函数(如自定义 fetch)按对象编号参与缓存键:同一个函数对象复用同一个客户端(WeakMap 不阻止回收)
42
+ const functionIds = new WeakMap<object, number>()
43
+ let nextFunctionId = 0
44
+
45
+ /** 配置的缓存键:对象的键按字母排序(写法顺序不同视为同一份配置),函数按对象编号;无法序列化时返回 null(不缓存) */
32
46
  function configKey(config: ClientConfig): string | null {
33
47
  try {
34
48
  return JSON.stringify(config, (_key, value: unknown) => {
35
49
  if (typeof value === 'function') {
36
- throw new Error('not comparable')
50
+ let id = functionIds.get(value)
51
+ if (id === undefined) {
52
+ id = ++nextFunctionId
53
+ functionIds.set(value, id)
54
+ }
55
+ return `function#${id}`
56
+ }
57
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
58
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
37
59
  }
38
60
  return value
39
61
  })
@@ -55,7 +77,10 @@ function sourceKey(source: ShortcutOptions['provider']): string | object | null
55
77
  return source as object
56
78
  }
57
79
 
58
- /** 拆出节点参数和函数自己的参数(ownKeys),其余作为客户端配置;值为 undefined 的参数忽略 */
80
+ /**
81
+ * 拆出节点参数和函数自己的参数(ownKeys),其余作为客户端配置;值为 undefined 的参数忽略。
82
+ * signal 总是作为函数自己的参数(放进客户端配置会让每次调用都新建客户端)
83
+ */
59
84
  function resolve<T extends ShortcutOptions, K extends keyof T & string = never>(
60
85
  options: T,
61
86
  ownKeys: readonly K[] = [],
@@ -65,7 +90,7 @@ function resolve<T extends ShortcutOptions, K extends keyof T & string = never>(
65
90
  const config: Record<string, unknown> = {}
66
91
  for (const [key, value] of Object.entries(others)) {
67
92
  if (value !== undefined) {
68
- ;((ownKeys as readonly string[]).includes(key) ? own : config)[key] = value
93
+ ;((ownKeys as readonly string[]).includes(key) ? own : key === 'signal' ? {} : config)[key] = value
69
94
  }
70
95
  }
71
96
  return { client: getClient(provider, config as ClientConfig), own: own as Pick<T, K> }
@@ -104,17 +129,36 @@ export interface GetBalancesOptions extends BalancesOptions, ShortcutOptions {}
104
129
  *
105
130
  * await getBalances(owner, [NATIVE_MINT, USDC]) // 指定代币:免费公共节点也能用
106
131
  * await getBalances(owner, [NATIVE_MINT, USDC], { symbol: true })
132
+ * await getBalances(owner, undefined, { tokenPrograms: ['spl'] }) // 不含 Token-2022 代币
133
+ * await getBalances(owner, [USDC], { accounts: 'all' }) // 算上非 ATA 账户:需要支持 getTokenAccountsByOwner 的节点
107
134
  * await getBalances(owner, undefined, { provider: 'https://my-rpc…' }) // 全部持仓:需要支持 getTokenAccountsByOwner 的节点
108
135
  * ```
109
136
  */
110
137
  export function getBalances(owner: string, mints?: readonly string[], options: GetBalancesOptions = {}): Promise<TokenBalance[]> {
111
- const { client, own } = resolve(options, ['symbol', 'scan'])
138
+ const { client, own } = resolve(options, BALANCE_KEYS)
112
139
  return client.balances(owner, mints, own)
113
140
  }
114
141
 
142
+ const BALANCE_KEYS = ['symbol', 'scan', 'accounts', 'tokenPrograms', 'signal', 'minContextSlot', 'withSlot'] as const
143
+
144
+ /**
145
+ * 多个钱包一次查,结果与 queries 一一对应;ATA 模式下所有钱包合并成一次 getMultipleAccounts。参数同 getBalances。
146
+ *
147
+ * ```ts
148
+ * await getMultiBalances([{ owner: a, mints: [NATIVE_MINT, USDC] }, { owner: b, mints: [USDC] }])
149
+ * ```
150
+ */
151
+ export function getMultiBalances(queries: readonly BalanceQuery[], options: GetBalancesOptions = {}): Promise<TokenBalance[][]> {
152
+ const { client, own } = resolve(options, BALANCE_KEYS)
153
+ return client.multiBalances(queries, own)
154
+ }
155
+
156
+ export interface GetSolBalancesOptions extends SolBalancesOptions, ShortcutOptions {}
157
+
115
158
  /** 批量查多个地址的 SOL 余额(一次 getMultipleAccounts) */
116
- export function getSolBalances(addresses: readonly string[], options: ShortcutOptions = {}): Promise<SolBalance[]> {
117
- return resolve(options).client.solBalances(addresses)
159
+ export function getSolBalances(addresses: readonly string[], options: GetSolBalancesOptions = {}): Promise<SolBalance[]> {
160
+ const { client, own } = resolve(options, ['signal', 'minContextSlot', 'withSlot'])
161
+ return client.solBalances(addresses, own)
118
162
  }
119
163
 
120
164
  export interface GetTokensOptions<F extends TokenField = DefaultTokenField> extends TokensOptions<F>, ShortcutOptions {}
@@ -127,23 +171,28 @@ export interface GetTokensOptions<F extends TokenField = DefaultTokenField> exte
127
171
  * ```
128
172
  */
129
173
  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 })
174
+ const { client, own } = resolve(options, ['fields', 'signal'])
175
+ return client.tokens<F>(mints, own)
132
176
  }
133
177
 
178
+ export interface GetNftsOptions extends RequestOptions, ShortcutOptions {}
179
+
134
180
  /** 批量查 NFT 元数据(名称、uri、所属集合、版税、创作者等) */
135
- export function getNfts(mints: readonly string[], options: ShortcutOptions = {}): Promise<NftDetails[]> {
136
- return resolve(options).client.nfts(mints)
181
+ export function getNfts(mints: readonly string[], options: GetNftsOptions = {}): Promise<NftDetails[]> {
182
+ const { client, own } = resolve(options, ['signal'])
183
+ return client.nfts(mints, own)
137
184
  }
138
185
 
139
186
  /** 批量查 NFT 持有人 */
140
- export function getNftOwners(mints: readonly string[], options: ShortcutOptions = {}): Promise<NftOwner[]> {
141
- return resolve(options).client.nftOwners(mints)
187
+ export function getNftOwners(mints: readonly string[], options: GetNftsOptions = {}): Promise<NftOwner[]> {
188
+ const { client, own } = resolve(options, ['signal'])
189
+ return client.nftOwners(mints, own)
142
190
  }
143
191
 
144
192
  /** 查某地址持有的全部 NFT(需要支持 getTokenAccountsByOwner 的节点) */
145
- export function getOwnerNfts(owner: string, options: ShortcutOptions = {}): Promise<NftDetails[]> {
146
- return resolve(options).client.ownerNfts(owner)
193
+ export function getOwnerNfts(owner: string, options: GetNftsOptions = {}): Promise<NftDetails[]> {
194
+ const { client, own } = resolve(options, ['signal'])
195
+ return client.ownerNfts(owner, own)
147
196
  }
148
197
 
149
198
  export interface GetOwnerTokensOptions extends OwnerTokensOptions, ShortcutOptions {}
@@ -158,10 +207,168 @@ export interface GetOwnerTokensOptions extends OwnerTokensOptions, ShortcutOptio
158
207
  * ```
159
208
  */
160
209
  export function getOwnerTokens(owner: string, options: GetOwnerTokensOptions = {}): Promise<OwnedToken[]> {
161
- const { client, own } = resolve(options, ['metadata', 'includeNative', 'includeZero', 'includeNfts'])
210
+ const { client, own } = resolve(options, ['metadata', 'includeNative', 'includeZero', 'includeNfts', 'tokenPrograms', 'signal', 'minContextSlot', 'withSlot'])
162
211
  return client.ownerTokens(owner, own)
163
212
  }
164
213
 
214
+ export interface WatchBalancesOptions extends Omit<GetBalancesOptions, 'signal' | 'minContextSlot'> {
215
+ /** 轮询间隔(毫秒),默认 15000;多处订阅同一份时取最小的 */
216
+ interval?: number
217
+ /** 第一次拿到结果、以及之后余额(balance)变化时调用;previous 是上一次通知的结果(第一次为 null) */
218
+ onChange: (balances: TokenBalance[], previous: TokenBalance[] | null) => void
219
+ /** 某次轮询失败时调用(之后照常继续轮询) */
220
+ onError?: (err: unknown) => void
221
+ }
222
+
223
+ interface Watcher {
224
+ interval: number
225
+ onChange: WatchBalancesOptions['onChange']
226
+ onError?: WatchBalancesOptions['onError']
227
+ }
228
+
229
+ interface Watch {
230
+ client: SolanaClient
231
+ owner: string
232
+ mints: readonly string[] | undefined
233
+ options: BalancesOptions
234
+ watchers: Set<Watcher>
235
+ timer: ReturnType<typeof setTimeout> | null
236
+ running: boolean
237
+ stopped: boolean
238
+ last: TokenBalance[] | null
239
+ lastKey: string | null
240
+ }
241
+
242
+ // 共享轮询:按客户端(同样的节点配置会复用同一个客户端)+ owner + mints(顺序无关)+ 影响结果的选项
243
+ const watches = new WeakMap<SolanaClient, Map<string, Watch>>()
244
+
245
+ /**
246
+ * 轮询余额,多处订阅同一个钱包时合并成一份轮询,只在余额变化时通知。返回取消订阅的函数;最后一个订阅者取消时停止轮询。
247
+ * 参数同 getBalances(不支持 signal / minContextSlot);上一次请求没完成时不会发起下一次。
248
+ * 结果顺序按第一个订阅者传的 mints;新订阅者加入时如果已有结果,立即收到一次(previous 为 null)
249
+ *
250
+ * ```ts
251
+ * const stop = watchBalances(owner, [NATIVE_MINT, USDC], { interval: 10_000, onChange: (list) => render(list) })
252
+ * stop()
253
+ * ```
254
+ */
255
+ export function watchBalances(owner: string, mints: readonly string[] | undefined, options: WatchBalancesOptions): () => void {
256
+ const { onChange, onError, interval = 15_000, ...rest } = options
257
+ if (!(interval > 0)) {
258
+ throw new Error(`Invalid interval: ${interval}`)
259
+ }
260
+ const { client, own } = resolve(rest, ['symbol', 'scan', 'accounts', 'tokenPrograms', 'withSlot'])
261
+ const key = JSON.stringify([
262
+ owner,
263
+ mints === undefined ? null : [...new Set(mints)].sort(),
264
+ own.symbol ?? false,
265
+ own.scan ?? false,
266
+ own.accounts ?? 'ata',
267
+ own.tokenPrograms === undefined ? null : [...own.tokenPrograms].sort(),
268
+ own.withSlot ?? false,
269
+ ])
270
+ let group = watches.get(client)
271
+ if (!group) {
272
+ group = new Map()
273
+ watches.set(client, group)
274
+ }
275
+ let watch = group.get(key)
276
+ const watcher: Watcher = { interval, onChange, onError }
277
+ if (!watch) {
278
+ watch = { client, owner, mints, options: own, watchers: new Set([watcher]), timer: null, running: false, stopped: false, last: null, lastKey: null }
279
+ group.set(key, watch)
280
+ poll(watch)
281
+ } else {
282
+ watch.watchers.add(watcher)
283
+ const current = watch
284
+ if (current.last) {
285
+ const last = current.last
286
+ queueMicrotask(() => current.watchers.has(watcher) && safely(() => watcher.onChange(last, null)))
287
+ }
288
+ // 间隔变小:按新的间隔重新安排下一次
289
+ if (current.timer && interval < nextInterval(current, watcher)) {
290
+ clearTimeout(current.timer)
291
+ current.timer = null
292
+ schedule(current)
293
+ }
294
+ }
295
+ const owned = watch
296
+ const ownedGroup = group
297
+ return () => {
298
+ if (!owned.watchers.delete(watcher) || owned.watchers.size) {
299
+ return
300
+ }
301
+ owned.stopped = true
302
+ if (owned.timer) {
303
+ clearTimeout(owned.timer)
304
+ owned.timer = null
305
+ }
306
+ if (ownedGroup.get(key) === owned) {
307
+ ownedGroup.delete(key)
308
+ }
309
+ }
310
+ }
311
+
312
+ /** 除了 except 以外订阅者的最小间隔(用于判断新订阅者是否让间隔变小) */
313
+ function nextInterval(watch: Watch, except?: Watcher): number {
314
+ let min = Number.POSITIVE_INFINITY
315
+ for (const w of watch.watchers) {
316
+ if (w !== except) min = Math.min(min, w.interval)
317
+ }
318
+ return min
319
+ }
320
+
321
+ function schedule(watch: Watch): void {
322
+ if (watch.stopped || watch.timer || watch.running) {
323
+ return
324
+ }
325
+ watch.timer = setTimeout(() => poll(watch), nextInterval(watch))
326
+ }
327
+
328
+ function poll(watch: Watch): void {
329
+ watch.timer = null
330
+ if (watch.running || watch.stopped) {
331
+ return
332
+ }
333
+ watch.running = true
334
+ watch.client
335
+ .balances(watch.owner, watch.mints, watch.options)
336
+ .then(
337
+ (balances) => {
338
+ // 只比较余额(slot 每次都会变)
339
+ const key = JSON.stringify(balances.map((b) => [b.token, b.balance, b.success]))
340
+ if (watch.stopped || key === watch.lastKey) {
341
+ return
342
+ }
343
+ const previous = watch.last
344
+ watch.last = balances
345
+ watch.lastKey = key
346
+ for (const w of [...watch.watchers]) {
347
+ safely(() => w.onChange(balances, previous))
348
+ }
349
+ },
350
+ (err: unknown) => {
351
+ if (!watch.stopped) {
352
+ for (const w of [...watch.watchers]) {
353
+ safely(() => w.onError?.(err))
354
+ }
355
+ }
356
+ },
357
+ )
358
+ .finally(() => {
359
+ watch.running = false
360
+ schedule(watch)
361
+ })
362
+ }
363
+
364
+ function safely(fn: () => void): void {
365
+ try {
366
+ fn()
367
+ } catch {
368
+ // 订阅者的回调出错不影响轮询
369
+ }
370
+ }
371
+
165
372
  /** 测试用:按参数取(或创建)客户端,用于验证缓存复用(不在包的公开导出里) */
166
373
  export function resolveClientForTest(options: ShortcutOptions): SolanaClient {
167
374
  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
  }