@w3lib/solana-batch-call 0.6.0 → 0.7.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 (165) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +16 -8
  3. package/dist/accountReader.d.ts +57 -0
  4. package/dist/accountReader.d.ts.map +1 -0
  5. package/dist/accountReader.js +149 -0
  6. package/dist/accountReader.js.map +1 -0
  7. package/dist/address.d.ts +9 -0
  8. package/dist/address.d.ts.map +1 -1
  9. package/dist/address.js +36 -6
  10. package/dist/address.js.map +1 -1
  11. package/dist/approvals.d.ts +5 -3
  12. package/dist/approvals.d.ts.map +1 -1
  13. package/dist/approvals.js +25 -36
  14. package/dist/approvals.js.map +1 -1
  15. package/dist/balances.d.ts +178 -0
  16. package/dist/balances.d.ts.map +1 -0
  17. package/dist/balances.js +524 -0
  18. package/dist/balances.js.map +1 -0
  19. package/dist/batchPlan.d.ts +2 -2
  20. package/dist/batchPlan.d.ts.map +1 -1
  21. package/dist/batchPlan.js +5 -26
  22. package/dist/batchPlan.js.map +1 -1
  23. package/dist/bytes.d.ts +31 -0
  24. package/dist/bytes.d.ts.map +1 -0
  25. package/dist/bytes.js +74 -0
  26. package/dist/bytes.js.map +1 -0
  27. package/dist/claim.d.ts +5 -3
  28. package/dist/claim.d.ts.map +1 -1
  29. package/dist/claim.js +10 -10
  30. package/dist/claim.js.map +1 -1
  31. package/dist/client.d.ts +26 -41
  32. package/dist/client.d.ts.map +1 -1
  33. package/dist/client.js +41 -986
  34. package/dist/client.js.map +1 -1
  35. package/dist/clientConfig.d.ts +11 -0
  36. package/dist/clientConfig.d.ts.map +1 -0
  37. package/dist/clientConfig.js +3 -0
  38. package/dist/clientConfig.js.map +1 -0
  39. package/dist/decode.d.ts.map +1 -1
  40. package/dist/decode.js +2 -29
  41. package/dist/decode.js.map +1 -1
  42. package/dist/fees.d.ts.map +1 -1
  43. package/dist/fees.js +2 -12
  44. package/dist/fees.js.map +1 -1
  45. package/dist/history.d.ts.map +1 -1
  46. package/dist/history.js +4 -15
  47. package/dist/history.js.map +1 -1
  48. package/dist/index.d.ts +3 -3
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/layout.d.ts +21 -0
  53. package/dist/layout.d.ts.map +1 -1
  54. package/dist/layout.js +50 -69
  55. package/dist/layout.js.map +1 -1
  56. package/dist/liquidity/common.js +2 -2
  57. package/dist/liquidity/common.js.map +1 -1
  58. package/dist/lp.d.ts +9 -13
  59. package/dist/lp.d.ts.map +1 -1
  60. package/dist/lp.js +67 -55
  61. package/dist/lp.js.map +1 -1
  62. package/dist/portfolio.d.ts.map +1 -1
  63. package/dist/portfolio.js +3 -8
  64. package/dist/portfolio.js.map +1 -1
  65. package/dist/reclaim.d.ts +4 -2
  66. package/dist/reclaim.d.ts.map +1 -1
  67. package/dist/reclaim.js +16 -16
  68. package/dist/reclaim.js.map +1 -1
  69. package/dist/risk.js +2 -2
  70. package/dist/risk.js.map +1 -1
  71. package/dist/rpc.d.ts +30 -7
  72. package/dist/rpc.d.ts.map +1 -1
  73. package/dist/rpc.js +400 -109
  74. package/dist/rpc.js.map +1 -1
  75. package/dist/simulate.d.ts.map +1 -1
  76. package/dist/simulate.js +3 -26
  77. package/dist/simulate.js.map +1 -1
  78. package/dist/source.d.ts +8 -4
  79. package/dist/source.d.ts.map +1 -1
  80. package/dist/source.js +11 -4
  81. package/dist/source.js.map +1 -1
  82. package/dist/subpaths/approvals.d.ts +3 -3
  83. package/dist/subpaths/approvals.d.ts.map +1 -1
  84. package/dist/subpaths/approvals.js.map +1 -1
  85. package/dist/subpaths/claim.d.ts +3 -3
  86. package/dist/subpaths/claim.d.ts.map +1 -1
  87. package/dist/subpaths/claim.js.map +1 -1
  88. package/dist/subpaths/reclaim.d.ts +3 -3
  89. package/dist/subpaths/reclaim.d.ts.map +1 -1
  90. package/dist/subpaths/reclaim.js.map +1 -1
  91. package/dist/swap/index.js +3 -3
  92. package/dist/swap/index.js.map +1 -1
  93. package/dist/tokenDetails.d.ts +12 -0
  94. package/dist/tokenDetails.d.ts.map +1 -0
  95. package/dist/tokenDetails.js +196 -0
  96. package/dist/tokenDetails.js.map +1 -0
  97. package/dist/tokenMeta.d.ts +63 -0
  98. package/dist/tokenMeta.d.ts.map +1 -0
  99. package/dist/tokenMeta.js +226 -0
  100. package/dist/tokenMeta.js.map +1 -0
  101. package/dist/tradeCheck.js +3 -3
  102. package/dist/tradeCheck.js.map +1 -1
  103. package/dist/util.d.ts +22 -0
  104. package/dist/util.d.ts.map +1 -0
  105. package/dist/util.js +107 -0
  106. package/dist/util.js.map +1 -0
  107. package/dist/write/chain.d.ts +51 -0
  108. package/dist/write/chain.d.ts.map +1 -0
  109. package/dist/write/chain.js +184 -0
  110. package/dist/write/chain.js.map +1 -0
  111. package/dist/write/collect.js +2 -2
  112. package/dist/write/collect.js.map +1 -1
  113. package/dist/write/errors.d.ts +10 -0
  114. package/dist/write/errors.d.ts.map +1 -0
  115. package/dist/write/errors.js +77 -0
  116. package/dist/write/errors.js.map +1 -0
  117. package/dist/write/pack.d.ts +28 -0
  118. package/dist/write/pack.d.ts.map +1 -0
  119. package/dist/write/pack.js +125 -0
  120. package/dist/write/pack.js.map +1 -0
  121. package/dist/write/send.d.ts +23 -9
  122. package/dist/write/send.d.ts.map +1 -1
  123. package/dist/write/send.js +53 -351
  124. package/dist/write/send.js.map +1 -1
  125. package/dist/write/tracker.d.ts +24 -0
  126. package/dist/write/tracker.d.ts.map +1 -0
  127. package/dist/write/tracker.js +129 -0
  128. package/dist/write/tracker.js.map +1 -0
  129. package/package.json +1 -1
  130. package/src/accountReader.ts +187 -0
  131. package/src/address.ts +41 -6
  132. package/src/approvals.ts +36 -44
  133. package/src/balances.ts +678 -0
  134. package/src/batchPlan.ts +6 -26
  135. package/src/bytes.ts +89 -0
  136. package/src/claim.ts +15 -11
  137. package/src/client.ts +59 -1239
  138. package/src/clientConfig.ts +12 -0
  139. package/src/decode.ts +4 -30
  140. package/src/fees.ts +2 -9
  141. package/src/history.ts +4 -15
  142. package/src/index.ts +13 -1
  143. package/src/layout.ts +63 -82
  144. package/src/liquidity/common.ts +2 -2
  145. package/src/lp.ts +86 -60
  146. package/src/portfolio.ts +8 -11
  147. package/src/reclaim.ts +20 -17
  148. package/src/risk.ts +2 -2
  149. package/src/rpc.ts +472 -116
  150. package/src/simulate.ts +3 -21
  151. package/src/source.ts +14 -5
  152. package/src/subpaths/approvals.ts +3 -2
  153. package/src/subpaths/claim.ts +3 -2
  154. package/src/subpaths/reclaim.ts +3 -2
  155. package/src/swap/index.ts +3 -3
  156. package/src/tokenDetails.ts +240 -0
  157. package/src/tokenMeta.ts +270 -0
  158. package/src/tradeCheck.ts +3 -3
  159. package/src/util.ts +110 -0
  160. package/src/write/chain.ts +218 -0
  161. package/src/write/collect.ts +2 -2
  162. package/src/write/errors.ts +82 -0
  163. package/src/write/pack.ts +142 -0
  164. package/src/write/send.ts +74 -407
  165. package/src/write/tracker.ts +152 -0
