@w3lib/solana-batch-call 0.2.2 → 0.4.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 (123) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +205 -2
  3. package/dist/address.d.ts +2 -0
  4. package/dist/address.d.ts.map +1 -1
  5. package/dist/address.js +8 -0
  6. package/dist/address.js.map +1 -1
  7. package/dist/client.d.ts +4 -0
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +8 -0
  10. package/dist/client.js.map +1 -1
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/layout.d.ts +5 -0
  16. package/dist/layout.d.ts.map +1 -1
  17. package/dist/layout.js +16 -0
  18. package/dist/layout.js.map +1 -1
  19. package/dist/liquidity/amm.d.ts +7 -0
  20. package/dist/liquidity/amm.d.ts.map +1 -0
  21. package/dist/liquidity/amm.js +307 -0
  22. package/dist/liquidity/amm.js.map +1 -0
  23. package/dist/liquidity/clmm.d.ts +18 -0
  24. package/dist/liquidity/clmm.d.ts.map +1 -0
  25. package/dist/liquidity/clmm.js +85 -0
  26. package/dist/liquidity/clmm.js.map +1 -0
  27. package/dist/liquidity/common.d.ts +94 -0
  28. package/dist/liquidity/common.d.ts.map +1 -0
  29. package/dist/liquidity/common.js +228 -0
  30. package/dist/liquidity/common.js.map +1 -0
  31. package/dist/liquidity/index.d.ts +34 -0
  32. package/dist/liquidity/index.d.ts.map +1 -0
  33. package/dist/liquidity/index.js +157 -0
  34. package/dist/liquidity/index.js.map +1 -0
  35. package/dist/liquidity/orca.d.ts +6 -0
  36. package/dist/liquidity/orca.d.ts.map +1 -0
  37. package/dist/liquidity/orca.js +303 -0
  38. package/dist/liquidity/orca.js.map +1 -0
  39. package/dist/liquidity/raydiumClmm.d.ts +6 -0
  40. package/dist/liquidity/raydiumClmm.d.ts.map +1 -0
  41. package/dist/liquidity/raydiumClmm.js +307 -0
  42. package/dist/liquidity/raydiumClmm.js.map +1 -0
  43. package/dist/liquidity/types.d.ts +150 -0
  44. package/dist/liquidity/types.d.ts.map +1 -0
  45. package/dist/liquidity/types.js +2 -0
  46. package/dist/liquidity/types.js.map +1 -0
  47. package/dist/lp.d.ts +168 -0
  48. package/dist/lp.d.ts.map +1 -0
  49. package/dist/lp.js +632 -0
  50. package/dist/lp.js.map +1 -0
  51. package/dist/lpMath.d.ts +82 -0
  52. package/dist/lpMath.d.ts.map +1 -0
  53. package/dist/lpMath.js +365 -0
  54. package/dist/lpMath.js.map +1 -0
  55. package/dist/shortcuts.d.ts +8 -0
  56. package/dist/shortcuts.d.ts.map +1 -1
  57. package/dist/shortcuts.js +1 -1
  58. package/dist/shortcuts.js.map +1 -1
  59. package/dist/subpaths/liquidity.d.ts +23 -0
  60. package/dist/subpaths/liquidity.d.ts.map +1 -0
  61. package/dist/subpaths/liquidity.js +19 -0
  62. package/dist/subpaths/liquidity.js.map +1 -0
  63. package/dist/subpaths/lp.d.ts +31 -0
  64. package/dist/subpaths/lp.d.ts.map +1 -0
  65. package/dist/subpaths/lp.js +33 -0
  66. package/dist/subpaths/lp.js.map +1 -0
  67. package/dist/subpaths/write.d.ts +22 -0
  68. package/dist/subpaths/write.d.ts.map +1 -0
  69. package/dist/subpaths/write.js +19 -0
  70. package/dist/subpaths/write.js.map +1 -0
  71. package/dist/write/batch.d.ts +41 -0
  72. package/dist/write/batch.d.ts.map +1 -0
  73. package/dist/write/batch.js +46 -0
  74. package/dist/write/batch.js.map +1 -0
  75. package/dist/write/collect.d.ts +50 -0
  76. package/dist/write/collect.d.ts.map +1 -0
  77. package/dist/write/collect.js +220 -0
  78. package/dist/write/collect.js.map +1 -0
  79. package/dist/write/instructions.d.ts +16 -0
  80. package/dist/write/instructions.d.ts.map +1 -0
  81. package/dist/write/instructions.js +89 -0
  82. package/dist/write/instructions.js.map +1 -0
  83. package/dist/write/send.d.ts +115 -0
  84. package/dist/write/send.d.ts.map +1 -0
  85. package/dist/write/send.js +720 -0
  86. package/dist/write/send.js.map +1 -0
  87. package/dist/write/signer.d.ts +94 -0
  88. package/dist/write/signer.d.ts.map +1 -0
  89. package/dist/write/signer.js +197 -0
  90. package/dist/write/signer.js.map +1 -0
  91. package/dist/write/transaction.d.ts +75 -0
  92. package/dist/write/transaction.d.ts.map +1 -0
  93. package/dist/write/transaction.js +133 -0
  94. package/dist/write/transaction.js.map +1 -0
  95. package/dist/write/transfer.d.ts +72 -0
  96. package/dist/write/transfer.d.ts.map +1 -0
  97. package/dist/write/transfer.js +127 -0
  98. package/dist/write/transfer.js.map +1 -0
  99. package/package.json +23 -5
  100. package/src/address.ts +9 -0
  101. package/src/client.ts +10 -0
  102. package/src/index.ts +2 -1
  103. package/src/layout.ts +17 -0
  104. package/src/liquidity/amm.ts +353 -0
  105. package/src/liquidity/clmm.ts +108 -0
  106. package/src/liquidity/common.ts +285 -0
  107. package/src/liquidity/index.ts +177 -0
  108. package/src/liquidity/orca.ts +344 -0
  109. package/src/liquidity/raydiumClmm.ts +348 -0
  110. package/src/liquidity/types.ts +158 -0
  111. package/src/lp.ts +850 -0
  112. package/src/lpMath.ts +420 -0
  113. package/src/shortcuts.ts +1 -1
  114. package/src/subpaths/liquidity.ts +45 -0
  115. package/src/subpaths/lp.ts +71 -0
  116. package/src/subpaths/write.ts +29 -0
  117. package/src/write/batch.ts +78 -0
  118. package/src/write/collect.ts +286 -0
  119. package/src/write/instructions.ts +98 -0
  120. package/src/write/send.ts +876 -0
  121. package/src/write/signer.ts +314 -0
  122. package/src/write/transaction.ts +181 -0
  123. package/src/write/transfer.ts +189 -0
