@w3lib/solana-batch-call 0.0.0-stage → 0.1.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 +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +169 -2
  4. package/dist/address.d.ts +14 -0
  5. package/dist/address.d.ts.map +1 -0
  6. package/dist/address.js +81 -0
  7. package/dist/address.js.map +1 -0
  8. package/dist/client.d.ts +96 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +575 -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 +55 -0
  21. package/dist/layout.d.ts.map +1 -0
  22. package/dist/layout.js +177 -0
  23. package/dist/layout.js.map +1 -0
  24. package/dist/rpc.d.ts +59 -0
  25. package/dist/rpc.d.ts.map +1 -0
  26. package/dist/rpc.js +295 -0
  27. package/dist/rpc.js.map +1 -0
  28. package/dist/shortcuts.d.ts +60 -0
  29. package/dist/shortcuts.d.ts.map +1 -0
  30. package/dist/shortcuts.js +136 -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 +126 -0
  35. package/dist/source.js.map +1 -0
  36. package/dist/types.d.ts +104 -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 +56 -3
  41. package/src/address.ts +92 -0
  42. package/src/client.ts +753 -0
  43. package/src/constants.ts +41 -0
  44. package/src/index.ts +54 -0
  45. package/src/layout.ts +238 -0
  46. package/src/rpc.ts +349 -0
  47. package/src/shortcuts.ts +173 -0
  48. package/src/source.ts +151 -0
  49. package/src/types.ts +108 -0