package/src/write/send.ts CHANGED
@@ -1,12 +1,19 @@
1
- import { sha256 } from '@noble/hashes/sha2.js'
2
1
  import { base58, base64 } from '@scure/base'
3
2
 
4
3
  import type { SolanaClient } from '../client.js'
5
- import { SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../constants.js'
6
- import { RpcError, isNodeFault } from '../rpc.js'
7
- import { MAX_COMPUTE_UNITS, setComputeUnitLimit, setComputeUnitPrice } from './instructions.js'
4
+ import type { RpcSource } from '../source.js'
5
+ import { mapPool } from '../util.js'
6
+ import { Chain, isRecentMessage, remember, type Blockhash } from './chain.js'
7
+ import { errorText, isBudgetExceeded, isRejection, transactionErrorText } from './errors.js'
8
+ import { MAX_COMPUTE_UNITS } from './instructions.js'
9
+ import { budgetInstructions, packUnits } from './pack.js'
8
10
  import type { LocalSigner, ResolvedSigner, SignerInput, WalletSigner } from './signer.js'
9
- import { MAX_ACCOUNT_LOCKS, PACKET_DATA_SIZE, compileMessage, messageBlockhash, sameBytes, serializeTransaction, transactionSize, type CompiledMessage, type Instruction } from './transaction.js'
11
+ import { MAX_ACCOUNT_LOCKS, PACKET_DATA_SIZE, compileMessage, messageBlockhash, sameBytes, serializeTransaction, type CompiledMessage, type Instruction } from './transaction.js'
12
+ import { Tracker } from './tracker.js'
13
+
14
+ // 拆出去的部分仍从这里导出(包内其他模块和外部引用的路径不变)
15
+ export { priorityFeeFor } from './chain.js'
16
+ export { transactionErrorText } from './errors.js'
10
17
 
11
18
  /**
12
19
  * 每个调用的状态:
@@ -52,6 +59,23 @@ export interface SendOptions {
52
59
  signal?: AbortSignal
53
60
  /** 每个结果状态变化时回调,用于展示进度 */
54
61
  onUpdate?: (result: WriteResult) => void
62
+ /**
63
+ * 发送交易用的节点(如专门的发送服务、质押连接的节点):URL、URL 数组、web3.js Connection 或自定义传输。
64
+ * 不传时用 client 的节点;模拟、查确认状态仍用 client
65
+ */
66
+ sendRpc?: RpcSource | readonly RpcSource[]
67
+ /**
68
+ * 广播给哪些节点(sendRpc,不传时 client 的节点):
69
+ * - 'first'(默认):按故障切换顺序只发给第一个可用的节点
70
+ * - 'all':同时发给所有节点,任一接受即可(多个节点各自转发给出块节点,拥堵时更容易上链;等待确认期间的重发也会发给所有节点)。
71
+ * 默认 'first':公共节点对 sendTransaction 限频严,每两次轮询就重发一次时,全部广播会成倍增加请求;官方节点在浏览器里总是 403,发给它只是白费请求
72
+ */
73
+ broadcast?: 'first' | 'all'
74
+ /**
75
+ * 计算单元上限 = 模拟消耗 × ratio + extra(默认 ratio 1.1、extra 1000),不超过 140 万。
76
+ * 消耗会随链上状态变化的调用(如兑换穿过的 tick 数不同)可以调大,避免上链时超出上限失败;固定手续费的调用(扫空 SOL)不受影响
77
+ */
78
+ computeUnitMargin?: { ratio?: number; extra?: number }
55
79
  }
