@w3lib/solana-batch-call 0.2.2 → 0.3.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 +14 -0
- package/README.md +132 -2
- package/dist/client.d.ts +4 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +8 -0
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- 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 +5 -0
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +16 -0
- package/dist/layout.js.map +1 -1
- package/dist/lp.d.ts +123 -0
- package/dist/lp.d.ts.map +1 -0
- package/dist/lp.js +631 -0
- package/dist/lp.js.map +1 -0
- package/dist/lpMath.d.ts +37 -0
- package/dist/lpMath.d.ts.map +1 -0
- package/dist/lpMath.js +176 -0
- package/dist/lpMath.js.map +1 -0
- package/dist/shortcuts.d.ts +8 -0
- package/dist/shortcuts.d.ts.map +1 -1
- package/dist/shortcuts.js +1 -1
- package/dist/shortcuts.js.map +1 -1
- package/dist/subpaths/lp.d.ts +31 -0
- package/dist/subpaths/lp.d.ts.map +1 -0
- package/dist/subpaths/lp.js +33 -0
- package/dist/subpaths/lp.js.map +1 -0
- package/dist/subpaths/write.d.ts +22 -0
- package/dist/subpaths/write.d.ts.map +1 -0
- package/dist/subpaths/write.js +19 -0
- package/dist/subpaths/write.js.map +1 -0
- package/dist/write/batch.d.ts +41 -0
- package/dist/write/batch.d.ts.map +1 -0
- package/dist/write/batch.js +46 -0
- package/dist/write/batch.js.map +1 -0
- package/dist/write/collect.d.ts +50 -0
- package/dist/write/collect.d.ts.map +1 -0
- package/dist/write/collect.js +220 -0
- package/dist/write/collect.js.map +1 -0
- package/dist/write/instructions.d.ts +16 -0
- package/dist/write/instructions.d.ts.map +1 -0
- package/dist/write/instructions.js +89 -0
- package/dist/write/instructions.js.map +1 -0
- package/dist/write/send.d.ts +115 -0
- package/dist/write/send.d.ts.map +1 -0
- package/dist/write/send.js +720 -0
- package/dist/write/send.js.map +1 -0
- package/dist/write/signer.d.ts +94 -0
- package/dist/write/signer.d.ts.map +1 -0
- package/dist/write/signer.js +197 -0
- package/dist/write/signer.js.map +1 -0
- package/dist/write/transaction.d.ts +75 -0
- package/dist/write/transaction.d.ts.map +1 -0
- package/dist/write/transaction.js +133 -0
- package/dist/write/transaction.js.map +1 -0
- package/dist/write/transfer.d.ts +72 -0
- package/dist/write/transfer.d.ts.map +1 -0
- package/dist/write/transfer.js +127 -0
- package/dist/write/transfer.js.map +1 -0
- package/package.json +23 -12
- package/src/client.ts +10 -0
- package/src/index.ts +1 -0
- package/src/layout.ts +17 -0
- package/src/lp.ts +849 -0
- package/src/lpMath.ts +205 -0
- package/src/shortcuts.ts +1 -1
- package/src/subpaths/lp.ts +62 -0
- package/src/subpaths/write.ts +29 -0
- package/src/write/batch.ts +78 -0
- package/src/write/collect.ts +286 -0
- package/src/write/instructions.ts +98 -0
- package/src/write/send.ts +876 -0
- package/src/write/signer.ts +314 -0
- package/src/write/transaction.ts +181 -0
- package/src/write/transfer.ts +189 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
2
|
+
import { base58, base64 } from '@scure/base';
|
|
3
|
+
import { SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../constants.js';
|
|
4
|
+
import { RpcError, isNodeFault } from '../rpc.js';
|
|
5
|
+
import { MAX_COMPUTE_UNITS, setComputeUnitLimit, setComputeUnitPrice } from './instructions.js';
|
|
6
|
+
import { MAX_ACCOUNT_LOCKS, PACKET_DATA_SIZE, compileMessage, messageBlockhash, sameBytes, serializeTransaction, transactionSize } from './transaction.js';
|
|
7
|
+
export function emptyOutcome(status, error = null) {
|
|
8
|
+
return { status, signature: null, slot: null, fee: null, computeUnits: null, error };
|
|
9
|
+
}
|
|
10
|
+
export function toWriteResult(index, from, outcome) {
|
|
11
|
+
return {
|
|
12
|
+
index,
|
|
13
|
+
from,
|
|
14
|
+
status: outcome.status,
|
|
15
|
+
signature: outcome.signature,
|
|
16
|
+
slot: outcome.slot,
|
|
17
|
+
fee: outcome.fee?.toString() ?? null,
|
|
18
|
+
computeUnits: outcome.computeUnits,
|
|
19
|
+
error: outcome.error,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** 并发数为 limit 的 map,结果按输入顺序 */
|
|
23
|
+
export async function mapPool(items, limit, fn) {
|
|
24
|
+
const results = new Array(items.length);
|
|
25
|
+
let next = 0;
|
|
26
|
+
const worker = async () => {
|
|
27
|
+
while (next < items.length) {
|
|
28
|
+
const i = next++;
|
|
29
|
+
results[i] = await fn(items[i], i);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker));
|
|
33
|
+
return results;
|
|
34
|
+
}
|
|
35
|
+
function sleep(ms, signal) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
if (signal?.aborted) {
|
|
38
|
+
resolve();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const timer = setTimeout(done, ms);
|
|
42
|
+
function done() {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
signal?.removeEventListener('abort', done);
|
|
45
|
+
resolve();
|
|
46
|
+
}
|
|
47
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** 每个签名的基础手续费(lamports) */
|
|
51
|
+
export const LAMPORTS_PER_SIGNATURE = 5000n;
|
|
52
|
+
/** 交易手续费:签名数 × 5000 + 优先费用(单价 × 计算单元上限,向上取整) */
|
|
53
|
+
export function transactionFee(signatures, computeUnits, price) {
|
|
54
|
+
return LAMPORTS_PER_SIGNATURE * BigInt(signatures) + (price * BigInt(computeUnits) + 999999n) / 1000000n;
|
|
55
|
+
}
|
|
56
|
+
function errorText(err) {
|
|
57
|
+
return err instanceof Error ? err.message : String(err);
|
|
58
|
+
}
|
|
59
|
+
const TX_ERRORS = {
|
|
60
|
+
InsufficientFundsForFee: 'insufficient SOL for the transaction fee',
|
|
61
|
+
AccountNotFound: 'fee payer account not found (it has no SOL)',
|
|
62
|
+
BlockhashNotFound: 'blockhash not found',
|
|
63
|
+
AlreadyProcessed: 'transaction already processed',
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* 日志里最有用的一行:程序打印的 “Program log: Error: …”,其次是系统程序直接打印的说明(如 “Transfer: insufficient lamports …”,
|
|
67
|
+
* 不以 “Program ” 开头),最后才是运行时的 “Program … failed: custom program error: 0x1”
|
|
68
|
+
*/
|
|
69
|
+
function logDetail(logs) {
|
|
70
|
+
const reversed = [...logs].reverse();
|
|
71
|
+
const programError = reversed.find((line) => /^Program log: (Error|error)/.test(line));
|
|
72
|
+
if (programError)
|
|
73
|
+
return programError.replace(/^Program log: /, '');
|
|
74
|
+
const plain = reversed.find((line) => !line.startsWith('Program '));
|
|
75
|
+
if (plain)
|
|
76
|
+
return plain;
|
|
77
|
+
return reversed.find((line) => / failed: /.test(line))?.replace(/^Program \w+ failed: /, '');
|
|
78
|
+
}
|
|
79
|
+
// 常见程序的自定义错误码(上链失败时没有日志,只有错误码)
|
|
80
|
+
const SYSTEM_ERRORS = ['account already in use', 'insufficient lamports', 'invalid program id', 'invalid account data length'];
|
|
81
|
+
const TOKEN_ERRORS = {
|
|
82
|
+
0: 'account not rent-exempt',
|
|
83
|
+
1: 'insufficient funds',
|
|
84
|
+
2: 'invalid mint',
|
|
85
|
+
3: 'account not associated with this mint',
|
|
86
|
+
4: 'owner does not match',
|
|
87
|
+
9: 'account is not initialized',
|
|
88
|
+
11: 'non-native account can only be closed if its balance is zero',
|
|
89
|
+
17: 'account is frozen',
|
|
90
|
+
18: 'mint decimals mismatch',
|
|
91
|
+
};
|
|
92
|
+
function customErrorText(program, code) {
|
|
93
|
+
if (program === SYSTEM_PROGRAM_ID)
|
|
94
|
+
return SYSTEM_ERRORS[code];
|
|
95
|
+
if (program === TOKEN_PROGRAM_ID || program === TOKEN_2022_PROGRAM_ID)
|
|
96
|
+
return TOKEN_ERRORS[code];
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 交易错误的可读说明:优先取日志(见 logDetail),否则按错误类型;
|
|
101
|
+
* programs 是每条指令的程序(按下标),用来翻译常见程序的自定义错误码
|
|
102
|
+
*/
|
|
103
|
+
export function transactionErrorText(err, logs, programs) {
|
|
104
|
+
const detail = logDetail(logs ?? []);
|
|
105
|
+
if (typeof err === 'string') {
|
|
106
|
+
return TX_ERRORS[err] ?? err;
|
|
107
|
+
}
|
|
108
|
+
const instruction = err?.InstructionError;
|
|
109
|
+
if (instruction) {
|
|
110
|
+
const [index, reason] = instruction;
|
|
111
|
+
const custom = reason?.Custom;
|
|
112
|
+
const known = custom === undefined ? undefined : customErrorText(programs?.[index], custom);
|
|
113
|
+
return detail ?? known ?? (typeof reason === 'string' ? reason : JSON.stringify(reason));
|
|
114
|
+
}
|
|
115
|
+
if (err?.InsufficientFundsForRent) {
|
|
116
|
+
return 'account would be left with less SOL than the rent-exempt minimum';
|
|
117
|
+
}
|
|
118
|
+
return detail ?? JSON.stringify(err);
|
|
119
|
+
}
|
|
120
|
+
function isBudgetExceeded(err, logs) {
|
|
121
|
+
const reason = err?.InstructionError?.[1];
|
|
122
|
+
return reason === 'ComputationalBudgetExceeded' || (logs ?? []).some((l) => /exceeded CUs meter|compute units/i.test(l) && /exceed/i.test(l));
|
|
123
|
+
}
|
|
124
|
+
// ---- 节点访问 ----
|
|
125
|
+
/**
|
|
126
|
+
* 最近发出过的交易消息(sha256):同样的消息签名也相同,再发一次只会被节点当成同一笔。
|
|
127
|
+
* 在签名之前检查(钱包签名的交易也适用),重复时调整计算单元上限让消息不同
|
|
128
|
+
*/
|
|
129
|
+
const recentMessages = new Set();
|
|
130
|
+
function messageKey(message) {
|
|
131
|
+
return base64.encode(sha256(message));
|
|
132
|
+
}
|
|
133
|
+
function remember(message) {
|
|
134
|
+
recentMessages.add(messageKey(message));
|
|
135
|
+
if (recentMessages.size > 10_000) {
|
|
136
|
+
recentMessages.delete(recentMessages.values().next().value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** 一批发送共用的节点访问:blockhash(缓存 10 秒)、优先费用(按账户缓存 10 秒) */
|
|
140
|
+
class Chain {
|
|
141
|
+
client;
|
|
142
|
+
options;
|
|
143
|
+
#blockhash = null;
|
|
144
|
+
#fees = new Map();
|
|
145
|
+
constructor(client, options) {
|
|
146
|
+
this.client = client;
|
|
147
|
+
this.options = options;
|
|
148
|
+
}
|
|
149
|
+
get commitment() {
|
|
150
|
+
return this.options.commitment ?? 'confirmed';
|
|
151
|
+
}
|
|
152
|
+
#walletChain = null;
|
|
153
|
+
/** Wallet Standard 的链标识('solana:mainnet' 等);网络未知时为 undefined */
|
|
154
|
+
walletChain() {
|
|
155
|
+
this.#walletChain ??= this.client.getCluster().then((cluster) => (cluster ? `solana:${cluster}` : undefined), () => undefined);
|
|
156
|
+
return this.#walletChain;
|
|
157
|
+
}
|
|
158
|
+
blockhash(fresh = false) {
|
|
159
|
+
if (fresh || !this.#blockhash || Date.now() - this.#blockhash.at > 10_000) {
|
|
160
|
+
const value = this.client
|
|
161
|
+
.request('getLatestBlockhash', [{ commitment: 'confirmed' }])
|
|
162
|
+
.then((res) => res.value);
|
|
163
|
+
value.catch(() => {
|
|
164
|
+
if (this.#blockhash?.value === value)
|
|
165
|
+
this.#blockhash = null;
|
|
166
|
+
});
|
|
167
|
+
this.#blockhash = { at: Date.now(), value };
|
|
168
|
+
}
|
|
169
|
+
return this.#blockhash.value;
|
|
170
|
+
}
|
|
171
|
+
async priorityFee(writable) {
|
|
172
|
+
const option = this.options.priorityFee ?? 'auto';
|
|
173
|
+
if (option !== 'auto') {
|
|
174
|
+
return BigInt(option);
|
|
175
|
+
}
|
|
176
|
+
const accounts = [...writable].sort().slice(0, 128);
|
|
177
|
+
const key = accounts.join(',');
|
|
178
|
+
let cached = this.#fees.get(key);
|
|
179
|
+
if (!cached || Date.now() - cached.at > 10_000) {
|
|
180
|
+
const cap = BigInt(this.options.maxPriorityFee ?? 1_000_000);
|
|
181
|
+
const value = this.client
|
|
182
|
+
.request('getRecentPrioritizationFees', [accounts])
|
|
183
|
+
.then((list) => {
|
|
184
|
+
const fees = list.map((f) => BigInt(f.prioritizationFee)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
185
|
+
const p75 = fees.length ? fees[Math.min(fees.length - 1, Math.floor(fees.length * 0.75))] : 0n;
|
|
186
|
+
return p75 < cap ? p75 : cap;
|
|
187
|
+
})
|
|
188
|
+
.catch(() => 0n); // 节点不支持时不加优先费用
|
|
189
|
+
cached = { at: Date.now(), value };
|
|
190
|
+
this.#fees.set(key, cached);
|
|
191
|
+
}
|
|
192
|
+
return cached.value;
|
|
193
|
+
}
|
|
194
|
+
async simulate(message, minContextSlot) {
|
|
195
|
+
const raw = base64.encode(serializeTransaction(message, message.signers.map(() => null)));
|
|
196
|
+
const config = { encoding: 'base64', sigVerify: false, replaceRecentBlockhash: true, commitment: 'confirmed' };
|
|
197
|
+
if (minContextSlot !== undefined) {
|
|
198
|
+
config.minContextSlot = minContextSlot;
|
|
199
|
+
}
|
|
200
|
+
// 节点暂时的问题(限频、高度还没到 minContextSlot 等)重试几次,不当成调用失败
|
|
201
|
+
for (let attempt = 0;; attempt++) {
|
|
202
|
+
try {
|
|
203
|
+
const res = await this.client.request('simulateTransaction', [raw, config]);
|
|
204
|
+
return res.value;
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (attempt >= 3 || (err instanceof RpcError && !isNodeFault(err)))
|
|
208
|
+
throw err;
|
|
209
|
+
await sleep(500 * (attempt + 1), this.options.signal);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** 广播:'accepted';节点明确拒绝时抛出;节点都连不上时返回错误说明(交易可能已经发出) */
|
|
214
|
+
async broadcast(raw) {
|
|
215
|
+
try {
|
|
216
|
+
await this.client.request('sendTransaction', [raw, { encoding: 'base64', skipPreflight: true, maxRetries: 0 }]);
|
|
217
|
+
return 'accepted';
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
if (/already (been )?processed/i.test(errorText(err))) {
|
|
221
|
+
return 'accepted';
|
|
222
|
+
}
|
|
223
|
+
if (err instanceof RpcError && !isNodeFault(err)) {
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
return { uncertain: `broadcast not acknowledged: ${errorText(err)}` };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** 按 SendOptions 取优先费用单价(micro-lamports / 计算单元);writable 是交易里的可写账户 */
|
|
231
|
+
export function priorityFeeFor(client, options, writable) {
|
|
232
|
+
return new Chain(client, options).priorityFee(writable);
|
|
233
|
+
}
|
|
234
|
+
/** 统一轮询所有待确认的交易:一次 getSignatureStatuses 查全部(每 256 个一组),未确认的每次重发,blockhash 过期后再查一次历史确认没上链 */
|
|
235
|
+
class Tracker {
|
|
236
|
+
chain;
|
|
237
|
+
#watched = new Set();
|
|
238
|
+
#running = false;
|
|
239
|
+
constructor(chain) {
|
|
240
|
+
this.chain = chain;
|
|
241
|
+
}
|
|
242
|
+
watch(signature, raw, lastValidBlockHeight) {
|
|
243
|
+
const { timeout = 120_000 } = this.chain.options;
|
|
244
|
+
return new Promise((resolve) => {
|
|
245
|
+
this.#watched.add({ signature, raw, lastValidBlockHeight, deadline: Date.now() + timeout, polls: 0, resolve });
|
|
246
|
+
if (!this.#running) {
|
|
247
|
+
this.#running = true;
|
|
248
|
+
void this.#loop();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/** 查交易状态;contextSlot 是各次响应里最小的 context.slot(节点的数据新到哪个 slot) */
|
|
253
|
+
async #statuses(signatures, history) {
|
|
254
|
+
const found = new Map();
|
|
255
|
+
let contextSlot = Number.POSITIVE_INFINITY;
|
|
256
|
+
for (let i = 0; i < signatures.length; i += 256) {
|
|
257
|
+
const chunk = signatures.slice(i, i + 256);
|
|
258
|
+
const res = await this.chain.client.request('getSignatureStatuses', [
|
|
259
|
+
chunk,
|
|
260
|
+
{ searchTransactionHistory: history },
|
|
261
|
+
]);
|
|
262
|
+
contextSlot = Math.min(contextSlot, res.context?.slot ?? 0);
|
|
263
|
+
res.value.forEach((s, k) => s && found.set(chunk[k], s));
|
|
264
|
+
}
|
|
265
|
+
return { found, contextSlot };
|
|
266
|
+
}
|
|
267
|
+
#done(status) {
|
|
268
|
+
const level = status.confirmationStatus;
|
|
269
|
+
return this.chain.commitment === 'finalized' ? level === 'finalized' : level === 'confirmed' || level === 'finalized';
|
|
270
|
+
}
|
|
271
|
+
#settle(item, result) {
|
|
272
|
+
this.#watched.delete(item);
|
|
273
|
+
item.resolve(result);
|
|
274
|
+
}
|
|
275
|
+
async #loop() {
|
|
276
|
+
const { pollInterval = 2_000, signal } = this.chain.options;
|
|
277
|
+
try {
|
|
278
|
+
while (this.#watched.size) {
|
|
279
|
+
await sleep(pollInterval, signal);
|
|
280
|
+
if (signal?.aborted) {
|
|
281
|
+
;
|
|
282
|
+
[...this.#watched].forEach((w) => this.#settle(w, { kind: 'aborted' }));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const items = [...this.#watched];
|
|
286
|
+
try {
|
|
287
|
+
const { found: statuses } = await this.#statuses(items.map((w) => w.signature), false);
|
|
288
|
+
for (const item of items) {
|
|
289
|
+
const status = statuses.get(item.signature);
|
|
290
|
+
if (status && this.#done(status)) {
|
|
291
|
+
this.#settle(item, { kind: 'landed', slot: status.slot, err: status.err });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const waiting = items.filter((w) => this.#watched.has(w));
|
|
295
|
+
if (!waiting.length) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
// 区块高度和对应的 slot 一起取(同一个节点、同一个时刻)
|
|
299
|
+
const epoch = await this.chain.client.request('getEpochInfo', [{ commitment: 'confirmed' }]);
|
|
300
|
+
const expired = waiting.filter((w) => epoch.blockHeight > w.lastValidBlockHeight);
|
|
301
|
+
if (expired.length) {
|
|
302
|
+
// 过期了:再查一次历史确认确实没上链。请求可能落到另一个节点(多节点 / 负载均衡),
|
|
303
|
+
// 它的数据必须不早于看到过期的那个 slot,否则可能只是它还没看到这笔交易,这一轮先不判断
|
|
304
|
+
const history = await this.#statuses(expired.map((w) => w.signature), true);
|
|
305
|
+
const caughtUp = history.contextSlot >= epoch.absoluteSlot;
|
|
306
|
+
for (const item of expired) {
|
|
307
|
+
const status = history.found.get(item.signature);
|
|
308
|
+
if (!status) {
|
|
309
|
+
if (!caughtUp)
|
|
310
|
+
continue;
|
|
311
|
+
this.#settle(item, { kind: 'expired' });
|
|
312
|
+
}
|
|
313
|
+
else if (this.#done(status)) {
|
|
314
|
+
this.#settle(item, { kind: 'landed', slot: status.slot, err: status.err });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// 节点暂时出错:下次再查
|
|
321
|
+
}
|
|
322
|
+
const now = Date.now();
|
|
323
|
+
for (const item of [...this.#watched]) {
|
|
324
|
+
if (now >= item.deadline) {
|
|
325
|
+
this.#settle(item, { kind: 'timeout' });
|
|
326
|
+
}
|
|
327
|
+
else if (++item.polls % 2 === 0) {
|
|
328
|
+
// 每两次轮询重发一次(每笔都重发会让待确认交易多时很快触发节点限频)
|
|
329
|
+
void this.chain.broadcast(item.raw).catch(() => { });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
this.#running = false;
|
|
336
|
+
if (this.#watched.size) {
|
|
337
|
+
this.#running = true;
|
|
338
|
+
void this.#loop();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// ---- 发送 ----
|
|
344
|
+
/** 打包用的占位 blockhash(大小与真实值相同) */
|
|
345
|
+
const PLACEHOLDER_BLOCKHASH = '11111111111111111111111111111111';
|
|
346
|
+
function budgetInstructions(units, price) {
|
|
347
|
+
return [setComputeUnitLimit(units), setComputeUnitPrice(price)];
|
|
348
|
+
}
|
|
349
|
+
function fits(payer, units) {
|
|
350
|
+
const message = compileMessage(payer, [...budgetInstructions(MAX_COMPUTE_UNITS, 0n), ...units.flatMap((u) => u.instructions)], PLACEHOLDER_BLOCKHASH);
|
|
351
|
+
return transactionSize(message) <= PACKET_DATA_SIZE && message.accountKeys.length <= MAX_ACCOUNT_LOCKS;
|
|
352
|
+
}
|
|
353
|
+
/** 交易错误对应的调用:InstructionError 的指令下标(前两条是计算预算指令),或租金不足的账户所在的调用 */
|
|
354
|
+
function failingUnit(err, units, message) {
|
|
355
|
+
const instruction = err?.InstructionError;
|
|
356
|
+
if (instruction) {
|
|
357
|
+
let offset = instruction[0] - 2;
|
|
358
|
+
for (let i = 0; i < units.length; i++) {
|
|
359
|
+
const count = units[i].instructions.length;
|
|
360
|
+
if (offset < count) {
|
|
361
|
+
return offset >= 0 ? i : null;
|
|
362
|
+
}
|
|
363
|
+
offset -= count;
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
const rent = err?.InsufficientFundsForRent;
|
|
368
|
+
if (rent?.account_index !== undefined) {
|
|
369
|
+
// 账户余额是按顺序被扣的:从最后一个用到它的调用开始剔除(如付款方转出太多时,先剔除最后的转账)
|
|
370
|
+
const account = message.accountKeys[rent.account_index];
|
|
371
|
+
for (let i = units.length - 1; i >= 0; i--) {
|
|
372
|
+
if (units[i].instructions.some((ix) => ix.keys.some((k) => k.pubkey === account && k.isWritable)))
|
|
373
|
+
return i;
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* 发送一批调用:按手续费支付者分组,组内按顺序把相邻的调用打包成交易(不超过交易大小上限),
|
|
381
|
+
* 逐笔模拟(失败的调用剔除后重新模拟其余的)、签名、发送,不等上一笔确认;不同支付者并发。
|
|
382
|
+
* 模拟失败且同一支付者还有未确认的交易时,先等它们确认再模拟一次(后一笔依赖前一笔时自动正确处理)
|
|
383
|
+
*/
|
|
384
|
+
export async function sendUnits(client, units, options, pack = {}) {
|
|
385
|
+
const chain = new Chain(client, options);
|
|
386
|
+
const tracker = new Tracker(chain);
|
|
387
|
+
const groups = new Map();
|
|
388
|
+
units.forEach((u) => {
|
|
389
|
+
const list = groups.get(u.payer.address);
|
|
390
|
+
if (list)
|
|
391
|
+
list.push(u);
|
|
392
|
+
else
|
|
393
|
+
groups.set(u.payer.address, [u]);
|
|
394
|
+
});
|
|
395
|
+
await mapPool([...groups.values()], options.concurrency ?? 8, (list) => sendGroup(chain, tracker, list, options, pack.batchSize ?? Number.POSITIVE_INFINITY));
|
|
396
|
+
}
|
|
397
|
+
function isRejection(err) {
|
|
398
|
+
const e = err;
|
|
399
|
+
return e?.code === 4001 || /reject|denied|cancel|declined/i.test(`${String(e?.name ?? '')} ${String(e?.message ?? '')}`);
|
|
400
|
+
}
|
|
401
|
+
async function sendGroup(chain, tracker, units, options, batchSize) {
|
|
402
|
+
const { signal, dryRun = false, wait = true, retries = 2 } = options;
|
|
403
|
+
const payer = units[0].payer;
|
|
404
|
+
/** 有浏览器钱包等外部签名者:先准备好全部交易,再让钱包一次签完 */
|
|
405
|
+
const deferred = units.some((u) => u.payer.kind === 'wallet' || u.signers.some((s) => s.kind === 'wallet'));
|
|
406
|
+
/** 已发出的交易:sent 为 true 表示还没有结果;watch() 开始等待确认(wait 为 false 时只在需要时才等) */
|
|
407
|
+
const inFlight = [];
|
|
408
|
+
/** 同一支付者已上链交易的最高 slot(之后的模拟不早于它) */
|
|
409
|
+
let minSlot;
|
|
410
|
+
let stopped = false;
|
|
411
|
+
const settle = async () => {
|
|
412
|
+
// 等待期间可能有过期重发的新交易加进来:直到没有新的为止
|
|
413
|
+
for (let seen = -1; seen !== inFlight.length;) {
|
|
414
|
+
seen = inFlight.length;
|
|
415
|
+
await Promise.all(inFlight.map((f) => f.watch()));
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
const update = (unit, patch) => {
|
|
419
|
+
try {
|
|
420
|
+
unit.onUpdate({ ...emptyOutcome(patch.status), ...patch });
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
// 调用方的进度回调出错不能打断发送(已经发出的交易还要继续跟踪)
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
const skip = (list, error) => list.forEach((u) => update(u, { status: 'skipped', error }));
|
|
427
|
+
const fail = (list, error) => {
|
|
428
|
+
list.forEach((u) => update(u, { status: 'failed', error }));
|
|
429
|
+
if (list.some((u) => !u.continueOnError))
|
|
430
|
+
stopped = true;
|
|
431
|
+
};
|
|
432
|
+
/**
|
|
433
|
+
* 准备一批调用:模拟,剔除失败的调用后重新模拟其余的,计算单元超出单笔上限时拆成两笔。返回待签名的交易(可能 0 笔或多笔)。
|
|
434
|
+
* attempt:过期后第几次重发
|
|
435
|
+
*/
|
|
436
|
+
const prepare = async (batch, attempt) => {
|
|
437
|
+
let pending = [...batch];
|
|
438
|
+
// 过期重发时不再等其他交易:这次调用本身就在某笔交易的等待回调里,等全部交易会等到自己(死锁);依赖的交易早已上链
|
|
439
|
+
let waited = attempt > 0;
|
|
440
|
+
while (pending.length) {
|
|
441
|
+
if (signal?.aborted) {
|
|
442
|
+
skip(pending, 'aborted');
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const fixed = pending.length === 1 ? pending[0].fixed : undefined;
|
|
447
|
+
const instructions = pending.flatMap((u) => u.instructions);
|
|
448
|
+
const blockhash = await chain.blockhash(attempt > 0);
|
|
449
|
+
const probe = compileMessage(payer.address, [...budgetInstructions(MAX_COMPUTE_UNITS, 0n), ...instructions], blockhash.blockhash);
|
|
450
|
+
const price = fixed?.price ?? (await chain.priorityFee(probe.writable));
|
|
451
|
+
// 模拟时计算单元上限取最大值,优先费用单价按 0(否则节点按 140 万 × 单价预扣手续费,余额少的付款方会被误判为付不起);
|
|
452
|
+
// 算出真实的上限后再按真实费用复核一次
|
|
453
|
+
const simMessage = compileMessage(payer.address, [...budgetInstructions(fixed?.computeUnits ?? MAX_COMPUTE_UNITS, fixed ? price : 0n), ...instructions], blockhash.blockhash);
|
|
454
|
+
const sim = await chain.simulate(simMessage, minSlot);
|
|
455
|
+
if (sim.err) {
|
|
456
|
+
if (!waited && inFlight.some((f) => f.sent)) {
|
|
457
|
+
// 可能依赖前面还没确认的交易:等它们确认后重新模拟
|
|
458
|
+
waited = true;
|
|
459
|
+
await settle();
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (isBudgetExceeded(sim.err, sim.logs) && pending.length > 1) {
|
|
463
|
+
// 计算单元超出单笔上限:拆成两笔
|
|
464
|
+
const half = Math.ceil(pending.length / 2);
|
|
465
|
+
const first = await prepare(pending.slice(0, half), attempt);
|
|
466
|
+
if (stopped) {
|
|
467
|
+
skip(pending.slice(half), 'skipped: a previous call failed');
|
|
468
|
+
return first;
|
|
469
|
+
}
|
|
470
|
+
return [...first, ...(await prepare(pending.slice(half), attempt))];
|
|
471
|
+
}
|
|
472
|
+
const text = transactionErrorText(sim.err, sim.logs);
|
|
473
|
+
const index = failingUnit(sim.err, pending, simMessage);
|
|
474
|
+
if (index === null || pending.length === 1) {
|
|
475
|
+
fail(pending, text);
|
|
476
|
+
return [];
|
|
477
|
+
}
|
|
478
|
+
const failed = pending[index];
|
|
479
|
+
update(failed, { status: 'failed', error: text });
|
|
480
|
+
if (!failed.continueOnError) {
|
|
481
|
+
// 后面的调用不再执行;前面的照常发送
|
|
482
|
+
stopped = true;
|
|
483
|
+
skip(pending.slice(index + 1), 'skipped: a previous call failed');
|
|
484
|
+
pending = pending.slice(0, index);
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
pending.splice(index, 1);
|
|
488
|
+
}
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const consumed = sim.unitsConsumed ?? null;
|
|
492
|
+
const limit = fixed?.computeUnits ?? (consumed ? Math.min(MAX_COMPUTE_UNITS, Math.ceil(consumed * 1.1) + 1000) : MAX_COMPUTE_UNITS);
|
|
493
|
+
if (!fixed && price > 0n) {
|
|
494
|
+
const check = await chain.simulate(compileMessage(payer.address, [...budgetInstructions(limit, price), ...instructions], blockhash.blockhash), minSlot);
|
|
495
|
+
if (check.err) {
|
|
496
|
+
fail(pending, transactionErrorText(check.err, check.logs));
|
|
497
|
+
return [];
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (dryRun) {
|
|
501
|
+
const fee = transactionFee(simMessage.signers.length, limit, price);
|
|
502
|
+
pending.forEach((u) => update(u, { status: 'simulated', fee, computeUnits: consumed }));
|
|
503
|
+
return [];
|
|
504
|
+
}
|
|
505
|
+
return [{ units: pending, instructions, blockhash, price, limit, consumed, fixed: Boolean(fixed) }];
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
fail(pending, errorText(err));
|
|
509
|
+
return [];
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return [];
|
|
513
|
+
};
|
|
514
|
+
/** 签名(外部钱包一次签完)并发送,等待确认;过期时重新准备、签名、发送 */
|
|
515
|
+
const finish = async (list, attempt) => {
|
|
516
|
+
if (!list.length)
|
|
517
|
+
return;
|
|
518
|
+
const txs = list.map((p) => {
|
|
519
|
+
// 同样的消息签名也相同、会被节点当成同一笔:调整计算单元上限让消息不同
|
|
520
|
+
let limit = p.limit;
|
|
521
|
+
let message = compileMessage(payer.address, [...budgetInstructions(limit, p.price), ...p.instructions], p.blockhash.blockhash);
|
|
522
|
+
while (!p.fixed && recentMessages.has(messageKey(message.bytes))) {
|
|
523
|
+
limit += 1;
|
|
524
|
+
message = compileMessage(payer.address, [...budgetInstructions(limit, p.price), ...p.instructions], p.blockhash.blockhash);
|
|
525
|
+
}
|
|
526
|
+
remember(message.bytes);
|
|
527
|
+
const signers = new Map([[payer.address, payer]]);
|
|
528
|
+
p.units.forEach((u) => u.signers.forEach((s) => signers.set(s.address, s)));
|
|
529
|
+
return {
|
|
530
|
+
prepared: p,
|
|
531
|
+
message: message.bytes,
|
|
532
|
+
signerList: message.signers,
|
|
533
|
+
signatures: message.signers.map(() => null),
|
|
534
|
+
signers,
|
|
535
|
+
fee: transactionFee(message.signers.length, limit, p.price),
|
|
536
|
+
failed: null,
|
|
537
|
+
modified: false,
|
|
538
|
+
lastValidBlockHeight: p.blockhash.lastValidBlockHeight,
|
|
539
|
+
};
|
|
540
|
+
});
|
|
541
|
+
for (const tx of txs) {
|
|
542
|
+
const missing = tx.signerList.find((s) => !tx.signers.has(s));
|
|
543
|
+
if (missing)
|
|
544
|
+
tx.failed = `missing signer ${missing}`;
|
|
545
|
+
}
|
|
546
|
+
// 外部钱包先签(有的钱包只在没有其他签名时才会往交易里加指令),一个钱包一次签完所有交易
|
|
547
|
+
const wallets = new Map();
|
|
548
|
+
txs.forEach((tx) => tx.signers.forEach((s) => s.kind === 'wallet' && wallets.set(s.address, s)));
|
|
549
|
+
for (const wallet of wallets.values()) {
|
|
550
|
+
const needing = txs.filter((tx) => !tx.failed && tx.signerList.includes(wallet.address));
|
|
551
|
+
if (!needing.length)
|
|
552
|
+
continue;
|
|
553
|
+
try {
|
|
554
|
+
const signed = await wallet.signTransactions(needing.map((tx) => ({ message: tx.message, signers: tx.signerList, signatures: tx.signatures })), await chain.walletChain());
|
|
555
|
+
needing.forEach((tx, k) => {
|
|
556
|
+
const result = signed[k];
|
|
557
|
+
if (sameBytes(result.message, tx.message)) {
|
|
558
|
+
const index = tx.signerList.indexOf(wallet.address);
|
|
559
|
+
tx.signatures[index] = result.signatures[result.signers.indexOf(wallet.address)] ?? null;
|
|
560
|
+
if (!tx.signatures[index])
|
|
561
|
+
tx.failed = 'wallet did not sign the transaction';
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
// 钱包改了交易(如加了优先费用指令):只有它一个签名者时接受(用户在钱包里确认过);还有其他签名者时不让本地私钥去签钱包改过的内容
|
|
565
|
+
if (tx.signerList.length > 1 || result.signers.length > 1) {
|
|
566
|
+
tx.failed = 'the wallet modified the transaction; this is not supported when other signers are involved';
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
tx.message = result.message;
|
|
570
|
+
tx.signerList = result.signers;
|
|
571
|
+
tx.signatures = result.signatures;
|
|
572
|
+
tx.fee = null; // 钱包可能改了计算单元或优先费用
|
|
573
|
+
tx.modified = true;
|
|
574
|
+
remember(result.message);
|
|
575
|
+
// 钱包换了 blockhash:不知道新 blockhash 的有效期,不判断过期、不自动重发(误判过期再发一笔可能两笔都上链)
|
|
576
|
+
if (messageBlockhash(result.message) !== tx.prepared.blockhash.blockhash)
|
|
577
|
+
tx.lastValidBlockHeight = Number.POSITIVE_INFINITY;
|
|
578
|
+
if (!tx.signatures[0])
|
|
579
|
+
tx.failed = 'wallet did not sign the transaction';
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
const text = isRejection(err) ? 'rejected by user' : errorText(err);
|
|
584
|
+
needing.forEach((tx) => (tx.failed = text));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// 本地签名
|
|
588
|
+
for (const tx of txs) {
|
|
589
|
+
if (tx.failed)
|
|
590
|
+
continue;
|
|
591
|
+
try {
|
|
592
|
+
await Promise.all(tx.signerList.map(async (s, i) => {
|
|
593
|
+
const signer = tx.signers.get(s);
|
|
594
|
+
if (signer?.kind === 'local')
|
|
595
|
+
tx.signatures[i] = await signer.sign(tx.message);
|
|
596
|
+
}));
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
tx.failed = errorText(err);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/** 这一轮里签名 / 发送失败、且不允许继续的交易之后的交易都跳过(模拟阶段失败的调用排在已准备好的交易之后,不影响它们) */
|
|
603
|
+
let halted = false;
|
|
604
|
+
const halt = (units, error) => {
|
|
605
|
+
fail(units, error);
|
|
606
|
+
if (units.some((u) => !u.continueOnError))
|
|
607
|
+
halted = true;
|
|
608
|
+
};
|
|
609
|
+
for (const tx of txs) {
|
|
610
|
+
const { prepared } = tx;
|
|
611
|
+
if (signal?.aborted || halted) {
|
|
612
|
+
skip(prepared.units, signal?.aborted ? 'aborted' : 'skipped: a previous call failed');
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
if (tx.failed) {
|
|
616
|
+
halt(prepared.units, tx.failed);
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
const signature = base58.encode(tx.signatures[0]);
|
|
620
|
+
const raw = base64.encode(serializeTransaction({ bytes: tx.message, signers: tx.signerList }, tx.signatures));
|
|
621
|
+
let sent;
|
|
622
|
+
try {
|
|
623
|
+
sent = await chain.broadcast(raw);
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
halt(prepared.units, errorText(err));
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
const fee = tx.fee;
|
|
630
|
+
const consumed = prepared.consumed;
|
|
631
|
+
const note = sent === 'accepted' ? null : sent.uncertain;
|
|
632
|
+
prepared.units.forEach((u) => update(u, { status: 'sent', signature, fee, computeUnits: consumed, error: note }));
|
|
633
|
+
const landed = prepared.units;
|
|
634
|
+
/** 交易里每条指令的程序(翻译上链失败的错误码用);钱包改过的交易不知道对应关系 */
|
|
635
|
+
const programs = tx.modified ? undefined : [...budgetInstructions(0, 0n), ...prepared.instructions].map((ix) => ix.programId);
|
|
636
|
+
const layout = compileMessage(payer.address, [...budgetInstructions(0, 0n), ...prepared.instructions], prepared.blockhash.blockhash);
|
|
637
|
+
let watching = null;
|
|
638
|
+
const flight = { sent: true, watch: () => (watching ??= follow()) };
|
|
639
|
+
const follow = () => tracker.watch(signature, raw, tx.lastValidBlockHeight).then(async (result) => {
|
|
640
|
+
flight.sent = false;
|
|
641
|
+
const base = { signature, fee, computeUnits: consumed };
|
|
642
|
+
if (result.kind === 'landed') {
|
|
643
|
+
minSlot = Math.max(minSlot ?? 0, result.slot);
|
|
644
|
+
if (result.err === null || result.err === undefined) {
|
|
645
|
+
landed.forEach((u) => update(u, { ...base, status: 'confirmed', slot: result.slot }));
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
const index = tx.modified ? null : failingUnit(result.err, landed, layout);
|
|
649
|
+
const text = transactionErrorText(result.err, null, programs);
|
|
650
|
+
landed.forEach((u, k) => update(u, { ...base, status: 'reverted', slot: result.slot, error: index === null || k === index ? text : 'reverted: another call in the same transaction failed' }));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
else if (result.kind === 'expired') {
|
|
654
|
+
if (attempt < retries) {
|
|
655
|
+
await finish(await prepare(landed, attempt + 1), attempt + 1);
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
landed.forEach((u) => update(u, { status: 'failed', error: 'transaction expired before it was confirmed (blockhash no longer valid)' }));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
else if (result.kind === 'timeout') {
|
|
662
|
+
landed.forEach((u) => update(u, { ...base, status: 'pending', error: `not confirmed within ${options.timeout ?? 120_000}ms` }));
|
|
663
|
+
}
|
|
664
|
+
// aborted:保持 sent
|
|
665
|
+
});
|
|
666
|
+
inFlight.push(flight);
|
|
667
|
+
if (wait) {
|
|
668
|
+
void flight.watch();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
// 打包:从下一个调用开始,依次加入后面的调用,直到超出交易大小 / 账户数上限
|
|
673
|
+
let next = 0;
|
|
674
|
+
const nextBatch = () => {
|
|
675
|
+
while (next < units.length) {
|
|
676
|
+
const first = units[next];
|
|
677
|
+
next++;
|
|
678
|
+
if (!fits(payer.address, [first])) {
|
|
679
|
+
update(first, { status: 'failed', error: `transaction too large (over ${PACKET_DATA_SIZE} bytes or ${MAX_ACCOUNT_LOCKS} accounts)` });
|
|
680
|
+
if (!first.continueOnError)
|
|
681
|
+
stopped = true;
|
|
682
|
+
if (stopped)
|
|
683
|
+
return null;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
const batch = [first];
|
|
687
|
+
while (!first.fixed && next < units.length && batch.length < batchSize) {
|
|
688
|
+
const candidate = units[next];
|
|
689
|
+
if (candidate.fixed || !fits(payer.address, [...batch, candidate]))
|
|
690
|
+
break;
|
|
691
|
+
batch.push(candidate);
|
|
692
|
+
next++;
|
|
693
|
+
}
|
|
694
|
+
return batch;
|
|
695
|
+
}
|
|
696
|
+
return null;
|
|
697
|
+
};
|
|
698
|
+
const skipRest = () => skip(units.slice(next), signal?.aborted ? 'aborted' : 'skipped: a previous call failed');
|
|
699
|
+
if (deferred) {
|
|
700
|
+
// 先全部准备好(模拟),再一次签名、发送:钱包只弹一次确认。依赖同一批里前面交易结果的调用模拟会失败(还没有交易发出)
|
|
701
|
+
const prepared = [];
|
|
702
|
+
for (let batch = nextBatch(); batch; batch = stopped || signal?.aborted ? null : nextBatch()) {
|
|
703
|
+
prepared.push(...(await prepare(batch, 0)));
|
|
704
|
+
}
|
|
705
|
+
if (stopped || signal?.aborted)
|
|
706
|
+
skipRest();
|
|
707
|
+
await finish(prepared, 0);
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
for (let batch = nextBatch(); batch; batch = stopped || signal?.aborted ? null : nextBatch()) {
|
|
711
|
+
await finish(await prepare(batch, 0), 0);
|
|
712
|
+
}
|
|
713
|
+
if (stopped || signal?.aborted)
|
|
714
|
+
skipRest();
|
|
715
|
+
}
|
|
716
|
+
if (wait) {
|
|
717
|
+
await settle();
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
//# sourceMappingURL=send.js.map
|