@w3lib/solana-batch-call 0.5.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.
- package/CHANGELOG.md +47 -0
- package/README.md +129 -9
- package/dist/accountReader.d.ts +57 -0
- package/dist/accountReader.d.ts.map +1 -0
- package/dist/accountReader.js +149 -0
- package/dist/accountReader.js.map +1 -0
- package/dist/address.d.ts +9 -0
- package/dist/address.d.ts.map +1 -1
- package/dist/address.js +36 -6
- package/dist/address.js.map +1 -1
- package/dist/approvals.d.ts +5 -3
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +25 -36
- package/dist/approvals.js.map +1 -1
- package/dist/balances.d.ts +178 -0
- package/dist/balances.d.ts.map +1 -0
- package/dist/balances.js +524 -0
- package/dist/balances.js.map +1 -0
- package/dist/batchPlan.d.ts +52 -0
- package/dist/batchPlan.d.ts.map +1 -0
- package/dist/batchPlan.js +64 -0
- package/dist/batchPlan.js.map +1 -0
- package/dist/bytes.d.ts +31 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +74 -0
- package/dist/bytes.js.map +1 -0
- package/dist/claim.d.ts +163 -0
- package/dist/claim.d.ts.map +1 -0
- package/dist/claim.js +197 -0
- package/dist/claim.js.map +1 -0
- package/dist/client.d.ts +26 -41
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +41 -986
- package/dist/client.js.map +1 -1
- package/dist/clientConfig.d.ts +11 -0
- package/dist/clientConfig.d.ts.map +1 -0
- package/dist/clientConfig.js +3 -0
- package/dist/clientConfig.js.map +1 -0
- package/dist/decode.d.ts +22 -0
- package/dist/decode.d.ts.map +1 -0
- package/dist/decode.js +160 -0
- package/dist/decode.js.map +1 -0
- package/dist/describe.d.ts +26 -0
- package/dist/describe.d.ts.map +1 -0
- package/dist/describe.js +58 -0
- package/dist/describe.js.map +1 -0
- package/dist/fees.d.ts.map +1 -1
- package/dist/fees.js +2 -12
- package/dist/fees.js.map +1 -1
- package/dist/history.d.ts +125 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +220 -0
- package/dist/history.js.map +1 -0
- package/dist/historyHelius.d.ts +52 -0
- package/dist/historyHelius.d.ts.map +1 -0
- package/dist/historyHelius.js +125 -0
- package/dist/historyHelius.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +21 -0
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +50 -69
- package/dist/layout.js.map +1 -1
- package/dist/liquidity/common.js +2 -2
- package/dist/liquidity/common.js.map +1 -1
- package/dist/liquidity/orca.d.ts +6 -0
- package/dist/liquidity/orca.d.ts.map +1 -1
- package/dist/liquidity/orca.js +14 -3
- package/dist/liquidity/orca.js.map +1 -1
- package/dist/liquidity/raydiumClmm.d.ts +6 -0
- package/dist/liquidity/raydiumClmm.d.ts.map +1 -1
- package/dist/liquidity/raydiumClmm.js +12 -5
- package/dist/liquidity/raydiumClmm.js.map +1 -1
- package/dist/lp.d.ts +55 -12
- package/dist/lp.d.ts.map +1 -1
- package/dist/lp.js +158 -70
- package/dist/lp.js.map +1 -1
- package/dist/lpMath.d.ts +22 -0
- package/dist/lpMath.d.ts.map +1 -1
- package/dist/lpMath.js +21 -0
- package/dist/lpMath.js.map +1 -1
- package/dist/portfolio.d.ts.map +1 -1
- package/dist/portfolio.js +3 -8
- package/dist/portfolio.js.map +1 -1
- package/dist/programs.d.ts +19 -0
- package/dist/programs.d.ts.map +1 -0
- package/dist/programs.js +175 -0
- package/dist/programs.js.map +1 -0
- package/dist/reclaim.d.ts +192 -0
- package/dist/reclaim.d.ts.map +1 -0
- package/dist/reclaim.js +399 -0
- package/dist/reclaim.js.map +1 -0
- package/dist/risk.js +2 -2
- package/dist/risk.js.map +1 -1
- package/dist/rpc.d.ts +30 -7
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +404 -111
- package/dist/rpc.js.map +1 -1
- package/dist/simulate.d.ts +106 -0
- package/dist/simulate.d.ts.map +1 -0
- package/dist/simulate.js +395 -0
- package/dist/simulate.js.map +1 -0
- package/dist/source.d.ts +8 -4
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +11 -4
- package/dist/source.js.map +1 -1
- package/dist/subpaths/approvals.d.ts +3 -3
- package/dist/subpaths/approvals.d.ts.map +1 -1
- package/dist/subpaths/approvals.js.map +1 -1
- package/dist/subpaths/claim.d.ts +31 -0
- package/dist/subpaths/claim.d.ts.map +1 -0
- package/dist/subpaths/claim.js +31 -0
- package/dist/subpaths/claim.js.map +1 -0
- package/dist/subpaths/history.d.ts +21 -0
- package/dist/subpaths/history.d.ts.map +1 -0
- package/dist/subpaths/history.js +22 -0
- package/dist/subpaths/history.js.map +1 -0
- package/dist/subpaths/lp.d.ts +2 -2
- package/dist/subpaths/lp.d.ts.map +1 -1
- package/dist/subpaths/lp.js +2 -2
- package/dist/subpaths/lp.js.map +1 -1
- package/dist/subpaths/reclaim.d.ts +34 -0
- package/dist/subpaths/reclaim.d.ts.map +1 -0
- package/dist/subpaths/reclaim.js +34 -0
- package/dist/subpaths/reclaim.js.map +1 -0
- package/dist/subpaths/simulate.d.ts +22 -0
- package/dist/subpaths/simulate.d.ts.map +1 -0
- package/dist/subpaths/simulate.js +23 -0
- package/dist/subpaths/simulate.js.map +1 -0
- package/dist/swap/index.js +3 -3
- package/dist/swap/index.js.map +1 -1
- package/dist/tokenDetails.d.ts +12 -0
- package/dist/tokenDetails.d.ts.map +1 -0
- package/dist/tokenDetails.js +196 -0
- package/dist/tokenDetails.js.map +1 -0
- package/dist/tokenMeta.d.ts +63 -0
- package/dist/tokenMeta.d.ts.map +1 -0
- package/dist/tokenMeta.js +226 -0
- package/dist/tokenMeta.js.map +1 -0
- package/dist/tradeCheck.js +3 -3
- package/dist/tradeCheck.js.map +1 -1
- package/dist/util.d.ts +22 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +107 -0
- package/dist/util.js.map +1 -0
- package/dist/write/chain.d.ts +51 -0
- package/dist/write/chain.d.ts.map +1 -0
- package/dist/write/chain.js +184 -0
- package/dist/write/chain.js.map +1 -0
- package/dist/write/collect.js +2 -2
- package/dist/write/collect.js.map +1 -1
- package/dist/write/errors.d.ts +10 -0
- package/dist/write/errors.d.ts.map +1 -0
- package/dist/write/errors.js +77 -0
- package/dist/write/errors.js.map +1 -0
- package/dist/write/instructions.d.ts +2 -0
- package/dist/write/instructions.d.ts.map +1 -1
- package/dist/write/instructions.js +16 -0
- package/dist/write/instructions.js.map +1 -1
- package/dist/write/pack.d.ts +28 -0
- package/dist/write/pack.d.ts.map +1 -0
- package/dist/write/pack.js +125 -0
- package/dist/write/pack.js.map +1 -0
- package/dist/write/send.d.ts +23 -9
- package/dist/write/send.d.ts.map +1 -1
- package/dist/write/send.js +53 -351
- package/dist/write/send.js.map +1 -1
- package/dist/write/tracker.d.ts +24 -0
- package/dist/write/tracker.d.ts.map +1 -0
- package/dist/write/tracker.js +129 -0
- package/dist/write/tracker.js.map +1 -0
- package/package.json +21 -1
- package/src/accountReader.ts +187 -0
- package/src/address.ts +41 -6
- package/src/approvals.ts +36 -44
- package/src/balances.ts +678 -0
- package/src/batchPlan.ts +117 -0
- package/src/bytes.ts +89 -0
- package/src/claim.ts +327 -0
- package/src/client.ts +59 -1239
- package/src/clientConfig.ts +12 -0
- package/src/decode.ts +176 -0
- package/src/describe.ts +77 -0
- package/src/fees.ts +2 -9
- package/src/history.ts +325 -0
- package/src/historyHelius.ts +136 -0
- package/src/index.ts +13 -1
- package/src/layout.ts +63 -82
- package/src/liquidity/common.ts +2 -2
- package/src/liquidity/orca.ts +15 -5
- package/src/liquidity/raydiumClmm.ts +13 -5
- package/src/lp.ts +226 -75
- package/src/lpMath.ts +38 -0
- package/src/portfolio.ts +8 -11
- package/src/programs.ts +216 -0
- package/src/reclaim.ts +550 -0
- package/src/risk.ts +2 -2
- package/src/rpc.ts +476 -118
- package/src/simulate.ts +517 -0
- package/src/source.ts +14 -5
- package/src/subpaths/approvals.ts +3 -2
- package/src/subpaths/claim.ts +54 -0
- package/src/subpaths/history.ts +38 -0
- package/src/subpaths/lp.ts +5 -1
- package/src/subpaths/reclaim.ts +58 -0
- package/src/subpaths/simulate.ts +37 -0
- package/src/swap/index.ts +3 -3
- package/src/tokenDetails.ts +240 -0
- package/src/tokenMeta.ts +270 -0
- package/src/tradeCheck.ts +3 -3
- package/src/util.ts +110 -0
- package/src/write/chain.ts +218 -0
- package/src/write/collect.ts +2 -2
- package/src/write/errors.ts +82 -0
- package/src/write/instructions.ts +17 -0
- package/src/write/pack.ts +142 -0
- package/src/write/send.ts +74 -407
- 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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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,
|
|
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
|
-
|
|
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 *
|
|
660
|
-
if (
|
|
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 &&
|
|
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 (
|
|
838
|
-
const
|
|
839
|
-
next
|
|
840
|
-
if (
|
|
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
|
-
|
|
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
|
}
|