56
80
 
57
81
  /** 每个调用的结果,都是字符串 / 数字 / null,可以直接 JSON.stringify */
@@ -113,36 +137,6 @@ export function toWriteResult(index: number, from: string, outcome: TxOutcome):
113
137
  }
114
138
  }
115
139
 
116
- /** 并发数为 limit 的 map,结果按输入顺序 */
117
- export async function mapPool<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
118
- const results = new Array<R>(items.length)
119
- let next = 0
120
- const worker = async () => {
121
- while (next < items.length) {
122
- const i = next++
123
- results[i] = await fn(items[i] as T, i)
124
- }
125
- }
126
- await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker))
127
- return results
128
- }
129
-
130
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
131
- return new Promise((resolve) => {
132
- if (signal?.aborted) {
133
- resolve()
134
- return
135
- }
136
- const timer = setTimeout(done, ms)
137
- function done() {
138
- clearTimeout(timer)
139
- signal?.removeEventListener('abort', done)
140
- resolve()
141
- }
142
- signal?.addEventListener('abort', done, { once: true })
143
- })
144
- }
145
-
146
140
  /** 每个签名的基础手续费(lamports) */
147
141
  export const LAMPORTS_PER_SIGNATURE = 5000n
148
142
 
@@ -151,358 +145,8 @@ export function transactionFee(signatures: number, computeUnits: number, price:
151
145
  return LAMPORTS_PER_SIGNATURE * BigInt(signatures) + (price * BigInt(computeUnits) + 999_999n) / 1_000_000n
152
146
  }
153
147
 
