@w3lib/solana-batch-call 0.0.0-stage → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +270 -2
  4. package/dist/address.d.ts +20 -0
  5. package/dist/address.d.ts.map +1 -0
  6. package/dist/address.js +120 -0
  7. package/dist/address.js.map +1 -0
  8. package/dist/client.d.ts +190 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +971 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/constants.d.ts +21 -0
  13. package/dist/constants.d.ts.map +1 -0
  14. package/dist/constants.js +35 -0
  15. package/dist/constants.js.map +1 -0
  16. package/dist/index.d.ts +9 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +9 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/layout.d.ts +84 -0
  21. package/dist/layout.d.ts.map +1 -0
  22. package/dist/layout.js +237 -0
  23. package/dist/layout.js.map +1 -0
  24. package/dist/rpc.d.ts +115 -0
  25. package/dist/rpc.d.ts.map +1 -0
  26. package/dist/rpc.js +458 -0
  27. package/dist/rpc.js.map +1 -0
  28. package/dist/shortcuts.d.ts +93 -0
  29. package/dist/shortcuts.d.ts.map +1 -0
  30. package/dist/shortcuts.js +281 -0
  31. package/dist/shortcuts.js.map +1 -0
  32. package/dist/source.d.ts +35 -0
  33. package/dist/source.d.ts.map +1 -0
  34. package/dist/source.js +124 -0
  35. package/dist/source.js.map +1 -0
  36. package/dist/types.d.ts +155 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +2 -0
  39. package/dist/types.js.map +1 -0
  40. package/package.json +60 -4
  41. package/src/address.ts +134 -0
  42. package/src/client.ts +1298 -0
  43. package/src/constants.ts +41 -0
  44. package/src/index.ts +98 -0
  45. package/src/layout.ts +320 -0
  46. package/src/rpc.ts +549 -0
  47. package/src/shortcuts.ts +367 -0
  48. package/src/source.ts +149 -0
  49. package/src/types.ts +164 -0