@@ -0,0 +1,876 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js'
2
+ import { base58, base64 } from '@scure/base'
3
+
4
+ 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'
8
+ 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'
10
+
11
+ /**
12
+ * 每个调用的状态:
13
+ * - confirmed:已上链且执行成功
14
+ * - reverted:已上链但执行失败(手续费已扣);同一笔交易里的其他调用也一起回滚,同样是 reverted
15
+ * - failed:没有发出去(模拟失败、SOL 不够付手续费、交易过期且重试次数用完等),不花手续费
16
+ * - sent:已广播,没等确认(wait: false,或等待中途取消)。广播时节点都连不上(交易可能已经发出)也算 sent,error 里有说明
17
+ * - pending:等到 timeout 还没确认,交易的 blockhash 过期前仍可能上链
18
+ * - skipped:没有执行:前面的调用失败(continueOnError 为 false 时)、已取消、余额为 0 等
19
+ * - simulated:dryRun 模拟通过
20
+ */
21
+ export type WriteStatus = 'confirmed' | 'reverted' | 'failed' | 'sent' | 'pending' | 'skipped' | 'simulated'
22
+
23
+ /** 发送参数(batchCall / batchTransfer / batchCollect 共用) */
24
+ export interface SendOptions {
25
+ /** 等待确认。默认 true;false 时广播后即返回,status 为 'sent'(不会在过期后重发) */
26
+ wait?: boolean
27
+ /** 每笔交易等待确认的最长时间(毫秒),超时 status 为 'pending'。默认 120000 */
28
+ timeout?: number
29
+ /** 查询确认状态的间隔(毫秒),默认 2000。等待期间每次都重发一次原交易(同一笔签名交易重复发送是安全的) */
30
+ pollInterval?: number
31
+ /** 同时处理的手续费支付者数(同一支付者的交易按顺序发出,不等上一笔确认),默认 8 */
32
+ concurrency?: number
33
+ /**
34
+ * 手续费支付者:所有交易的手续费(以及新建代币账户的租金)由它付,发送方的钱包不需要有 SOL。
35
+ * 不传时每个钱包自己付
36
+ */
37
+ feePayer?: SignerInput
38
+ /**
39
+ * 优先费用(每个计算单元多少 micro-lamports),或 'auto'(默认):按 getRecentPrioritizationFees 里这笔交易可写账户的最近费用取 75 分位,
40
+ * 不超过 maxPriorityFee。计算单元上限按模拟结果设置(× 1.1 + 1000),优先费用 = 单价 × 上限
41
+ */
42
+ priorityFee?: number | bigint | 'auto'
43
+ /** 'auto' 时优先费用单价的上限(micro-lamports),默认 1000000 */
44
+ maxPriorityFee?: number | bigint
45
+ /** 交易过期(blockhash 失效)仍未上链时,重新模拟、换新 blockhash 重发的次数。默认 2 */
46
+ retries?: number
47
+ /** 等到什么确认级别,默认 'confirmed' */
48
+ commitment?: 'confirmed' | 'finalized'
49
+ /** 只模拟、不发送,status 为 'simulated' 或 'failed'。注意:依赖前一笔交易结果的调用模拟会失败 */
50
+ dryRun?: boolean
51
+ /** 取消:不再发新交易,已发出的不再等待确认(status 为 'sent') */
52
+ signal?: AbortSignal
53
+ /** 每个结果状态变化时回调,用于展示进度 */
54
+ onUpdate?: (result: WriteResult) => void
55
+ }
56
+
57
+ /** 每个调用的结果,都是字符串 / 数字 / null,可以直接 JSON.stringify */
58
+ export interface WriteResult {
59
+ /** 在输入里的下标 */
60
+ index: number
61
+ /** 发送的钱包地址 */
62
+ from: string
63
+ status: WriteStatus
64
+ /** 交易签名(同一笔交易里的多个调用相同) */
65
+ signature: string | null
66
+ /** 上链的 slot */
67
+ slot: number | null
68
+ /** 整笔交易的手续费(lamports):每个签名 5000 + 优先费用;同一笔交易里的多个调用相同,不含新建账户的租金 */
69
+ fee: string | null
70
+ /** 模拟时整笔交易消耗的计算单元 */
71
+ computeUnits: number | null
72
+ error: string | null
73
+ }
74
+
75
+ /** 交易层面的结果 */
76
+ export interface TxOutcome {
77
+ status: WriteStatus
78
+ signature: string | null
79
+ slot: number | null
80
+ fee: bigint | null
81
+ computeUnits: number | null
82
+ error: string | null
83
+ }
84
+
85
+ /** 一个调用:要原子执行的一组指令(同一笔交易里的多个调用,任一失败整笔回滚) */
86
+ export interface Unit {
87
+ instructions: Instruction[]
88
+ /** 手续费支付者 */
89
+ payer: ResolvedSigner
90
+ /** 指令需要的其他签名者 */
91
+ signers: ResolvedSigner[]
92
+ /** 单独成一笔交易,计算单元上限和优先费用固定(扫空 SOL 时手续费要事先算好) */
93
+ fixed?: { computeUnits: number; price: bigint }
94
+ /** 发送前失败时后面的调用是否继续,默认 false */
95
+ continueOnError?: boolean
96
+ onUpdate(outcome: TxOutcome): void
97
+ }
98
+
99
+ export function emptyOutcome(status: WriteStatus, error: string | null = null): TxOutcome {
100
+ return { status, signature: null, slot: null, fee: null, computeUnits: null, error }
101
+ }
102
+
103
+ export function toWriteResult(index: number, from: string, outcome: TxOutcome): WriteResult {
104
+ return {
105
+ index,
106
+ from,
107
+ status: outcome.status,
108
+ signature: outcome.signature,
109
+ slot: outcome.slot,
110
+ fee: outcome.fee?.toString() ?? null,
111
+ computeUnits: outcome.computeUnits,
112
+ error: outcome.error,
113
+ }
114
+ }
115
+
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
+ /** 每个签名的基础手续费(lamports) */
147
+ export const LAMPORTS_PER_SIGNATURE = 5000n
148
+
149
+ /** 交易手续费:签名数 × 5000 + 优先费用(单价 × 计算单元上限,向上取整) */
150
+ export function transactionFee(signatures: number, computeUnits: number, price: bigint): bigint {
151
+ return LAMPORTS_PER_SIGNATURE * BigInt(signatures) + (price * BigInt(computeUnits) + 999_999n) / 1_000_000n
152
+ }
153
+
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
+ // ---- 发送 ----
493
+
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
+ /** 交易错误对应的调用:InstructionError 的指令下标(前两条是计算预算指令),或租金不足的账户所在的调用 */
507
+ function failingUnit(err: unknown, units: readonly Unit[], message: CompiledMessage): number | null {
508
+ const instruction = (err as { InstructionError?: [number, unknown] } | null)?.InstructionError
509
+ if (instruction) {
510
+ let offset = instruction[0] - 2
511
+ for (let i = 0; i < units.length; i++) {
512
+ const count = (units[i] as Unit).instructions.length
513
+ if (offset < count) {
514
+ return offset >= 0 ? i : null
515
+ }
516
+ offset -= count
517
+ }
518
+ return null
519
+ }
520
+ const rent = (err as { InsufficientFundsForRent?: { account_index?: number } } | null)?.InsufficientFundsForRent
521
+ if (rent?.account_index !== undefined) {
522
+ // 账户余额是按顺序被扣的:从最后一个用到它的调用开始剔除(如付款方转出太多时,先剔除最后的转账)
523
+ const account = message.accountKeys[rent.account_index]
524
+ for (let i = units.length - 1; i >= 0; i--) {
525
+ if ((units[i] as Unit).instructions.some((ix) => ix.keys.some((k) => k.pubkey === account && k.isWritable))) return i
526
+ }
527
+ return null
528
+ }
529
+ return null
530
+ }
531
+
532
+ export interface SendUnitsOptions {
533
+ /** 每笔交易最多打包几个调用,默认不限(按交易大小 1232 字节和 64 个账户的上限) */
534
+ batchSize?: number
535
+ }
536
+
537
+ /**
538
+ * 发送一批调用:按手续费支付者分组,组内按顺序把相邻的调用打包成交易(不超过交易大小上限),
539
+ * 逐笔模拟(失败的调用剔除后重新模拟其余的)、签名、发送,不等上一笔确认;不同支付者并发。
540
+ * 模拟失败且同一支付者还有未确认的交易时,先等它们确认再模拟一次(后一笔依赖前一笔时自动正确处理)
541
+ */
542
+ export async function sendUnits(client: SolanaClient, units: readonly Unit[], options: SendOptions, pack: SendUnitsOptions = {}): Promise<void> {
543
+ const chain = new Chain(client, options)
544
+ const tracker = new Tracker(chain)
545
+ const groups = new Map<string, Unit[]>()
546
+ units.forEach((u) => {
547
+ const list = groups.get(u.payer.address)
548
+ if (list) list.push(u)
549
+ else groups.set(u.payer.address, [u])
550
+ })
551
+ await mapPool([...groups.values()], options.concurrency ?? 8, (list) => sendGroup(chain, tracker, list, options, pack.batchSize ?? Number.POSITIVE_INFINITY))
552
+ }
553
+
554
+ /** 模拟通过、等待签名的一笔交易 */
555
+ interface Prepared {
556
+ units: Unit[]
557
+ instructions: Instruction[]
558
+ blockhash: Blockhash
559
+ price: bigint
560
+ limit: number
561
+ consumed: number | null
562
+ fixed: boolean
563
+ }
564
+
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
+ async function sendGroup(chain: Chain, tracker: Tracker, units: readonly Unit[], options: SendOptions, batchSize: number): Promise<void> {
571
+ const { signal, dryRun = false, wait = true, retries = 2 } = options
572
+ const payer = (units[0] as Unit).payer
573
+ /** 有浏览器钱包等外部签名者:先准备好全部交易,再让钱包一次签完 */
574
+ const deferred = units.some((u) => u.payer.kind === 'wallet' || u.signers.some((s) => s.kind === 'wallet'))
575
+ /** 已发出的交易:sent 为 true 表示还没有结果;watch() 开始等待确认(wait 为 false 时只在需要时才等) */
576
+ const inFlight: Array<{ sent: boolean; watch: () => Promise<void> }> = []
577
+ /** 同一支付者已上链交易的最高 slot(之后的模拟不早于它) */
578
+ let minSlot: number | undefined
579
+ let stopped = false
580
+ const settle = async () => {
581
+ // 等待期间可能有过期重发的新交易加进来:直到没有新的为止
582
+ for (let seen = -1; seen !== inFlight.length; ) {
583
+ seen = inFlight.length
584
+ await Promise.all(inFlight.map((f) => f.watch()))
585
+ }
586
+ }
587
+ const update = (unit: Unit, patch: Partial<TxOutcome> & { status: WriteStatus }) => {
588
+ try {
589
+ unit.onUpdate({ ...emptyOutcome(patch.status), ...patch })
590
+ } catch {
591
+ // 调用方的进度回调出错不能打断发送(已经发出的交易还要继续跟踪)
592
+ }
593
+ }
594
+ const skip = (list: readonly Unit[], error: string) => list.forEach((u) => update(u, { status: 'skipped', error }))
595
+ const fail = (list: readonly Unit[], error: string) => {
596
+ list.forEach((u) => update(u, { status: 'failed', error }))
597
+ if (list.some((u) => !u.continueOnError)) stopped = true
598
+ }
599
+
600
+ /**
601
+ * 准备一批调用:模拟,剔除失败的调用后重新模拟其余的,计算单元超出单笔上限时拆成两笔。返回待签名的交易(可能 0 笔或多笔)。
602
+ * attempt:过期后第几次重发
603
+ */
604
+ const prepare = async (batch: readonly Unit[], attempt: number): Promise<Prepared[]> => {
605
+ let pending = [...batch]
606
+ // 过期重发时不再等其他交易:这次调用本身就在某笔交易的等待回调里,等全部交易会等到自己(死锁);依赖的交易早已上链
607
+ let waited = attempt > 0
608
+ while (pending.length) {
609
+ if (signal?.aborted) {
610
+ skip(pending, 'aborted')
611
+ return []
612
+ }
613
+ try {
614
+ const fixed = pending.length === 1 ? (pending[0] as Unit).fixed : undefined
615
+ const instructions = pending.flatMap((u) => u.instructions)
616
+ const blockhash = await chain.blockhash(attempt > 0)
617
+ const probe = compileMessage(payer.address, [...budgetInstructions(MAX_COMPUTE_UNITS, 0n), ...instructions], blockhash.blockhash)
618
+ const price = fixed?.price ?? (await chain.priorityFee(probe.writable))
619
+ // 模拟时计算单元上限取最大值,优先费用单价按 0(否则节点按 140 万 × 单价预扣手续费,余额少的付款方会被误判为付不起);
620
+ // 算出真实的上限后再按真实费用复核一次
621
+ 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)
623
+ if (sim.err) {
624
+ if (!waited && inFlight.some((f) => f.sent)) {
625
+ // 可能依赖前面还没确认的交易:等它们确认后重新模拟
626
+ waited = true
627
+ await settle()
628
+ continue
629
+ }
630
+ if (isBudgetExceeded(sim.err, sim.logs) && pending.length > 1) {
631
+ // 计算单元超出单笔上限:拆成两笔
632
+ const half = Math.ceil(pending.length / 2)
633
+ const first = await prepare(pending.slice(0, half), attempt)
634
+ if (stopped) {
635
+ skip(pending.slice(half), 'skipped: a previous call failed')
636
+ return first
637
+ }
638
+ return [...first, ...(await prepare(pending.slice(half), attempt))]
639
+ }
640
+ const text = transactionErrorText(sim.err, sim.logs)
641
+ const index = failingUnit(sim.err, pending, simMessage)
642
+ if (index === null || pending.length === 1) {
643
+ fail(pending, text)
644
+ return []
645
+ }
646
+ const failed = pending[index] as Unit
647
+ update(failed, { status: 'failed', error: text })
648
+ if (!failed.continueOnError) {
649
+ // 后面的调用不再执行;前面的照常发送
650
+ stopped = true
651
+ skip(pending.slice(index + 1), 'skipped: a previous call failed')
652
+ pending = pending.slice(0, index)
653
+ } else {
654
+ pending.splice(index, 1)
655
+ }
656
+ continue
657
+ }
658
+ 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) {
661
+ const check = await chain.simulate(compileMessage(payer.address, [...budgetInstructions(limit, price), ...instructions], blockhash.blockhash), minSlot)
662
+ if (check.err) {
663
+ fail(pending, transactionErrorText(check.err, check.logs))
664
+ return []
665
+ }
666
+ }
667
+ if (dryRun) {
668
+ const fee = transactionFee(simMessage.signers.length, limit, price)
669
+ pending.forEach((u) => update(u, { status: 'simulated', fee, computeUnits: consumed }))
670
+ return []
671
+ }
672
+ return [{ units: pending, instructions, blockhash, price, limit, consumed, fixed: Boolean(fixed) }]
673
+ } catch (err) {
674
+ fail(pending, errorText(err))
675
+ return []
676
+ }
677
+ }
678
+ return []
679
+ }
680
+
681
+ /** 签名(外部钱包一次签完)并发送,等待确认;过期时重新准备、签名、发送 */
682
+ const finish = async (list: readonly Prepared[], attempt: number): Promise<void> => {
683
+ if (!list.length) return
684
+ const txs = list.map((p) => {
685
+ // 同样的消息签名也相同、会被节点当成同一笔:调整计算单元上限让消息不同
686
+ let limit = p.limit
687
+ let message = compileMessage(payer.address, [...budgetInstructions(limit, p.price), ...p.instructions], p.blockhash.blockhash)
688
+ while (!p.fixed && recentMessages.has(messageKey(message.bytes))) {
689
+ limit += 1
690
+ message = compileMessage(payer.address, [...budgetInstructions(limit, p.price), ...p.instructions], p.blockhash.blockhash)
691
+ }
692
+ remember(message.bytes)
693
+ const signers = new Map<string, ResolvedSigner>([[payer.address, payer]])
694
+ p.units.forEach((u) => u.signers.forEach((s) => signers.set(s.address, s)))
695
+ return {
696
+ prepared: p,
697
+ message: message.bytes,
698
+ signerList: message.signers,
699
+ signatures: message.signers.map((): Uint8Array | null => null),
700
+ signers,
701
+ fee: transactionFee(message.signers.length, limit, p.price) as bigint | null,
702
+ failed: null as string | null,
703
+ modified: false,
704
+ lastValidBlockHeight: p.blockhash.lastValidBlockHeight,
705
+ }
706
+ })
707
+ for (const tx of txs) {
708
+ const missing = tx.signerList.find((s) => !tx.signers.has(s))
709
+ if (missing) tx.failed = `missing signer ${missing}`
710
+ }
711
+
712
+ // 外部钱包先签(有的钱包只在没有其他签名时才会往交易里加指令),一个钱包一次签完所有交易
713
+ const wallets = new Map<string, WalletSigner>()
714
+ txs.forEach((tx) => tx.signers.forEach((s) => s.kind === 'wallet' && wallets.set(s.address, s)))
715
+ for (const wallet of wallets.values()) {
716
+ const needing = txs.filter((tx) => !tx.failed && tx.signerList.includes(wallet.address))
717
+ if (!needing.length) continue
718
+ try {
719
+ const signed = await wallet.signTransactions(
720
+ needing.map((tx) => ({ message: tx.message, signers: tx.signerList, signatures: tx.signatures })),
721
+ await chain.walletChain(),
722
+ )
723
+ needing.forEach((tx, k) => {
724
+ const result = signed[k] as (typeof signed)[number]
725
+ if (sameBytes(result.message, tx.message)) {
726
+ const index = tx.signerList.indexOf(wallet.address)
727
+ tx.signatures[index] = result.signatures[result.signers.indexOf(wallet.address)] ?? null
728
+ if (!tx.signatures[index]) tx.failed = 'wallet did not sign the transaction'
729
+ return
730
+ }
731
+ // 钱包改了交易(如加了优先费用指令):只有它一个签名者时接受(用户在钱包里确认过);还有其他签名者时不让本地私钥去签钱包改过的内容
732
+ if (tx.signerList.length > 1 || result.signers.length > 1) {
733
+ tx.failed = 'the wallet modified the transaction; this is not supported when other signers are involved'
734
+ return
735
+ }
736
+ tx.message = result.message
737
+ tx.signerList = result.signers
738
+ tx.signatures = result.signatures
739
+ tx.fee = null // 钱包可能改了计算单元或优先费用
740
+ tx.modified = true
741
+ remember(result.message)
742
+ // 钱包换了 blockhash:不知道新 blockhash 的有效期,不判断过期、不自动重发(误判过期再发一笔可能两笔都上链)
743
+ if (messageBlockhash(result.message) !== tx.prepared.blockhash.blockhash) tx.lastValidBlockHeight = Number.POSITIVE_INFINITY
744
+ if (!tx.signatures[0]) tx.failed = 'wallet did not sign the transaction'
745
+ })
746
+ } catch (err) {
747
+ const text = isRejection(err) ? 'rejected by user' : errorText(err)
748
+ needing.forEach((tx) => (tx.failed = text))
749
+ }
750
+ }
751
+ // 本地签名
752
+ for (const tx of txs) {
753
+ if (tx.failed) continue
754
+ try {
755
+ await Promise.all(
756
+ tx.signerList.map(async (s, i) => {
757
+ const signer = tx.signers.get(s)
758
+ if (signer?.kind === 'local') tx.signatures[i] = await (signer as LocalSigner).sign(tx.message)
759
+ }),
760
+ )
761
+ } catch (err) {
762
+ tx.failed = errorText(err)
763
+ }
764
+ }
765
+
766
+ /** 这一轮里签名 / 发送失败、且不允许继续的交易之后的交易都跳过(模拟阶段失败的调用排在已准备好的交易之后,不影响它们) */
767
+ let halted = false
768
+ const halt = (units: readonly Unit[], error: string) => {
769
+ fail(units, error)
770
+ if (units.some((u) => !u.continueOnError)) halted = true
771
+ }
772
+ for (const tx of txs) {
773
+ const { prepared } = tx
774
+ if (signal?.aborted || halted) {
775
+ skip(prepared.units, signal?.aborted ? 'aborted' : 'skipped: a previous call failed')
776
+ continue
777
+ }
778
+ if (tx.failed) {
779
+ halt(prepared.units, tx.failed)
780
+ continue
781
+ }
782
+ const signature = base58.encode(tx.signatures[0] as Uint8Array)
783
+ const raw = base64.encode(serializeTransaction({ bytes: tx.message, signers: tx.signerList }, tx.signatures))
784
+ let sent: Awaited<ReturnType<Chain['broadcast']>>
785
+ try {
786
+ sent = await chain.broadcast(raw)
787
+ } catch (err) {
788
+ halt(prepared.units, errorText(err))
789
+ continue
790
+ }
791
+ const fee = tx.fee
792
+ const consumed = prepared.consumed
793
+ const note = sent === 'accepted' ? null : sent.uncertain
794
+ prepared.units.forEach((u) => update(u, { status: 'sent', signature, fee, computeUnits: consumed, error: note }))
795
+ const landed = prepared.units
796
+ /** 交易里每条指令的程序(翻译上链失败的错误码用);钱包改过的交易不知道对应关系 */
797
+ const programs = tx.modified ? undefined : [...budgetInstructions(0, 0n), ...prepared.instructions].map((ix) => ix.programId)
798
+ const layout = compileMessage(payer.address, [...budgetInstructions(0, 0n), ...prepared.instructions], prepared.blockhash.blockhash)
799
+ let watching: Promise<void> | null = null
800
+ const flight = { sent: true, watch: () => (watching ??= follow()) }
801
+ const follow = () =>
802
+ tracker.watch(signature, raw, tx.lastValidBlockHeight).then(async (result) => {
803
+ flight.sent = false
804
+ const base = { signature, fee, computeUnits: consumed }
805
+ if (result.kind === 'landed') {
806
+ minSlot = Math.max(minSlot ?? 0, result.slot)
807
+ if (result.err === null || result.err === undefined) {
808
+ landed.forEach((u) => update(u, { ...base, status: 'confirmed', slot: result.slot }))
809
+ } else {
810
+ const index = tx.modified ? null : failingUnit(result.err, landed, layout)
811
+ const text = transactionErrorText(result.err, null, programs)
812
+ landed.forEach((u, k) =>
813
+ update(u, { ...base, status: 'reverted', slot: result.slot, error: index === null || k === index ? text : 'reverted: another call in the same transaction failed' }),
814
+ )
815
+ }
816
+ } else if (result.kind === 'expired') {
817
+ if (attempt < retries) {
818
+ await finish(await prepare(landed, attempt + 1), attempt + 1)
819
+ } else {
820
+ landed.forEach((u) => update(u, { status: 'failed', error: 'transaction expired before it was confirmed (blockhash no longer valid)' }))
821
+ }
822
+ } else if (result.kind === 'timeout') {
823
+ landed.forEach((u) => update(u, { ...base, status: 'pending', error: `not confirmed within ${options.timeout ?? 120_000}ms` }))
824
+ }
825
+ // aborted:保持 sent
826
+ })
827
+ inFlight.push(flight)
828
+ if (wait) {
829
+ void flight.watch()
830
+ }
831
+ }
832
+ }
833
+
834
+ // 打包:从下一个调用开始,依次加入后面的调用,直到超出交易大小 / 账户数上限
835
+ let next = 0
836
+ const nextBatch = (): Unit[] | null => {
837
+ while (next < units.length) {
838
+ const first = units[next] as Unit
839
+ next++
840
+ if (!fits(payer.address, [first])) {
841
+ update(first, { status: 'failed', error: `transaction too large (over ${PACKET_DATA_SIZE} bytes or ${MAX_ACCOUNT_LOCKS} accounts)` })
842
+ if (!first.continueOnError) stopped = true
843
+ if (stopped) return null
844
+ continue
845
+ }
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
854
+ }
855
+ return null
856
+ }
857
+ const skipRest = () => skip(units.slice(next), signal?.aborted ? 'aborted' : 'skipped: a previous call failed')
858
+
859
+ if (deferred) {
860
+ // 先全部准备好(模拟),再一次签名、发送:钱包只弹一次确认。依赖同一批里前面交易结果的调用模拟会失败(还没有交易发出)
861
+ const prepared: Prepared[] = []
862
+ for (let batch = nextBatch(); batch; batch = stopped || signal?.aborted ? null : nextBatch()) {
863
+ prepared.push(...(await prepare(batch, 0)))
864
+ }
865
+ if (stopped || signal?.aborted) skipRest()
866
+ await finish(prepared, 0)
867
+ } else {
868
+ for (let batch = nextBatch(); batch; batch = stopped || signal?.aborted ? null : nextBatch()) {
869
+ await finish(await prepare(batch, 0), 0)
870
+ }
871
+ if (stopped || signal?.aborted) skipRest()
872
+ }
873
+ if (wait) {
874
+ await settle()
875
+ }
876
+ }