154
- function errorText(err: unknown): string {
155
- return err instanceof Error ? err.message : String(err)
156
- }
157
-
158
- // ---- 交易错误 ----
159
-
160
- interface SimulationValue {
161
- err: unknown
162
- logs?: string[] | null
163
- unitsConsumed?: number
164
- }
165
-
166
- const TX_ERRORS: Record<string, string> = {
167
- InsufficientFundsForFee: 'insufficient SOL for the transaction fee',
168
- AccountNotFound: 'fee payer account not found (it has no SOL)',
169
- BlockhashNotFound: 'blockhash not found',
170
- AlreadyProcessed: 'transaction already processed',
171
- }
172
-
173
- /**
174
- * 日志里最有用的一行:程序打印的 “Program log: Error: …”,其次是系统程序直接打印的说明(如 “Transfer: insufficient lamports …”,
175
- * 不以 “Program ” 开头),最后才是运行时的 “Program … failed: custom program error: 0x1”
176
- */
177
- function logDetail(logs: readonly string[]): string | undefined {
178
- const reversed = [...logs].reverse()
179
- const programError = reversed.find((line) => /^Program log: (Error|error)/.test(line))
180
- if (programError) return programError.replace(/^Program log: /, '')
181
- const plain = reversed.find((line) => !line.startsWith('Program '))
182
- if (plain) return plain
183
- return reversed.find((line) => / failed: /.test(line))?.replace(/^Program \w+ failed: /, '')
184
- }
185
-
186
- // 常见程序的自定义错误码(上链失败时没有日志,只有错误码)
187
- const SYSTEM_ERRORS = ['account already in use', 'insufficient lamports', 'invalid program id', 'invalid account data length']
188
- const TOKEN_ERRORS: Record<number, string> = {
189
- 0: 'account not rent-exempt',
190
- 1: 'insufficient funds',
191
- 2: 'invalid mint',
192
- 3: 'account not associated with this mint',
193
- 4: 'owner does not match',
194
- 9: 'account is not initialized',
195
- 11: 'non-native account can only be closed if its balance is zero',
196
- 17: 'account is frozen',
197
- 18: 'mint decimals mismatch',
198
- }
199
-
200
- function customErrorText(program: string | undefined, code: number): string | undefined {
201
- if (program === SYSTEM_PROGRAM_ID) return SYSTEM_ERRORS[code]
202
- if (program === TOKEN_PROGRAM_ID || program === TOKEN_2022_PROGRAM_ID) return TOKEN_ERRORS[code]
203
- return undefined
204
- }
205
-
206
- /**
207
- * 交易错误的可读说明:优先取日志(见 logDetail),否则按错误类型;
208
- * programs 是每条指令的程序(按下标),用来翻译常见程序的自定义错误码
209
- */
210
- export function transactionErrorText(err: unknown, logs?: readonly string[] | null, programs?: readonly string[]): string {
211
- const detail = logDetail(logs ?? [])
212
- if (typeof err === 'string') {
213
- return TX_ERRORS[err] ?? err
214
- }
215
- const instruction = (err as { InstructionError?: [number, unknown] } | null)?.InstructionError
216
- if (instruction) {
217
- const [index, reason] = instruction
218
- const custom = (reason as { Custom?: number } | null)?.Custom
219
- const known = custom === undefined ? undefined : customErrorText(programs?.[index], custom)
220
- return detail ?? known ?? (typeof reason === 'string' ? reason : JSON.stringify(reason))
221
- }
222
- if ((err as { InsufficientFundsForRent?: unknown } | null)?.InsufficientFundsForRent) {
223
- return 'account would be left with less SOL than the rent-exempt minimum'
224
- }
225
- return detail ?? JSON.stringify(err)
226
- }
227
-
228
- function isBudgetExceeded(err: unknown, logs?: readonly string[] | null): boolean {
229
- const reason = (err as { InstructionError?: [number, unknown] } | null)?.InstructionError?.[1]
230
- return reason === 'ComputationalBudgetExceeded' || (logs ?? []).some((l) => /exceeded CUs meter|compute units/i.test(l) && /exceed/i.test(l))
231
- }
232
-
233
- // ---- 节点访问 ----
234
-
235
- /**
236
- * 最近发出过的交易消息(sha256):同样的消息签名也相同,再发一次只会被节点当成同一笔。
237
- * 在签名之前检查(钱包签名的交易也适用),重复时调整计算单元上限让消息不同
238
- */
239
- const recentMessages = new Set<string>()
240
-
241
- function messageKey(message: Uint8Array): string {
242
- return base64.encode(sha256(message))
243
- }
244
-
245
- function remember(message: Uint8Array): void {
246
- recentMessages.add(messageKey(message))
247
- if (recentMessages.size > 10_000) {
248
- recentMessages.delete(recentMessages.values().next().value as string)
249
- }
250
- }
251
-
252
- interface Blockhash {
253
- blockhash: string
254
- lastValidBlockHeight: number
255
- }
256
-
257
- /** 一批发送共用的节点访问:blockhash(缓存 10 秒)、优先费用(按账户缓存 10 秒) */
258
- class Chain {
259
- #blockhash: { at: number; value: Promise<Blockhash> } | null = null
260
- readonly #fees = new Map<string, { at: number; value: Promise<bigint> }>()
261
-
262
- constructor(
263
- readonly client: SolanaClient,
264
- readonly options: SendOptions,
265
- ) {}
266
-
267
- get commitment(): 'confirmed' | 'finalized' {
268
- return this.options.commitment ?? 'confirmed'
269
- }
270
-
271
- #walletChain: Promise<string | undefined> | null = null
272
-
273
- /** Wallet Standard 的链标识('solana:mainnet' 等);网络未知时为 undefined */
274
- walletChain(): Promise<string | undefined> {
275
- this.#walletChain ??= this.client.getCluster().then(
276
- (cluster) => (cluster ? `solana:${cluster}` : undefined),
277
- () => undefined,
278
- )
279
- return this.#walletChain
280
- }
281
-
282
- blockhash(fresh = false): Promise<Blockhash> {
283
- if (fresh || !this.#blockhash || Date.now() - this.#blockhash.at > 10_000) {
284
- const value = this.client
285
- .request<{ value: Blockhash }>('getLatestBlockhash', [{ commitment: 'confirmed' }])
286
- .then((res) => res.value)
287
- value.catch(() => {
288
- if (this.#blockhash?.value === value) this.#blockhash = null
289
- })
290
- this.#blockhash = { at: Date.now(), value }
291
- }
292
- return this.#blockhash.value
293
- }
294
-
295
- async priorityFee(writable: readonly string[]): Promise<bigint> {
296
- const option = this.options.priorityFee ?? 'auto'
297
- if (option !== 'auto') {
298
- return BigInt(option)
299
- }
300
- const accounts = [...writable].sort().slice(0, 128)
301
- const key = accounts.join(',')
302
- let cached = this.#fees.get(key)
303
- if (!cached || Date.now() - cached.at > 10_000) {
304
- const cap = BigInt(this.options.maxPriorityFee ?? 1_000_000)
305
- const value = this.client
306
- .request<Array<{ prioritizationFee: number }>>('getRecentPrioritizationFees', [accounts])
307
- .then((list) => {
308
- const fees = list.map((f) => BigInt(f.prioritizationFee)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
309
- const p75 = fees.length ? (fees[Math.min(fees.length - 1, Math.floor(fees.length * 0.75))] as bigint) : 0n
310
- return p75 < cap ? p75 : cap
311
- })
312
- .catch(() => 0n) // 节点不支持时不加优先费用
313
- cached = { at: Date.now(), value }
314
- this.#fees.set(key, cached)
315
- }
316
- return cached.value
317
- }
318
-
319
- async simulate(message: CompiledMessage, minContextSlot: number | undefined): Promise<SimulationValue> {
320
- const raw = base64.encode(serializeTransaction(message, message.signers.map(() => null)))
321
- const config: Record<string, unknown> = { encoding: 'base64', sigVerify: false, replaceRecentBlockhash: true, commitment: 'confirmed' }
322
- if (minContextSlot !== undefined) {
323
- config.minContextSlot = minContextSlot
324
- }
325
- // 节点暂时的问题(限频、高度还没到 minContextSlot 等)重试几次,不当成调用失败
326
- for (let attempt = 0; ; attempt++) {
327
- try {
328
- const res = await this.client.request<{ value: SimulationValue }>('simulateTransaction', [raw, config])
329
- return res.value
330
- } catch (err) {
331
- if (attempt >= 3 || (err instanceof RpcError && !isNodeFault(err))) throw err
332
- await sleep(500 * (attempt + 1), this.options.signal)
333
- }
334
- }
335
- }
336
-
337
- /** 广播:'accepted';节点明确拒绝时抛出;节点都连不上时返回错误说明(交易可能已经发出) */
338
- async broadcast(raw: string): Promise<'accepted' | { uncertain: string }> {
339
- try {
340
- await this.client.request('sendTransaction', [raw, { encoding: 'base64', skipPreflight: true, maxRetries: 0 }])
341
- return 'accepted'
342
- } catch (err) {
343
- if (/already (been )?processed/i.test(errorText(err))) {
344
- return 'accepted'
345
- }
346
- if (err instanceof RpcError && !isNodeFault(err)) {
347
- throw err
348
- }
349
- return { uncertain: `broadcast not acknowledged: ${errorText(err)}` }
350
- }
351
- }
352
- }
353
-
354
- /** 按 SendOptions 取优先费用单价(micro-lamports / 计算单元);writable 是交易里的可写账户 */
355
- export function priorityFeeFor(client: SolanaClient, options: SendOptions, writable: readonly string[]): Promise<bigint> {
356
- return new Chain(client, options).priorityFee(writable)
357
- }
358
-
359
- // ---- 确认 ----
360
-
361
- interface SignatureStatus {
362
- slot: number
363
- err: unknown
364
- confirmationStatus?: 'processed' | 'confirmed' | 'finalized' | null
365
- }
366
-
367
- type WatchResult = { kind: 'landed'; slot: number; err: unknown } | { kind: 'expired' } | { kind: 'timeout' } | { kind: 'aborted' }
368
-
369
- interface Watched {
370
- signature: string
371
- raw: string
372
- /** blockhash 的有效期;Infinity 表示不知道(钱包换了 blockhash),这时不判断过期、不自动重发 */
373
- lastValidBlockHeight: number
374
- deadline: number
375
- polls: number
376
- resolve(result: WatchResult): void
377
- }
378
-
379
- /** 统一轮询所有待确认的交易:一次 getSignatureStatuses 查全部(每 256 个一组),未确认的每次重发,blockhash 过期后再查一次历史确认没上链 */
380
- class Tracker {
381
- readonly #watched = new Set<Watched>()
382
- #running = false
383
-
384
- constructor(readonly chain: Chain) {}
385
-
386
- watch(signature: string, raw: string, lastValidBlockHeight: number): Promise<WatchResult> {
387
- const { timeout = 120_000 } = this.chain.options
388
- return new Promise((resolve) => {
389
- this.#watched.add({ signature, raw, lastValidBlockHeight, deadline: Date.now() + timeout, polls: 0, resolve })
390
- if (!this.#running) {
391
- this.#running = true
392
- void this.#loop()
393
- }
394
- })
395
- }
396
-
397
- /** 查交易状态;contextSlot 是各次响应里最小的 context.slot(节点的数据新到哪个 slot) */
398
- async #statuses(signatures: string[], history: boolean): Promise<{ found: Map<string, SignatureStatus>; contextSlot: number }> {
399
- const found = new Map<string, SignatureStatus>()
400
- let contextSlot = Number.POSITIVE_INFINITY
401
- for (let i = 0; i < signatures.length; i += 256) {
402
- const chunk = signatures.slice(i, i + 256)
403
- const res = await this.chain.client.request<{ context?: { slot?: number }; value: Array<SignatureStatus | null> }>('getSignatureStatuses', [
404
- chunk,
405
- { searchTransactionHistory: history },
406
- ])
407
- contextSlot = Math.min(contextSlot, res.context?.slot ?? 0)
408
- res.value.forEach((s, k) => s && found.set(chunk[k] as string, s))
409
- }
410
- return { found, contextSlot }
411
- }
412
-
413
- #done(status: SignatureStatus): boolean {
414
- const level = status.confirmationStatus
415
- return this.chain.commitment === 'finalized' ? level === 'finalized' : level === 'confirmed' || level === 'finalized'
416
- }
417
-
418
- #settle(item: Watched, result: WatchResult): void {
419
- this.#watched.delete(item)
420
- item.resolve(result)
421
- }
422
-
423
- async #loop(): Promise<void> {
424
- const { pollInterval = 2_000, signal } = this.chain.options
425
- try {
426
- while (this.#watched.size) {
427
- await sleep(pollInterval, signal)
428
- if (signal?.aborted) {
429
- ;[...this.#watched].forEach((w) => this.#settle(w, { kind: 'aborted' }))
430
- return
431
- }
432
- const items = [...this.#watched]
433
- try {
434
- const { found: statuses } = await this.#statuses(
435
- items.map((w) => w.signature),
436
- false,
437
- )
438
- for (const item of items) {
439
- const status = statuses.get(item.signature)
440
- if (status && this.#done(status)) {
441
- this.#settle(item, { kind: 'landed', slot: status.slot, err: status.err })
442
- }
443
- }
444
- const waiting = items.filter((w) => this.#watched.has(w))
445
- if (!waiting.length) {
446
- continue
447
- }
448
- // 区块高度和对应的 slot 一起取(同一个节点、同一个时刻)
449
- const epoch = await this.chain.client.request<{ blockHeight: number; absoluteSlot: number }>('getEpochInfo', [{ commitment: 'confirmed' }])
450
- const expired = waiting.filter((w) => epoch.blockHeight > w.lastValidBlockHeight)
451
- if (expired.length) {
452
- // 过期了:再查一次历史确认确实没上链。请求可能落到另一个节点(多节点 / 负载均衡),
453
- // 它的数据必须不早于看到过期的那个 slot,否则可能只是它还没看到这笔交易,这一轮先不判断
454
- const history = await this.#statuses(
455
- expired.map((w) => w.signature),
456
- true,
457
- )
458
- const caughtUp = history.contextSlot >= epoch.absoluteSlot
459
- for (const item of expired) {
460
- const status = history.found.get(item.signature)
461
- if (!status) {
462
- if (!caughtUp) continue
463
- this.#settle(item, { kind: 'expired' })
464
- } else if (this.#done(status)) {
465
- this.#settle(item, { kind: 'landed', slot: status.slot, err: status.err })
466
- }
467
- }
468
- }
469
- } catch {
470
- // 节点暂时出错:下次再查
471
- }
472
- const now = Date.now()
473
- for (const item of [...this.#watched]) {
474
- if (now >= item.deadline) {
475
- this.#settle(item, { kind: 'timeout' })
476
- } else if (++item.polls % 2 === 0) {
477
- // 每两次轮询重发一次(每笔都重发会让待确认交易多时很快触发节点限频)
478
- void this.chain.broadcast(item.raw).catch(() => {})
479
- }
480
- }
481
- }
482
- } finally {
483
- this.#running = false
484
- if (this.#watched.size) {
485
- this.#running = true
486
- void this.#loop()
487
- }
488
- }
489
- }
490
- }
491
-
492
148
  // ---- 发送 ----