@@ -0,0 +1,367 @@
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'
11
+ import type { RpcSource } from './source.js'
12
+ import type { DefaultTokenField, NftDetails, NftOwner, OwnedToken, SolBalance, TokenBalance, TokenDetails, TokenField } from './types.js'
13
+
14
+ /** 各函数共用的节点参数 */
15
+ export interface ShortcutOptions extends ClientConfig {
16
+ /**
17
+ * 节点:RPC URL、URL 数组(主节点 + 备用节点)、web3.js Connection、自定义传输(request(method, params))。
18
+ * 不传则使用 cluster(默认 mainnet)的内置公共节点
19
+ */
20
+ provider?: RpcSource | readonly RpcSource[]
21
+ }
22
+
23
+ // 复用客户端:多次调用共享连接、批量合并与故障切换状态。按 “配置内容” 分组,组内 URL 按字符串、对象按引用(WeakMap)。
24
+ // 配置里有函数(如自定义 fetch)或节点数组含对象时不缓存。
25
+ const MAX_CACHED = 32
26
+
27
+ interface ClientGroup {
28
+ byString: Map<string, SolanaClient>
29
+ byObject: WeakMap<object, SolanaClient>
30
+ }
31
+
32
+ const clientCache = new Map<string, ClientGroup>()
33
+
34
+ function setBounded<K, V>(map: Map<K, V>, key: K, value: V): void {
35
+ map.set(key, value)
36
+ if (map.size > MAX_CACHED) {
37
+ map.delete(map.keys().next().value as K)
38
+ }
39
+ }
40
+
41
+ function configKey(config: ClientConfig): string | null {
42
+ try {
43
+ return JSON.stringify(config, (_key, value: unknown) => {
44
+ if (typeof value === 'function') {
45
+ throw new Error('not comparable')
46
+ }
47
+ return value
48
+ })
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+
54
+ function sourceKey(source: ShortcutOptions['provider']): string | object | null {
55
+ if (source === undefined) {
56
+ return 'default'
57
+ }
58
+ if (typeof source === 'string') {
59
+ return `url:${source}`
60
+ }
61
+ if (Array.isArray(source)) {
62
+ return source.every((item) => typeof item === 'string') ? `urls:${source.join('\n')}` : null
63
+ }
64
+ return source as object
65
+ }
66
+
67
+ /**
68
+ * 拆出节点参数和函数自己的参数(ownKeys),其余作为客户端配置;值为 undefined 的参数忽略。
69
+ * signal 总是作为函数自己的参数(放进客户端配置会让每次调用都新建客户端)
70
+ */
71
+ function resolve<T extends ShortcutOptions, K extends keyof T & string = never>(
72
+ options: T,
73
+ ownKeys: readonly K[] = [],
74
+ ): { client: SolanaClient; own: Pick<T, K> } {
75
+ const { provider, ...others } = options
76
+ const own: Record<string, unknown> = {}
77
+ const config: Record<string, unknown> = {}
78
+ for (const [key, value] of Object.entries(others)) {
79
+ if (value !== undefined) {
80
+ ;((ownKeys as readonly string[]).includes(key) ? own : key === 'signal' ? {} : config)[key] = value
81
+ }
82
+ }
83
+ return { client: getClient(provider, config as ClientConfig), own: own as Pick<T, K> }
84
+ }
85
+
86
+ function getClient(source: ShortcutOptions['provider'], config: ClientConfig): SolanaClient {
87
+ const cfg = configKey(config)
88
+ const key = sourceKey(source)
89
+ if (cfg === null || key === null) {
90
+ return new SolanaClient(source, config)
91
+ }
92
+ let group = clientCache.get(cfg)
93
+ if (!group) {
94
+ group = { byString: new Map(), byObject: new WeakMap() }
95
+ setBounded(clientCache, cfg, group)
96
+ }
97
+ let client = typeof key === 'string' ? group.byString.get(key) : group.byObject.get(key)
98
+ if (!client) {
99
+ client = new SolanaClient(source, config)
100
+ if (typeof key === 'string') {
101
+ setBounded(group.byString, key, client)
102
+ } else {
103
+ group.byObject.set(key, client)
104
+ }
105
+ }
106
+ return client
107
+ }
108
+
109
+ export interface GetBalancesOptions extends BalancesOptions, ShortcutOptions {}
110
+
111
+ /**
112
+ * 批量查余额(SOL + SPL Token + Token-2022),返回原始余额、decimals 和换算后的数值。
113
+ *
114
+ * ```ts
115
+ * import { NATIVE_MINT, getBalances } from '@w3lib/solana-batch-call'
116
+ *
117
+ * await getBalances(owner, [NATIVE_MINT, USDC]) // 指定代币:免费公共节点也能用
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 的节点
121
+ * await getBalances(owner, undefined, { provider: 'https://my-rpc…' }) // 全部持仓:需要支持 getTokenAccountsByOwner 的节点
122
+ * ```
123
+ */
124
+ export function getBalances(owner: string, mints?: readonly string[], options: GetBalancesOptions = {}): Promise<TokenBalance[]> {
125
+ const { client, own } = resolve(options, BALANCE_KEYS)
126
+ return client.balances(owner, mints, own)
127
+ }
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
+
145
+ /** 批量查多个地址的 SOL 余额(一次 getMultipleAccounts) */
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)
149
+ }
150
+
151
+ export interface GetTokensOptions<F extends TokenField = DefaultTokenField> extends TokensOptions<F>, ShortcutOptions {}
152
+
153
+ /**
154
+ * 批量查代币详情,字段可选(默认 name / symbol / decimals)。
155
+ *
156
+ * ```ts
157
+ * await getTokens([USDC, PYUSD], { fields: ['name', 'symbol', 'decimals', 'supply', 'tokenProgram'] })
158
+ * ```
159
+ */
160
+ export function getTokens<const F extends TokenField = DefaultTokenField>(mints: readonly string[], options: GetTokensOptions<F> = {}): Promise<TokenDetails<F>[]> {
161
+ const { client, own } = resolve(options, ['fields', 'signal'])
162
+ return client.tokens<F>(mints, own)
163
+ }
164
+
165
+ export interface GetNftsOptions extends RequestOptions, ShortcutOptions {}
166
+
167
+ /** 批量查 NFT 元数据(名称、uri、所属集合、版税、创作者等) */
168
+ export function getNfts(mints: readonly string[], options: GetNftsOptions = {}): Promise<NftDetails[]> {
169
+ const { client, own } = resolve(options, ['signal'])
170
+ return client.nfts(mints, own)
171
+ }
172
+
173
+ /** 批量查 NFT 持有人 */
174
+ export function getNftOwners(mints: readonly string[], options: GetNftsOptions = {}): Promise<NftOwner[]> {
175
+ const { client, own } = resolve(options, ['signal'])
176
+ return client.nftOwners(mints, own)
177
+ }
178
+
179
+ /** 查某地址持有的全部 NFT(需要支持 getTokenAccountsByOwner 的节点) */
180
+ export function getOwnerNfts(owner: string, options: GetNftsOptions = {}): Promise<NftDetails[]> {
181
+ const { client, own } = resolve(options, ['signal'])
182
+ return client.ownerNfts(owner, own)
183
+ }
184
+
185
+ export interface GetOwnerTokensOptions extends OwnerTokensOptions, ShortcutOptions {}
186
+
187
+ /**
188
+ * 列出持有人拥有的全部代币(SOL 在第一位),带 name / symbol,适合做资产列表。
189
+ * 需要支持 getTokenAccountsByOwner 的节点。
190
+ *
191
+ * ```ts
192
+ * await getOwnerTokens(owner, { provider: 'https://my-rpc…' })
193
+ * // [{ token: NATIVE_MINT, native: true, name: 'Solana', symbol: 'SOL', formatted: '1.5', … }, { token: USDC, name: 'USD Coin', … }]
194
+ * ```
195
+ */
196
+ export function getOwnerTokens(owner: string, options: GetOwnerTokensOptions = {}): Promise<OwnedToken[]> {
197
+ const { client, own } = resolve(options, ['metadata', 'includeNative', 'includeZero', 'includeNfts', 'tokenPrograms', 'signal', 'minContextSlot', 'withSlot'])
198
+ return client.ownerTokens(owner, own)
199
+ }
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
+
359
+ /** 测试用:按参数取(或创建)客户端,用于验证缓存复用(不在包的公开导出里) */
360
+ export function resolveClientForTest(options: ShortcutOptions): SolanaClient {
361
+ return resolve(options).client
362
+ }
363
+
364
+ /** 测试用:清空客户端缓存 */
365
+ export function resetClientCache(): void {
366
+ clientCache.clear()
367
+ }
package/src/source.ts ADDED
@@ -0,0 +1,149 @@
1
+ import { DEFAULT_RPC_URLS, GENESIS_HASHES, type Cluster } from './constants.js'
2
+ import { FallbackRpc, HttpRpc, type FallbackOptions, type HttpRpcOptions, type RpcTransport } from './rpc.js'
3
+
4
+ /**
5
+ * 节点来源(也可以传数组:主节点 + 备用节点):
6
+ * - RPC URL 字符串
7
+ * - @solana/web3.js 的 Connection(使用其 rpcEndpoint)
8
+ * - 自定义传输:实现 `request(method, params)` 的对象(如包一层 @solana/kit 的 rpc)
9
+ */
10
+ export type RpcSource = string | RpcTransport | { rpcEndpoint: string }
11
+
12
+ export interface SourceOptions extends HttpRpcOptions, FallbackOptions {}
13
+
14
+ /** @param failFast 多个自定义节点时为 true:不在单节点内重试 429,直接切到下一个节点 */
15
+ function toTransport(source: RpcSource, options: SourceOptions, failFast: boolean): RpcTransport {
16
+ if (typeof source === 'string') {
17
+ return new HttpRpc(source, { ...options, retries: failFast ? 0 : options.retries })
18
+ }
19
+ if (typeof (source as RpcTransport).request === 'function') {
20
+ return source as RpcTransport
21
+ }
22
+ const endpoint = (source as { rpcEndpoint?: unknown }).rpcEndpoint
23
+ if (typeof endpoint === 'string') {
24
+ return new HttpRpc(endpoint, { ...options, retries: failFast ? 0 : options.retries })
25
+ }
26
+ throw new Error('Unsupported RPC source: expected a URL, a web3.js Connection or an object with request(method, params)')
27
+ }
28
+
29
+ // 各节点的创世区块哈希缓存:失败(含超时)时删除,下次重试
30
+ const genesisCache = new WeakMap<RpcTransport, Promise<string>>()
31
+
32
+ export function genesisOf(transport: RpcTransport): Promise<string> {
33
+ let cached = genesisCache.get(transport)
34
+ if (!cached) {
35
+ const promise = transport.request<string>('getGenesisHash')
36
+ genesisCache.set(transport, promise)
37
+ promise.catch(() => {
38
+ if (genesisCache.get(transport) === promise) {
39
+ genesisCache.delete(transport)
40
+ }
41
+ })
42
+ cached = promise
43
+ }
44
+ return cached
45
+ }
46
+
47
+ /** 识别失败后多久内不再重试(期间请求照常发出,不做校验) */
48
+ const CHECK_RETRY_INTERVAL = 60_000
49
+
50
+ export class NetworkMismatchError extends Error {
51
+ constructor(expected: string, actual: string) {
52
+ super(`Network mismatch: expected genesis ${expected}, but the node is on ${actual}`)
53
+ this.name = 'NetworkMismatchError'
54
+ }
55
+ }
56
+
57
+ /**
58
+ * 校验节点所在的网络(创世区块哈希),避免把 devnet 的数据当成 mainnet 的返回。
59
+ * 校验与请求并行发出,不增加延迟;结果缓存,每个节点只多一次请求;识别失败时不拦截,并在一段时间内不再重试。
60
+ */
61
+ export class NetworkCheckedRpc implements RpcTransport {
62
+ #checkFailedAt = 0
63
+
64
+ constructor(
65
+ readonly inner: RpcTransport,
66
+ readonly expected: () => Promise<string>,
67
+ ) {}
68
+
69
+ async #check(): Promise<void> {
70
+ if (this.#checkFailedAt && Date.now() - this.#checkFailedAt < CHECK_RETRY_INTERVAL) {
71
+ return
72
+ }
73
+ let expected: string
74
+ let actual: string
75
+ try {
76
+ ;[expected, actual] = await Promise.all([this.expected(), genesisOf(this.inner)])
77
+ } catch {
78
+ this.#checkFailedAt = Date.now()
79
+ return
80
+ }
81
+ this.#checkFailedAt = 0
82
+ if (expected !== actual) {
83
+ throw new NetworkMismatchError(expected, actual)
84
+ }
85
+ }
86
+
87
+ async request<T = unknown>(method: string, params?: readonly unknown[]): Promise<T> {
88
+ const [checked, result] = await Promise.allSettled([this.#check(), this.inner.request<T>(method, params)])
89
+ if (checked.status === 'rejected') {
90
+ throw checked.reason
91
+ }
92
+ if (result.status === 'rejected') {
93
+ throw result.reason
94
+ }
95
+ return result.value
96
+ }
97
+ }
98
+
99
+ export interface ResolvedSource {
100
+ transport: RpcTransport
101
+ /** 期望的创世区块哈希:指定了 cluster 时取对应值,否则取第一个响应节点的 */
102
+ genesis: () => Promise<string>
103
+ }
104
+
105
+ export function resolveSource(
106
+ source: RpcSource | readonly RpcSource[] | undefined,
107
+ cluster: Cluster | undefined,
108
+ options: SourceOptions = {},
109
+ ): ResolvedSource {
110
+ const isDefault = source === undefined
111
+ const list: readonly RpcSource[] = isDefault ? DEFAULT_RPC_URLS[cluster ?? 'mainnet'] : Array.isArray(source) ? source : [source as RpcSource]
112
+ if (!list.length) {
113
+ throw new Error('RPC source list is empty')
114
+ }
115
+ const multiple = list.length > 1
116
+ // 内置节点保留 429 重试:浏览器里官方节点总是 403,实际只有 publicnode 一个可用节点
117
+ const raw = list.map((item) => toTransport(item, options, multiple && !isDefault))
118
+
119
+ let detecting: Promise<string> | null = null
120
+ const genesis = (): Promise<string> => {
121
+ if (cluster) {
122
+ return Promise.resolve(GENESIS_HASHES[cluster])
123
+ }
124
+ if (!detecting) {
125
+ const promise = (async () => {
126
+ let lastError: unknown
127
+ for (const node of raw) {
128
+ try {
129
+ return await genesisOf(node)
130
+ } catch (err) {
131
+ lastError = err
132
+ }
133
+ }
134
+ throw lastError
135
+ })()
136
+ detecting = promise
137
+ promise.catch(() => {
138
+ if (detecting === promise) detecting = null
139
+ })
140
+ }
141
+ return detecting
142
+ }
143
+
144
+ // 内置节点本来就是按 cluster 选的,不校验;单个节点且没指定 cluster 时,期望值就是它自己,也不需要校验
145
+ const check = !isDefault && (multiple || cluster !== undefined)
146
+ const nodes = check ? raw.map((node) => new NetworkCheckedRpc(node, genesis)) : raw
147
+ // 单个节点也包一层 FallbackRpc:统一 onRequest 事件和健康状态(单节点失败时抛原始错误)
148
+ return { transport: new FallbackRpc(nodes, options), genesis }
149
+ }
package/src/types.ts ADDED
@@ -0,0 +1,164 @@
1
+ export type Commitment = 'processed' | 'confirmed' | 'finalized'
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
+
22
+ /** balances() 的单项结果,字段都是字符串 / 数字 / 布尔 / null,可以直接 JSON.stringify */
23
+ export interface TokenBalance {
24
+ /** 代币的 mint(原样) */
25
+ token: string
26
+ /** 是否按原生 SOL 查询 */
27
+ native: boolean
28
+ /** 余额(最小单位的十进制字符串);需要计算时用 BigInt(balance) */
29
+ balance: string
30
+ decimals: number
31
+ /** 按 decimals 换算后的十进制字符串,如 "1234.5";整数不带小数点 */
32
+ formatted: string
33
+ /** 代币所属程序(SPL Token / Token-2022);主币或未知时为 null */
34
+ tokenProgram: string | null
35
+ /** 仅在 symbol: true 时返回;读不到时为 null */
36
+ symbol?: string | null
37
+ /** 查询失败(mint 不存在、地址非法等)时为 false,balance / formatted 为 "0" */
38
+ success: boolean
39
+ /** 失败原因,仅 success 为 false 时返回 */
40
+ error?: FailureReason
41
+ /** 传了 withSlot / minContextSlot 时返回:读取时节点的 slot(一次查询发了多个请求时取最小的) */
42
+ slot?: number
43
+ }
44
+
45
+ /** ownerTokens() / getOwnerTokens() 的单项结果(总是 success: true) */
46
+ export interface OwnedToken extends Omit<TokenBalance, 'symbol' | 'error'> {
47
+ /** 该代币的代币账户数量;大于 1 时 balance 为所有账户的合计 */
48
+ accounts: number
49
+ /** metadata 为 true(默认)时返回;没有元数据或读取失败时为 null(见 metadataStatus) */
50
+ name?: string | null
51
+ symbol?: string | null
52
+ /** metadata 为 true 时返回:ok 读到了;missing 确认没有元数据;failed 节点问题没能读到(可以稍后单独用 getTokens 重查) */
53
+ metadataStatus?: 'ok' | 'missing' | 'failed'
54
+ }
55
+
56
+ export interface SolBalance {
57
+ address: string
58
+ /** lamports(十进制字符串) */
59
+ balance: string
60
+ /** 按 9 位精度换算后的 SOL */
61
+ formatted: string
62
+ success: boolean
63
+ /** 失败原因(只有 invalid-address),仅 success 为 false 时返回 */
64
+ error?: FailureReason
65
+ /** 传了 withSlot / minContextSlot 时返回:读取时节点的 slot(多个请求时取最小的);没有发请求(地址全部非法)时不返回 */
66
+ slot?: number
67
+ }
68
+
69
+ /** tokens() / getTokens() 可选的字段 */
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
+ }
90
+
91
+ export const DEFAULT_TOKEN_FIELDS = ['name', 'symbol', 'decimals'] as const satisfies readonly TokenField[]
92
+
93
+ export type DefaultTokenField = (typeof DEFAULT_TOKEN_FIELDS)[number]
94
+
95
+ interface TokenFieldTypes {
96
+ /** 优先取 Token-2022 TokenMetadata 扩展,其次 Metaplex 元数据 */
97
+ name: string | null
98
+ symbol: string | null
99
+ uri: string | null
100
+ decimals: number | null
101
+ /** 最小单位的十进制字符串 */
102
+ supply: string | null
103
+ tokenProgram: string | null
104
+ /** 没有 mint 权限(已放弃)时为 null */
105
+ mintAuthority: string | null
106
+ freezeAuthority: string | null
107
+ /** Token-2022 转账手续费;SPL Token、没有这个扩展、主币时为 null(不算失败) */
108
+ transferFee: TransferFeeConfig | null
109
+ }
110
+
111
+ /**
112
+ * tokens() / getTokens() 的单项结果,只包含请求的字段。
113
+ * 请求 supply 时额外带 supplyFormatted(按 decimals 换算)。
114
+ */
115
+ export type TokenDetails<F extends TokenField = DefaultTokenField> = {
116
+ /** mint 地址 */
117
+ address: string
118
+ native: boolean
119
+ /** mint 存在且请求的 name / symbol / uri / decimals / supply 都读到时为 true */
120
+ success: boolean
121
+ /** 失败原因,仅 success 为 false 时返回 */
122
+ error?: FailureReason
123
+ /** error 为 missing-field 时:第一个读不到的字段 */
124
+ errorField?: F
125
+ } & { [K in F]: TokenFieldTypes[K] } & ('supply' extends F ? { supplyFormatted: string | null } : unknown)
126
+
127
+ export type TokenStandard =
128
+ | 'NonFungible'
129
+ | 'FungibleAsset'
130
+ | 'Fungible'
131
+ | 'NonFungibleEdition'
132
+ | 'ProgrammableNonFungible'
133
+ | 'ProgrammableNonFungibleEdition'
134
+
135
+ /** nfts() / getNfts() 的单项结果(Metaplex 元数据;Token-2022 NFT 只有 name / symbol / uri) */
136
+ export interface NftDetails {
137
+ mint: string
138
+ name: string | null
139
+ symbol: string | null
140
+ /** 链下元数据 JSON 的地址 */
141
+ uri: string | null
142
+ /** 所属集合;verified 为 false 表示未经集合方签名确认 */
143
+ collection: { address: string; verified: boolean } | null
144
+ creators: Array<{ address: string; verified: boolean; share: number }>
145
+ /** 版税,万分比 */
146
+ sellerFeeBasisPoints: number | null
147
+ tokenStandard: TokenStandard | null
148
+ isMutable: boolean | null
149
+ updateAuthority: string | null
150
+ success: boolean
151
+ /** 失败原因,仅 success 为 false 时返回 */
152
+ error?: FailureReason
153
+ }
154
+
155
+ export interface NftOwner {
156
+ mint: string
157
+ /** 持有人钱包地址 */
158
+ owner: string | null
159
+ /** 持有该 NFT 的代币账户 */
160
+ tokenAccount: string | null
161
+ success: boolean
162
+ /** 失败原因,仅 success 为 false 时返回 */
163
+ error?: FailureReason
164
+ }