@@ -0,0 +1,41 @@
1
+ export const SYSTEM_PROGRAM_ID = '11111111111111111111111111111111'
2
+ export const TOKEN_PROGRAM_ID = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
3
+ export const TOKEN_2022_PROGRAM_ID = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'
4
+ export const ASSOCIATED_TOKEN_PROGRAM_ID = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'
5
+ export const METADATA_PROGRAM_ID = 'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s'
6
+
7
+ /** Wrapped SOL 的 mint,按惯例也用来表示原生 SOL */
8
+ export const NATIVE_MINT = 'So11111111111111111111111111111111111111112'
9
+
10
+ /** 原生 SOL 的精度(lamports) */
11
+ export const SOL_DECIMALS = 9
12
+
13
+ export type Cluster = 'mainnet' | 'devnet' | 'testnet'
14
+
15
+ /** 各网络的创世区块哈希(getGenesisHash),用于识别节点所在网络 */
16
+ export const GENESIS_HASHES: Readonly<Record<Cluster, string>> = {
17
+ mainnet: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d',
18
+ devnet: 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG',
19
+ testnet: '4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY',
20
+ }
21
+
22
+ export function clusterOfGenesis(hash: string): Cluster | null {
23
+ for (const [cluster, genesis] of Object.entries(GENESIS_HASHES)) {
24
+ if (genesis === hash) {
25
+ return cluster as Cluster
26
+ }
27
+ }
28
+ return null
29
+ }
30
+
31
+ /**
32
+ * 内置公共节点(按优先级排序,2026-10 实测):
33
+ * - publicnode:放行跨域、支持批量请求,但不支持 getTokenAccountsByOwner 等需要索引的方法
34
+ * - 官方节点:对带浏览器 Origin 的请求返回 403,只能在服务端使用
35
+ * 公共节点限频严,生产环境建议传入自己的节点(Helius / QuickNode / Triton 等)。
36
+ */
37
+ export const DEFAULT_RPC_URLS: Readonly<Record<Cluster, readonly string[]>> = {
38
+ mainnet: ['https://solana-rpc.publicnode.com', 'https://api.mainnet-beta.solana.com'],
39
+ devnet: ['https://api.devnet.solana.com'],
40
+ testnet: ['https://api.testnet.solana.com'],
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ export { SolanaClient, type BalancesOptions, type ClientConfig, type OwnerTokensOptions, type TokensOptions } from './client.js'
2
+ export {
3
+ getBalances,
4
+ getNftOwners,
5
+ getNfts,
6
+ getOwnerNfts,
7
+ getOwnerTokens,
8
+ getSolBalances,
9
+ getTokens,
10
+ type GetBalancesOptions,
11
+ type GetOwnerTokensOptions,
12
+ type GetTokensOptions,
13
+ type ShortcutOptions,
14
+ } from './shortcuts.js'
15
+ export {
16
+ DEFAULT_TOKEN_FIELDS,
17
+ type Commitment,
18
+ type DefaultTokenField,
19
+ type NftDetails,
20
+ type NftOwner,
21
+ type OwnedToken,
22
+ type SolBalance,
23
+ type TokenBalance,
24
+ type TokenDetails,
25
+ type TokenField,
26
+ type TokenStandard,
27
+ } from './types.js'
28
+ export {
29
+ ASSOCIATED_TOKEN_PROGRAM_ID,
30
+ DEFAULT_RPC_URLS,
31
+ GENESIS_HASHES,
32
+ METADATA_PROGRAM_ID,
33
+ NATIVE_MINT,
34
+ SOL_DECIMALS,
35
+ SYSTEM_PROGRAM_ID,
36
+ TOKEN_2022_PROGRAM_ID,
37
+ TOKEN_PROGRAM_ID,
38
+ clusterOfGenesis,
39
+ type Cluster,
40
+ } from './constants.js'
41
+ export { decodeAddress, encodeAddress, findProgramAddress, getAssociatedTokenAddress, getMetadataAddress, isAddress } from './address.js'
42
+ export {
43
+ formatAmount,
44
+ parseMetaplexMetadata,
45
+ parseMint,
46
+ parseTokenAccount,
47
+ type AccountInfo,
48
+ type MetaplexMetadata,
49
+ type MintInfo,
50
+ type TokenAccountInfo,
51
+ type TokenMetadata,
52
+ } from './layout.js'
53
+ export { FallbackRpc, HttpError, HttpRpc, RpcError, isNodeFault, type FallbackOptions, type HttpRpcOptions, type RpcTransport } from './rpc.js'
54
+ export { NetworkCheckedRpc, NetworkMismatchError, type RpcSource, type SourceOptions } from './source.js'
package/src/layout.ts ADDED
@@ -0,0 +1,238 @@
1
+ import { encodeAddress } from './address.js'
2
+
3
+ /** getMultipleAccounts 解析后的账户 */
4
+ export interface AccountInfo {
5
+ address: string
6
+ lamports: bigint
7
+ /** 所属程序 */
8
+ owner: string
9
+ data: Uint8Array
10
+ executable: boolean
11
+ }
12
+
13
+ class Reader {
14
+ #offset: number
15
+ readonly #view: DataView
16
+
17
+ constructor(
18
+ readonly bytes: Uint8Array,
19
+ offset = 0,
20
+ ) {
21
+ this.#offset = offset
22
+ this.#view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
23
+ }
24
+
25
+ get offset(): number {
26
+ return this.#offset
27
+ }
28
+
29
+ #need(n: number): void {
30
+ if (this.#offset + n > this.bytes.length) {
31
+ throw new RangeError('Account data is shorter than expected')
32
+ }
33
+ }
34
+
35
+ skip(n: number): void {
36
+ this.#need(n)
37
+ this.#offset += n
38
+ }
39
+
40
+ u8(): number {
41
+ this.#need(1)
42
+ return this.bytes[this.#offset++] as number
43
+ }
44
+
45
+ u16(): number {
46
+ this.#need(2)
47
+ const v = this.#view.getUint16(this.#offset, true)
48
+ this.#offset += 2
49
+ return v
50
+ }
51
+
52
+ u32(): number {
53
+ this.#need(4)
54
+ const v = this.#view.getUint32(this.#offset, true)
55
+ this.#offset += 4
56
+ return v
57
+ }
58
+
59
+ u64(): bigint {
60
+ this.#need(8)
61
+ const v = this.#view.getBigUint64(this.#offset, true)
62
+ this.#offset += 8
63
+ return v
64
+ }
65
+
66
+ pubkey(): string {
67
+ this.#need(32)
68
+ const v = encodeAddress(this.bytes.slice(this.#offset, this.#offset + 32))
69
+ this.#offset += 32
70
+ return v
71
+ }
72
+
73
+ /** borsh string:u32 长度 + UTF-8;去掉 Metaplex 用来补齐长度的 \0 */
74
+ string(): string {
75
+ const len = this.u32()
76
+ this.#need(len)
77
+ const v = new TextDecoder().decode(this.bytes.slice(this.#offset, this.#offset + len))
78
+ this.#offset += len
79
+ return v.replace(/\0+$/, '').trim()
80
+ }
81
+
82
+ option<T>(read: () => T): T | null {
83
+ return this.u8() === 1 ? read() : null
84
+ }
85
+ }
86
+
87
+ export interface MintInfo {
88
+ mintAuthority: string | null
89
+ supply: bigint
90
+ decimals: number
91
+ isInitialized: boolean
92
+ freezeAuthority: string | null
93
+ /** Token-2022 TokenMetadata 扩展(如有) */
94
+ metadata: TokenMetadata | null
95
+ }
96
+
97
+ export interface TokenMetadata {
98
+ name: string
99
+ symbol: string
100
+ uri: string
101
+ }
102
+
103
+ const MINT_SIZE = 82
104
+ const ACCOUNT_SIZE = 165
105
+ /** Token-2022 带扩展的账户:基础布局补齐到 165(Account 的大小),第 165 字节是账户类型,之后是扩展 */
106
+ const ACCOUNT_TYPE_OFFSET = 165
107
+ const EXTENSIONS_OFFSET = 166
108
+ const ACCOUNT_TYPE_MINT = 1
109
+ const ACCOUNT_TYPE_ACCOUNT = 2
110
+ const EXTENSION_TOKEN_METADATA = 19
111
+
112
+ /** 解析 Mint 账户(SPL Token / Token-2022 通用,Token-2022 额外解析 TokenMetadata 扩展) */
113
+ export function parseMint(data: Uint8Array): MintInfo {
114
+ // 只接受真正的 mint:SPL Token 恰好 82 字节;Token-2022 带扩展时账户类型必须是 Mint(避免把代币账户 / 多签账户当成 mint)
115
+ if (data.length !== MINT_SIZE && !(data.length > ACCOUNT_TYPE_OFFSET && data[ACCOUNT_TYPE_OFFSET] === ACCOUNT_TYPE_MINT)) {
116
+ throw new RangeError('Not a mint account')
117
+ }
118
+ const r = new Reader(data)
119
+ const hasMintAuthority = r.u32() === 1
120
+ const mintAuthority = r.pubkey()
121
+ const supply = r.u64()
122
+ const decimals = r.u8()
123
+ const isInitialized = r.u8() === 1
124
+ const hasFreezeAuthority = r.u32() === 1
125
+ const freezeAuthority = r.pubkey()
126
+ return {
127
+ mintAuthority: hasMintAuthority ? mintAuthority : null,
128
+ supply,
129
+ decimals,
130
+ isInitialized,
131
+ freezeAuthority: hasFreezeAuthority ? freezeAuthority : null,
132
+ metadata: data.length > EXTENSIONS_OFFSET ? parseTokenMetadataExtension(data) : null,
133
+ }
134
+ }
135
+
136
+ function parseTokenMetadataExtension(data: Uint8Array): TokenMetadata | null {
137
+ try {
138
+ const r = new Reader(data, EXTENSIONS_OFFSET)
139
+ while (r.offset + 4 <= data.length) {
140
+ const type = r.u16()
141
+ const length = r.u16()
142
+ if (type === 0 && length === 0) {
143
+ return null // 未初始化的填充
144
+ }
145
+ if (type === EXTENSION_TOKEN_METADATA) {
146
+ const m = new Reader(data.slice(r.offset, r.offset + length))
147
+ m.pubkey() // updateAuthority
148
+ m.pubkey() // mint
149
+ return { name: m.string(), symbol: m.string(), uri: m.string() }
150
+ }
151
+ r.skip(length)
152
+ }
153
+ } catch {
154
+ // 扩展数据不完整:当作没有
155
+ }
156
+ return null
157
+ }
158
+
159
+ export interface TokenAccountInfo {
160
+ mint: string
161
+ owner: string
162
+ amount: bigint
163
+ }
164
+
165
+ /** 解析代币账户(SPL Token / Token-2022 前 72 字节相同) */
166
+ export function parseTokenAccount(data: Uint8Array): TokenAccountInfo {
167
+ if (data.length !== ACCOUNT_SIZE && !(data.length > ACCOUNT_TYPE_OFFSET && data[ACCOUNT_TYPE_OFFSET] === ACCOUNT_TYPE_ACCOUNT)) {
168
+ throw new RangeError('Not a token account')
169
+ }
170
+ const r = new Reader(data)
171
+ return { mint: r.pubkey(), owner: r.pubkey(), amount: r.u64() }
172
+ }
173
+
174
+ export interface MetaplexMetadata extends TokenMetadata {
175
+ updateAuthority: string
176
+ mint: string
177
+ sellerFeeBasisPoints: number
178
+ creators: Array<{ address: string; verified: boolean; share: number }>
179
+ primarySaleHappened: boolean
180
+ isMutable: boolean
181
+ /** 0 NonFungible,1 FungibleAsset,2 Fungible,3 NonFungibleEdition,4 ProgrammableNonFungible,5 ProgrammableNonFungibleEdition */
182
+ tokenStandard: number | null
183
+ collection: { address: string; verified: boolean } | null
184
+ }
185
+
186
+ /** 解析 Metaplex Token Metadata 账户;老版本账户缺少后面的字段时,缺的部分为默认值 */
187
+ export function parseMetaplexMetadata(data: Uint8Array): MetaplexMetadata {
188
+ const r = new Reader(data)
189
+ if (r.u8() !== 4) {
190
+ throw new RangeError('Not a Metaplex metadata account') // Key::MetadataV1 = 4
191
+ }
192
+ const updateAuthority = r.pubkey()
193
+ const mint = r.pubkey()
194
+ const name = r.string()
195
+ const symbol = r.string()
196
+ const uri = r.string()
197
+ const sellerFeeBasisPoints = r.u16()
198
+ const creators =
199
+ r.option(() => {
200
+ const count = r.u32()
201
+ return Array.from({ length: count }, () => ({ address: r.pubkey(), verified: r.u8() === 1, share: r.u8() }))
202
+ }) ?? []
203
+ const result: MetaplexMetadata = {
204
+ updateAuthority,
205
+ mint,
206
+ name,
207
+ symbol,
208
+ uri,
209
+ sellerFeeBasisPoints,
210
+ creators,
211
+ primarySaleHappened: false,
212
+ isMutable: false,
213
+ tokenStandard: null,
214
+ collection: null,
215
+ }
216
+ try {
217
+ result.primarySaleHappened = r.u8() === 1
218
+ result.isMutable = r.u8() === 1
219
+ r.option(() => r.u8()) // editionNonce
220
+ result.tokenStandard = r.option(() => r.u8())
221
+ result.collection = r.option(() => {
222
+ const verified = r.u8() === 1
223
+ return { address: r.pubkey(), verified }
224
+ })
225
+ } catch {
226
+ // 老版本账户没有这些字段
227
+ }
228
+ return result
229
+ }
230
+
231
+ /** 按精度格式化:formatAmount(1_500_000_000n, 9) === '1.5' */
232
+ export function formatAmount(value: bigint, decimals: number): string {
233
+ const negative = value < 0n
234
+ const digits = (negative ? -value : value).toString().padStart(decimals + 1, '0')
235
+ const whole = digits.slice(0, digits.length - decimals)
236
+ const fraction = digits.slice(digits.length - decimals).replace(/0+$/, '')
237
+ return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`
238
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,349 @@
1
+ /** 最小的 JSON-RPC 传输接口:可以传入自己的实现(如包一层 @solana/kit 的 rpc) */
2
+ export interface RpcTransport {
3
+ request<T = unknown>(method: string, params?: readonly unknown[]): Promise<T>
4
+ }
5
+
6
+ /** 节点返回的 JSON-RPC 错误 */
7
+ export class RpcError extends Error {
8
+ readonly code: number | undefined
9
+ readonly data: unknown
10
+ /** 是否是节点自身的限制(限频、需要 API Key、禁止访问等),换节点可能成功 */
11
+ readonly nodeFault: boolean
12
+
13
+ constructor(message: string, code?: number, data?: unknown) {
14
+ super(message)
15
+ this.name = 'RpcError'
16
+ this.code = code
17
+ this.data = data
18
+ this.nodeFault =
19
+ code === 403 ||
20
+ code === 429 ||
21
+ code === -32005 ||
22
+ /forbidden|blocked|personal token|api.?key|rate.?limit|too many|not allowed|unauthori[sz]ed|limit exceeded|disabled|not available|not supported/i.test(message)
23
+ }
24
+ }
25
+
26
+ /** HTTP 层错误(非 2xx、网络错误、超时) */
27
+ export class HttpError extends Error {
28
+ readonly status: number | undefined
29
+
30
+ constructor(message: string, status?: number) {
31
+ super(message)
32
+ this.name = 'HttpError'
33
+ this.status = status
34
+ }
35
+ }
36
+
37
+ /** 是否是节点问题(应换节点重试):除了确定性的 JSON-RPC 错误(如参数错误),其余都算 */
38
+ export function isNodeFault(err: unknown): boolean {
39
+ if (err instanceof RpcError) {
40
+ return err.nodeFault
41
+ }
42
+ return true
43
+ }
44
+
45
+ export interface HttpRpcOptions {
46
+ /** 单次 HTTP 请求超时(毫秒),<= 0 不限制。默认 10000 */
47
+ timeout?: number
48
+ headers?: Record<string, string>
49
+ /** 一个 HTTP 批量请求最多包含的调用数。默认 20 */
50
+ maxBatchSize?: number
51
+ /** 是否合并成 JSON-RPC 批量请求。默认 true;节点不支持时会自动关闭 */
52
+ batch?: boolean
53
+ /** 收集窗口(毫秒),窗口内的调用合并成一个 HTTP 请求。默认 0(同一 tick) */
54
+ batchWait?: number
55
+ /** 遇到 HTTP 429 时的重试次数(指数退避,从 500ms 开始)。默认 2 */
56
+ retries?: number
57
+ /** 自定义 fetch(如需要代理时) */
58
+ fetch?: typeof fetch
59
+ }
60
+
61
+ interface Pending {
62
+ method: string
63
+ params: readonly unknown[]
64
+ resolve: (value: unknown) => void
65
+ reject: (reason: unknown) => void
66
+ }
67
+
68
+ interface RpcResponse {
69
+ id?: number | string | null
70
+ result?: unknown
71
+ error?: { code?: number; message?: string; data?: unknown }
72
+ }
73
+
74
+ let nextId = 1
75
+
76
+ /** 批量请求被拒绝、可以降级为逐条请求的 HTTP 状态 */
77
+ const BATCH_REJECTED_STATUS = new Set([400, 404, 405, 415, 422])
78
+
79
+ function isBatchLimitError(message: string | undefined): boolean {
80
+ return !!message && /batch/i.test(message) && /more than \d+|too (large|many|big)|not (allowed|supported)|exceed/i.test(message)
81
+ }
82
+
83
+ /**
84
+ * 基于 fetch 的 JSON-RPC 传输:同一收集窗口内的调用合并成一个 JSON-RPC 批量请求。
85
+ * 节点不支持批量(返回非数组)或限制批量大小(如 drpc 免费档最多 3 条)时自动降级。
86
+ */
87
+ export class HttpRpc implements RpcTransport {
88
+ readonly url: string
89
+ readonly #timeout: number
90
+ readonly #headers: Record<string, string>
91
+ readonly #batchWait: number
92
+ readonly #retries: number
93
+ readonly #fetch: typeof fetch
94
+ #maxBatchSize: number
95
+ #batch: boolean
96
+ #queue: Pending[] = []
97
+ #timer: ReturnType<typeof setTimeout> | null = null
98
+
99
+ constructor(url: string, options: HttpRpcOptions = {}) {
100
+ this.url = url
101
+ this.#timeout = options.timeout ?? 10_000
102
+ this.#headers = { 'content-type': 'application/json', ...options.headers }
103
+ this.#maxBatchSize = Math.max(1, options.maxBatchSize ?? 20)
104
+ this.#batch = options.batch ?? true
105
+ this.#batchWait = options.batchWait ?? 0
106
+ this.#retries = Math.max(0, options.retries ?? 2)
107
+ this.#fetch = options.fetch ?? ((...args) => fetch(...args))
108
+ }
109
+
110
+ request<T = unknown>(method: string, params: readonly unknown[] = []): Promise<T> {
111
+ return new Promise<T>((resolve, reject) => {
112
+ this.#queue.push({ method, params, resolve: resolve as (value: unknown) => void, reject })
113
+ if (!this.#batch || this.#queue.length >= this.#maxBatchSize) {
114
+ this.#flush()
115
+ } else if (!this.#timer) {
116
+ this.#timer = setTimeout(() => this.#flush(), this.#batchWait)
117
+ }
118
+ })
119
+ }
120
+
121
+ #flush(): void {
122
+ if (this.#timer) {
123
+ clearTimeout(this.#timer)
124
+ this.#timer = null
125
+ }
126
+ const queue = this.#queue
127
+ this.#queue = []
128
+ for (let i = 0; i < queue.length; i += this.#maxBatchSize) {
129
+ void this.#dispatch(queue.slice(i, i + this.#maxBatchSize))
130
+ }
131
+ }
132
+
133
+ async #dispatch(items: Pending[]): Promise<void> {
134
+ if (items.length === 1 || !this.#batch) {
135
+ await Promise.all(items.map((item) => this.#single(item)))
136
+ return
137
+ }
138
+ const ids = items.map(() => nextId++)
139
+ let body: unknown
140
+ try {
141
+ body = await this.#post(items.map((item, i) => ({ jsonrpc: '2.0', id: ids[i], method: item.method, params: item.params })))
142
+ } catch (err) {
143
+ if (err instanceof HttpError && err.status === 413 && items.length > 1) {
144
+ // 请求体太大:批量减半后重发
145
+ this.#maxBatchSize = Math.max(1, Math.floor(items.length / 2))
146
+ await this.#redispatch(items)
147
+ return
148
+ }
149
+ // 400 / 404 / 405 / 415 / 422 等:节点多半不接受批量请求,降级为逐条请求
150
+ // (401 / 403 / 429 / 5xx 是鉴权、限频或节点本身的问题,降级没有用,原样报错)
151
+ if (err instanceof HttpError && err.status !== undefined && BATCH_REJECTED_STATUS.has(err.status)) {
152
+ this.#disableBatch()
153
+ await Promise.all(items.map((item) => this.#single(item)))
154
+ return
155
+ }
156
+ items.forEach((item) => item.reject(err))
157
+ return
158
+ }
159
+ if (!Array.isArray(body)) {
160
+ const error = (body as RpcResponse)?.error
161
+ const rpcError = new RpcError(error?.message ?? 'Invalid JSON-RPC batch response', error?.code, error?.data)
162
+ // 限频 / 鉴权等节点问题:原样报错,不降级(降级成逐条请求只会让限频更严重)
163
+ if (rpcError.nodeFault) {
164
+ items.forEach((item) => item.reject(rpcError))
165
+ return
166
+ }
167
+ // 其他(如 -32600 invalid request):节点不支持批量,降级为逐条请求
168
+ this.#disableBatch()
169
+ await Promise.all(items.map((item) => this.#single(item)))
170
+ return
171
+ }
172
+ const byId = new Map((body as RpcResponse[]).map((res) => [res.id, res]))
173
+ const limited = (body as RpcResponse[]).find((res) => isBatchLimitError(res.error?.message))
174
+ if (limited) {
175
+ // 如 “Batch of more than 3 requests are not allowed”:按限制缩小批量后重发;
176
+ // 限制不小于当前批量(节点的上限判断有偏差)或解析不出数字时,降级为逐条请求,避免无限重发
177
+ const max = Number(/more than (\d+)/i.exec(limited.error?.message ?? '')?.[1])
178
+ if (Number.isFinite(max) && max > 1 && max < items.length) {
179
+ this.#maxBatchSize = max
180
+ await this.#redispatch(items)
181
+ } else {
182
+ this.#disableBatch()
183
+ await Promise.all(items.map((item) => this.#single(item)))
184
+ }
185
+ return
186
+ }
187
+ // 批量里个别调用被限频(JSON-RPC 429):只把这些调用退避后单独重试
188
+ const limitedItems: Pending[] = []
189
+ items.forEach((item, i) => {
190
+ const response = byId.get(ids[i])
191
+ if (response?.error?.code === 429 && this.#retries > 0) {
192
+ limitedItems.push(item)
193
+ } else {
194
+ settle(item, response)
195
+ }
196
+ })
197
+ if (limitedItems.length) {
198
+ await new Promise((r) => setTimeout(r, 500))
199
+ await Promise.all(limitedItems.map((item) => this.#single(item)))
200
+ }
201
+ }
202
+
203
+ /** 按当前的 maxBatchSize 重新分批发送(每批都比原来小,不会无限循环) */
204
+ async #redispatch(items: Pending[]): Promise<void> {
205
+ const size = this.#maxBatchSize
206
+ const chunks: Pending[][] = []
207
+ for (let i = 0; i < items.length; i += size) {
208
+ chunks.push(items.slice(i, i + size))
209
+ }
210
+ await Promise.all(chunks.map((chunk) => this.#dispatch(chunk)))
211
+ }
212
+
213
+ async #single(item: Pending): Promise<void> {
214
+ try {
215
+ const body = (await this.#post({ jsonrpc: '2.0', id: nextId++, method: item.method, params: item.params })) as RpcResponse
216
+ settle(item, body)
217
+ } catch (err) {
218
+ item.reject(err)
219
+ }
220
+ }
221
+
222
+ #disableBatch(): void {
223
+ this.#batch = false
224
+ }
225
+
226
+ async #post(payload: unknown): Promise<unknown> {
227
+ for (let attempt = 0; ; attempt++) {
228
+ let response: Response
229
+ try {
230
+ response = await this.#fetch(this.url, {
231
+ method: 'POST',
232
+ headers: this.#headers,
233
+ body: JSON.stringify(payload),
234
+ signal: this.#timeout > 0 && Number.isFinite(this.#timeout) ? AbortSignal.timeout(this.#timeout) : undefined,
235
+ })
236
+ } catch (err) {
237
+ throw new HttpError(`Request to ${this.url} failed: ${(err as Error)?.message ?? err}`)
238
+ }
239
+ if (response.status === 429 && attempt < this.#retries) {
240
+ await new Promise((r) => setTimeout(r, 500 * 2 ** attempt))
241
+ continue
242
+ }
243
+ const text = await response.text()
244
+ let body: unknown
245
+ try {
246
+ body = parseJson(text)
247
+ } catch {
248
+ throw new HttpError(`Invalid JSON from ${this.url} (HTTP ${response.status})`, response.status)
249
+ }
250
+ // 非 2xx 但返回了 JSON-RPC 结构(如 403 Access forbidden、drpc 的 500 + 批量错误数组):交给上层解析
251
+ if (!response.ok && !Array.isArray(body) && !(body as RpcResponse)?.error) {
252
+ throw new HttpError(`HTTP ${response.status} from ${this.url}`, response.status)
253
+ }
254
+ // 有的节点限频时返回 HTTP 200 + JSON-RPC 错误码 429:同样退避重试
255
+ if (attempt < this.#retries && isRateLimited(body)) {
256
+ await new Promise((r) => setTimeout(r, 500 * 2 ** attempt))
257
+ continue
258
+ }
259
+ return body
260
+ }
261
+ }
262
+ }
263
+
264
+ // lamports 等 u64 以 JSON 数字返回,超过 2^53(约 900 万 SOL)时 JSON.parse 会丢精度:
265
+ // 支持 JSON.parse source text access 的环境(Node 21+、新版浏览器)直接取原文;否则先把超长整数改成字符串再解析
266
+ let sourceAccess: boolean | undefined
267
+
268
+ function parseJson(text: string): unknown {
269
+ sourceAccess ??= (() => {
270
+ let supported = false
271
+ JSON.parse('1', (_key, value, context?: { source?: string }) => {
272
+ supported = typeof context?.source === 'string'
273
+ return value
274
+ })
275
+ return supported
276
+ })()
277
+ if (sourceAccess) {
278
+ return JSON.parse(text, (_key, value: unknown, context?: { source?: string }) =>
279
+ typeof value === 'number' && !Number.isSafeInteger(value) && Number.isInteger(value) && context?.source ? context.source : value,
280
+ )
281
+ }
282
+ return JSON.parse(text.replace(/([:[,]\s*)(-?\d{16,})(?=\s*[,}\]])/g, '$1"$2"'))
283
+ }
284
+
285
+ function isRateLimited(body: unknown): boolean {
286
+ const responses = (Array.isArray(body) ? body : [body]) as RpcResponse[]
287
+ return responses.length > 0 && responses.every((res) => res?.error?.code === 429)
288
+ }
289
+
290
+ function settle(item: Pending, response: RpcResponse | undefined): void {
291
+ if (!response) {
292
+ item.reject(new HttpError('Missing response in JSON-RPC batch'))
293
+ } else if (response.error) {
294
+ item.reject(new RpcError(response.error.message ?? 'JSON-RPC error', response.error.code, response.error.data))
295
+ } else {
296
+ item.resolve(response.result)
297
+ }
298
+ }
299
+
300
+ export interface FallbackOptions {
301
+ /** 出错节点的冷却时间(毫秒),期间排到最后。默认 30000 */
302
+ cooldown?: number
303
+ }
304
+
305
+ /**
306
+ * 多节点故障切换:按顺序使用,节点问题(网络、超时、限频、需要 Key、403 等)换下一个;
307
+ * 参数错误等确定性错误直接抛出。出错的节点在冷却期内排到最后。
308
+ */
309
+ export class FallbackRpc implements RpcTransport {
310
+ readonly nodes: readonly RpcTransport[]
311
+ readonly #cooldown: number
312
+ readonly #failedAt: number[]
313
+
314
+ constructor(nodes: readonly RpcTransport[], options: FallbackOptions = {}) {
315
+ if (!nodes.length) {
316
+ throw new Error('FallbackRpc requires at least one node')
317
+ }
318
+ this.nodes = nodes
319
+ this.#cooldown = options.cooldown ?? 30_000
320
+ this.#failedAt = nodes.map(() => 0)
321
+ }
322
+
323
+ async request<T = unknown>(method: string, params?: readonly unknown[]): Promise<T> {
324
+ let lastError: unknown
325
+ for (const index of this.#order()) {
326
+ try {
327
+ const result = await (this.nodes[index] as RpcTransport).request<T>(method, params)
328
+ this.#failedAt[index] = 0
329
+ return result
330
+ } catch (err) {
331
+ if (!isNodeFault(err)) {
332
+ throw err
333
+ }
334
+ this.#failedAt[index] = Date.now()
335
+ lastError = err
336
+ }
337
+ }
338
+ throw lastError
339
+ }
340
+
341
+ #order(): number[] {
342
+ const now = Date.now()
343
+ const healthy: number[] = []
344
+ const cooling: number[] = []
345
+ this.#failedAt.forEach((at, i) => (at && now - at < this.#cooldown ? cooling : healthy).push(i))
346
+ cooling.sort((a, b) => (this.#failedAt[a] as number) - (this.#failedAt[b] as number))
347
+ return [...healthy, ...cooling]
348
+ }
349
+ }