493
149
 
494
- /** 打包用的占位 blockhash(大小与真实值相同) */
495
- const PLACEHOLDER_BLOCKHASH = '11111111111111111111111111111111'
496
-
497
- function budgetInstructions(units: number, price: bigint): Instruction[] {
498
- return [setComputeUnitLimit(units), setComputeUnitPrice(price)]
499
- }
500
-
501
- function fits(payer: string, units: readonly Unit[]): boolean {
502
- const message = compileMessage(payer, [...budgetInstructions(MAX_COMPUTE_UNITS, 0n), ...units.flatMap((u) => u.instructions)], PLACEHOLDER_BLOCKHASH)
503
- return transactionSize(message) <= PACKET_DATA_SIZE && message.accountKeys.length <= MAX_ACCOUNT_LOCKS
504
- }
505
-
506
150
  /** 交易错误对应的调用:InstructionError 的指令下标(前两条是计算预算指令),或租金不足的账户所在的调用 */
507
151
  function failingUnit(err: unknown, units: readonly Unit[], message: CompiledMessage): number | null {
508
152
  const instruction = (err as { InstructionError?: [number, unknown] } | null)?.InstructionError
@@ -529,6 +173,30 @@ function failingUnit(err: unknown, units: readonly Unit[], message: CompiledMess
529
173
  return null
530
174
  }
531
175
 
176
+ /** 免租下限:0 字节系统账户(手续费支付者付完手续费后不能掉到它和 0 之间) */
177
+ const RENT_EXEMPT_MINIMUM = 890_880n
178
+
179
+ /**
180
+ * 取舍:以前有优先费用时总是按真实的上限和单价再模拟一次,它挡住的是两种情况:
181
+ * - 付款方付不起优先费用:这里用模拟后余额 + 当前余额判断,付不起(或拿不到余额)时照旧再模拟一次,这层保护没有丢;
182
+ * - 收紧后的计算单元上限(消耗 × 1.1 + 1000)不够用:第二次模拟和第一次只隔几百毫秒、基于几乎同一个状态,
183
+ * 消耗与第一次相同,上限按定义高于它,几乎不可能在这里失败;真正的风险是发送时状态已经变了(如兑换路径穿过更多 tick),
184
+ * 第二次模拟同样看不到。所以默认跳过,消耗会波动的调用用 SendOptions.computeUnitMargin 调大余量
185
+ *
186
+ * 第一次模拟(计算单元上限取最大、单价 0,节点已扣基础手续费)后,付款方能否再付得起优先费用(单价 × 真实的上限):
187
+ * 模拟后的余额和当前余额(手续费在执行前从当前余额里扣)各减去基础手续费 / 优先费用后都不低于免租下限才算付得起;
188
+ * 拿不到余额时按付不起处理(再完整模拟一次,同以前)
189
+ */
190
+ function canAffordPriority(signatures: number, limit: number, price: bigint, before: bigint | null, sim: { accounts?: Array<{ lamports: number | string } | null> | null }): boolean {
191
+ const after = sim.accounts?.[0]?.lamports
192
+ if (before === null || after === undefined || after === null) {
193
+ return false
194
+ }
195
+ const priority = transactionFee(0, limit, price)
196
+ const base = LAMPORTS_PER_SIGNATURE * BigInt(signatures)
197
+ return BigInt(after) - priority >= RENT_EXEMPT_MINIMUM && before - base - priority >= RENT_EXEMPT_MINIMUM
198
+ }
199
+
532
200
  export interface SendUnitsOptions {
533
201
  /** 每笔交易最多打包几个调用,默认不限(按交易大小 1232 字节和 64 个账户的上限) */
534
202
  batchSize?: number
@@ -562,13 +230,10 @@ interface Prepared {
562
230
  fixed: boolean
563
231
  }
564
232
 
565
- function isRejection(err: unknown): boolean {
566
- const e = err as { code?: unknown; name?: unknown; message?: unknown } | null
567
- return e?.code === 4001 || /reject|denied|cancel|declined/i.test(`${String(e?.name ?? '')} ${String(e?.message ?? '')}`)
568
- }
569
-
570
233
  async function sendGroup(chain: Chain, tracker: Tracker, units: readonly Unit[], options: SendOptions, batchSize: number): Promise<void> {
571
234
  const { signal, dryRun = false, wait = true, retries = 2 } = options
235
+ const marginRatio = Math.max(1, options.computeUnitMargin?.ratio ?? 1.1)
236
+ const marginExtra = Math.max(0, Math.ceil(options.computeUnitMargin?.extra ?? 1000))
572
237
  const payer = (units[0] as Unit).payer
573
238
  /** 有浏览器钱包等外部签名者:先准备好全部交易,再让钱包一次签完 */
574
239
  const deferred = units.some((u) => u.payer.kind === 'wallet' || u.signers.some((s) => s.kind === 'wallet'))
@@ -619,7 +284,12 @@ async function sendGroup(chain: Chain, tracker: Tracker, units: readonly Unit[],
619
284
  // 模拟时计算单元上限取最大值,优先费用单价按 0(否则节点按 140 万 × 单价预扣手续费,余额少的付款方会被误判为付不起);
620
285
  // 算出真实的上限后再按真实费用复核一次
621
286
  const simMessage = compileMessage(payer.address, [...budgetInstructions(fixed?.computeUnits ?? MAX_COMPUTE_UNITS, fixed ? price : 0n), ...instructions], blockhash.blockhash)
622
- const sim = await chain.simulate(simMessage, minSlot)
287
+ // 有优先费用时同时拿付款方模拟后的余额和当前余额(和模拟合并在同一个批量请求里),判断付不付得起优先费用,付得起就不再模拟第二次
288
+ const affordCheck = !fixed && price > 0n
289
+ const [sim, payerBefore] = await Promise.all([
290
+ chain.simulate(simMessage, minSlot, affordCheck ? [payer.address] : undefined),
291
+ affordCheck ? chain.balance(payer.address, minSlot) : null,
292
+ ])
623
293
  if (sim.err) {
624
294
  if (!waited && inFlight.some((f) => f.sent)) {
625
295
  // 可能依赖前面还没确认的交易:等它们确认后重新模拟
@@ -656,8 +326,8 @@ async function sendGroup(chain: Chain, tracker: Tracker, units: readonly Unit[],
656
326
  continue
657
327
  }
658
328
  const consumed = sim.unitsConsumed ?? null
659
- const limit = fixed?.computeUnits ?? (consumed ? Math.min(MAX_COMPUTE_UNITS, Math.ceil(consumed * 1.1) + 1000) : MAX_COMPUTE_UNITS)
660
- if (!fixed && price > 0n) {
329
+ const limit = fixed?.computeUnits ?? (consumed ? Math.min(MAX_COMPUTE_UNITS, Math.ceil(consumed * marginRatio) + marginExtra) : MAX_COMPUTE_UNITS)
330
+ if (affordCheck && !canAffordPriority(simMessage.signers.length, limit, price, payerBefore, sim)) {
661
331
  const check = await chain.simulate(compileMessage(payer.address, [...budgetInstructions(limit, price), ...instructions], blockhash.blockhash), minSlot)
662
332
  if (check.err) {
663
333
  fail(pending, transactionErrorText(check.err, check.logs))
@@ -685,7 +355,7 @@ async function sendGroup(chain: Chain, tracker: Tracker, units: readonly Unit[],
685
355
  // 同样的消息签名也相同、会被节点当成同一笔:调整计算单元上限让消息不同
686
356
  let limit = p.limit
687
357
  let message = compileMessage(payer.address, [...budgetInstructions(limit, p.price), ...p.instructions], p.blockhash.blockhash)
688
- while (!p.fixed && recentMessages.has(messageKey(message.bytes))) {
358
+ while (!p.fixed && isRecentMessage(message.bytes)) {
689
359
  limit += 1
690
360
  message = compileMessage(payer.address, [...budgetInstructions(limit, p.price), ...p.instructions], p.blockhash.blockhash)
691
361
  }
@@ -831,26 +501,23 @@ async function sendGroup(chain: Chain, tracker: Tracker, units: readonly Unit[],
831
501
  }
832
502
  }
833
503
 
834
- // 打包:从下一个调用开始,依次加入后面的调用,直到超出交易大小 / 账户数上限
504
+ // 打包(packUnits:相邻的调用放进同一笔,不超过交易大小 / 账户数上限;固定手续费的调用单独一笔),按顺序逐笔取出
505
+ const packs = packUnits(payer.address, units, { batchSize, alone: (i) => Boolean((units[i] as Unit).fixed) })
506
+ let packIndex = 0
507
+ /** 下一个还没取出的调用下标 */
835
508
  let next = 0
836
509
  const nextBatch = (): Unit[] | null => {
837
- while (next < units.length) {
838
- const first = units[next] as Unit
839
- next++
840
- if (!fits(payer.address, [first])) {
510
+ while (packIndex < packs.length) {
511
+ const pack = packs[packIndex++] as (typeof packs)[number]
512
+ next = (pack.indices[pack.indices.length - 1] as number) + 1
513
+ if (pack.tooLarge) {
514
+ const first = units[pack.indices[0] as number] as Unit
841
515
  update(first, { status: 'failed', error: `transaction too large (over ${PACKET_DATA_SIZE} bytes or ${MAX_ACCOUNT_LOCKS} accounts)` })
842
516
  if (!first.continueOnError) stopped = true
843
517
  if (stopped) return null
844
518
  continue
845
519
  }
846
- const batch: Unit[] = [first]
847
- while (!first.fixed && next < units.length && batch.length < batchSize) {
848
- const candidate = units[next] as Unit
849
- if (candidate.fixed || !fits(payer.address, [...batch, candidate])) break
850
- batch.push(candidate)
851
- next++
852
- }
853
- return batch
520
+ return pack.indices.map((i) => units[i] as Unit)
854
521
  }
855
522
  return null
856
523
  }