@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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +169 -2
- package/dist/address.d.ts +14 -0
- package/dist/address.d.ts.map +1 -0
- package/dist/address.js +81 -0
- package/dist/address.js.map +1 -0
- package/dist/client.d.ts +96 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +575 -0
- package/dist/client.js.map +1 -0
- package/dist/constants.d.ts +21 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +35 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +55 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +177 -0
- package/dist/layout.js.map +1 -0
- package/dist/rpc.d.ts +59 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +295 -0
- package/dist/rpc.js.map +1 -0
- package/dist/shortcuts.d.ts +60 -0
- package/dist/shortcuts.d.ts.map +1 -0
- package/dist/shortcuts.js +136 -0
- package/dist/shortcuts.js.map +1 -0
- package/dist/source.d.ts +35 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +126 -0
- package/dist/source.js.map +1 -0
- package/dist/types.d.ts +104 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +56 -3
- package/src/address.ts +92 -0
- package/src/client.ts +753 -0
- package/src/constants.ts +41 -0
- package/src/index.ts +54 -0
- package/src/layout.ts +238 -0
- package/src/rpc.ts +349 -0
- package/src/shortcuts.ts +173 -0
- package/src/source.ts +151 -0
- package/src/types.ts +108 -0
package/src/constants.ts
ADDED
|
@@ -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
|
+
}
|