@w3lib/evm-batch-call 0.4.2 → 0.5.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 (88) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +165 -2
  3. package/dist/fallback.d.ts +5 -0
  4. package/dist/fallback.d.ts.map +1 -1
  5. package/dist/fallback.js +7 -0
  6. package/dist/fallback.js.map +1 -1
  7. package/dist/lp.d.ts +149 -0
  8. package/dist/lp.d.ts.map +1 -0
  9. package/dist/lp.js +499 -0
  10. package/dist/lp.js.map +1 -0
  11. package/dist/lpMath.d.ts +25 -0
  12. package/dist/lpMath.d.ts.map +1 -0
  13. package/dist/lpMath.js +103 -0
  14. package/dist/lpMath.js.map +1 -0
  15. package/dist/lpV4.d.ts +108 -0
  16. package/dist/lpV4.d.ts.map +1 -0
  17. package/dist/lpV4.js +283 -0
  18. package/dist/lpV4.js.map +1 -0
  19. package/dist/owner.d.ts +1 -1
  20. package/dist/owner.d.ts.map +1 -1
  21. package/dist/owner.js +18 -5
  22. package/dist/owner.js.map +1 -1
  23. package/dist/scan.d.ts +54 -0
  24. package/dist/scan.d.ts.map +1 -1
  25. package/dist/scan.js +124 -92
  26. package/dist/scan.js.map +1 -1
  27. package/dist/source.d.ts +2 -0
  28. package/dist/source.d.ts.map +1 -1
  29. package/dist/source.js +4 -0
  30. package/dist/source.js.map +1 -1
  31. package/dist/subpaths/lp.d.ts +33 -0
  32. package/dist/subpaths/lp.d.ts.map +1 -0
  33. package/dist/subpaths/lp.js +47 -0
  34. package/dist/subpaths/lp.js.map +1 -0
  35. package/dist/subpaths/write.d.ts +20 -0
  36. package/dist/subpaths/write.d.ts.map +1 -0
  37. package/dist/subpaths/write.js +18 -0
  38. package/dist/subpaths/write.js.map +1 -0
  39. package/dist/tron.d.ts +2 -0
  40. package/dist/tron.d.ts.map +1 -1
  41. package/dist/tron.js +4 -0
  42. package/dist/tron.js.map +1 -1
  43. package/dist/write/batch.d.ts +88 -0
  44. package/dist/write/batch.d.ts.map +1 -0
  45. package/dist/write/batch.js +231 -0
  46. package/dist/write/batch.js.map +1 -0
  47. package/dist/write/collect.d.ts +53 -0
  48. package/dist/write/collect.d.ts.map +1 -0
  49. package/dist/write/collect.js +255 -0
  50. package/dist/write/collect.js.map +1 -0
  51. package/dist/write/rpc.d.ts +68 -0
  52. package/dist/write/rpc.d.ts.map +1 -0
  53. package/dist/write/rpc.js +236 -0
  54. package/dist/write/rpc.js.map +1 -0
  55. package/dist/write/send.d.ts +124 -0
  56. package/dist/write/send.d.ts.map +1 -0
  57. package/dist/write/send.js +373 -0
  58. package/dist/write/send.js.map +1 -0
  59. package/dist/write/signer.d.ts +58 -0
  60. package/dist/write/signer.d.ts.map +1 -0
  61. package/dist/write/signer.js +41 -0
  62. package/dist/write/signer.js.map +1 -0
  63. package/dist/write/transfer.d.ts +83 -0
  64. package/dist/write/transfer.d.ts.map +1 -0
  65. package/dist/write/transfer.js +259 -0
  66. package/dist/write/transfer.js.map +1 -0
  67. package/dist/write/tron.d.ts +58 -0
  68. package/dist/write/tron.d.ts.map +1 -0
  69. package/dist/write/tron.js +956 -0
  70. package/dist/write/tron.js.map +1 -0
  71. package/package.json +27 -14
  72. package/src/fallback.ts +8 -0
  73. package/src/lp.ts +721 -0
  74. package/src/lpMath.ts +113 -0
  75. package/src/lpV4.ts +406 -0
  76. package/src/owner.ts +19 -6
  77. package/src/scan.ts +155 -86
  78. package/src/source.ts +5 -0
  79. package/src/subpaths/lp.ts +71 -0
  80. package/src/subpaths/write.ts +26 -0
  81. package/src/tron.ts +5 -0
  82. package/src/write/batch.ts +302 -0
  83. package/src/write/collect.ts +312 -0
  84. package/src/write/rpc.ts +277 -0
  85. package/src/write/send.ts +490 -0
  86. package/src/write/signer.ts +90 -0
  87. package/src/write/transfer.ts +344 -0
  88. package/src/write/tron.ts +1100 -0
@@ -0,0 +1,344 @@
1
+ import { Interface, getAddress, getBigInt, parseUnits, type BigNumberish } from 'ethers'
2
+
3
+ import { ERC20_ABI, NATIVE_TOKEN, formatUnits } from '../erc20.js'
4
+ import type { Provider } from '../provider.js'
5
+ import { send7702, sendEach, tronModule, type NormalizedCall } from './batch.js'
6
+ import { WriteRpc } from './rpc.js'
7
+ import { emptyOutcome, sendJobs, toWriteResult, type SendOptions, type TronSendOptions, type TxJob, type TxOutcome, type WriteResult } from './send.js'
8
+ import { resolveSigner, type ResolvedSigner, type SignerInput } from './signer.js'
9
+
10
+ /**
11
+ * Disperse 合约(disperse.app),各主流链上地址相同:一笔交易把主币 / 代币分发给多个地址。
12
+ * 代币分发前需要 approve 给它;它只会从调用者自己的账户 transferFrom,别人无法动用这份额度
13
+ */
14
+ export const DISPERSE_ADDRESS = '0xD152f549545093347A162Dce210e7293f1452150'
15
+
16
+ const disperseInterface = new Interface([
17
+ 'function disperseEther(address[] recipients, uint256[] values) payable',
18
+ 'function disperseToken(address token, address[] recipients, uint256[] values)',
19
+ ])
20
+ const erc20Interface = new Interface(ERC20_ABI as unknown as string[])
21
+
22
+ /** 一个收款项:amount(最小单位)和 formatted(按精度换算后,如 '1.5')二选一 */
23
+ export interface TransferItem {
24
+ to: string
25
+ /** 数量,最小单位(wei),bigint 或十进制字符串 */
26
+ amount?: BigNumberish
27
+ /** 按 decimals 换算后的数量,如 '1.5';小数位超过精度时报错(不四舍五入) */
28
+ formatted?: string
29
+ /** 这一项转的代币,默认 options.token */
30
+ token?: string
31
+ }
32
+
33
+ /**
34
+ * - 'disperse'(默认):通过 Disperse 合约,每种代币每 batchSize 个收款人一笔交易。以下情况自动改为逐笔发送:
35
+ * 链上没有 Disperse 合约;某种代币只有一个收款人;某一批预估 revert(收款地址拒收主币、转账收税的代币等,逐笔发送能把出问题的那一个单独标成失败)
36
+ * - 'tx':每个收款人一笔交易
37
+ * - '7702':EIP-7702 批量执行,每 batchSize 个收款人一笔(见 BatchCallOptions.mode)
38
+ */
39
+ export type TransferMode = 'disperse' | 'tx' | '7702'
40
+
41
+ export interface BatchTransferOptions extends SendOptions, TronSendOptions {
42
+ /** 默认转的代币,默认主币(NATIVE_TOKEN);每项也可以单独指定 token */
43
+ token?: string
44
+ mode?: TransferMode
45
+ /** 每笔交易最多几个收款人:disperse 默认 200,7702 默认 50 */
46
+ batchSize?: number
47
+ /** Disperse 合约地址,默认 DISPERSE_ADDRESS */
48
+ disperse?: string
49
+ /** 发送前检查余额够不够(按代币汇总),不够时抛错、什么都不发。默认 true(主币不含手续费) */
50
+ checkBalance?: boolean
51
+ /** 7702:委托的合约,见 BatchCallOptions.delegate */
52
+ delegate?: string
53
+ /** 7702:见 BatchCallOptions.redelegate */
54
+ redelegate?: boolean
55
+ }
56
+
57
+ /** 单个收款项的结果,to 是收款地址 */
58
+ export interface TransferResult extends WriteResult {
59
+ token: string
60
+ native: boolean
61
+ /** 数量(最小单位) */
62
+ amount: string
63
+ /** 按精度换算后的数量;精度查不到时为 null */
64
+ formatted: string | null
65
+ /** 实际的发送方式(disperse 自动退回逐笔时为 'tx') */
66
+ via: TransferMode
67
+ /**
68
+ * 这一项的代币为了走 Disperse 发出的 approve 交易(额度不够时才有;USDT 等先归零时有两笔)。
69
+ * 同一种代币的各项相同;主币、逐笔发送、额度已够时为空数组
70
+ */
71
+ approvals: ApproveResult[]
72
+ }
73
+
74
+ /** approve 交易的结果:to 是代币地址,spender 是 Disperse 合约 */
75
+ export interface ApproveResult extends Omit<WriteResult, 'index'> {
76
+ spender: string
77
+ /** approve 的额度(最小单位) */
78
+ amount: string
79
+ }
80
+
81
+ /** 解析后的收款项 */
82
+ interface Planned {
83
+ index: number
84
+ to: string
85
+ token: string
86
+ native: boolean
87
+ amount: bigint
88
+ decimals: number | null
89
+ }
90
+
91
+ /** 链上有没有 Disperse 合约(按 chainId + 地址缓存) */
92
+ const disperseDeployed = new Map<string, boolean>()
93
+
94
+ /** 测试用 */
95
+ export function resetDisperseCache(): void {
96
+ disperseDeployed.clear()
97
+ }
98
+
99
+ async function hasCode(rpc: WriteRpc, address: string): Promise<boolean> {
100
+ const key = `${rpc.chainId}:${address.toLowerCase()}`
101
+ let deployed = disperseDeployed.get(key)
102
+ if (deployed === undefined) {
103
+ const code = await rpc.code(address)
104
+ deployed = Boolean(code && code !== '0x')
105
+ disperseDeployed.set(key, deployed)
106
+ }
107
+ return deployed
108
+ }
109
+
110
+ /**
111
+ * 一对多批量转账(主币 / ERC20,可混合)。结果与 recipients 一一对应。
112
+ *
113
+ * ```ts
114
+ * await batchTransfer(multi, privateKey, [
115
+ * { to: a, formatted: '1.5' }, // 默认代币(options.token,默认主币)
116
+ * { to: b, amount: 10n ** 18n },
117
+ * { to: c, token: USDT, formatted: '20' },
118
+ * ], { token: NATIVE_TOKEN })
119
+ * // [{ index: 0, to: a, token, native: true, amount: '1500000000000000000', formatted: '1.5', status: 'confirmed', hash, via: 'disperse', … }, …]
120
+ * ```
121
+ *
122
+ * - 默认通过 Disperse 合约分发(见 TransferMode),代币会先 approve 本次的精确总额
123
+ * - 默认先检查余额,不够时抛错,一笔都不发
124
+ * - 某个收款项失败不影响其他收款项
125
+ */
126
+ export async function batchTransfer(provider: Provider, from: SignerInput, recipients: readonly TransferItem[], options: BatchTransferOptions = {}): Promise<TransferResult[]> {
127
+ const tron = await tronModule(provider)
128
+ if (tron) {
129
+ return tron.tronBatchTransfer(provider, from, recipients, options)
130
+ }
131
+ const rpc = await WriteRpc.from(provider)
132
+ const signer = await resolveSigner(from)
133
+ const planned = await plan(provider, signer.address, recipients, options)
134
+ const mode = options.mode ?? 'disperse'
135
+ if (mode === 'tx' || mode === '7702') {
136
+ return sendDirect(rpc, signer, planned, mode, options)
137
+ }
138
+ return sendDisperse(rpc, provider, signer, planned, options)
139
+ }
140
+
141
+ /** 解析数量(formatted 按精度换算)并检查余额 */
142
+ async function plan(provider: Provider, owner: string, recipients: readonly TransferItem[], options: BatchTransferOptions): Promise<Planned[]> {
143
+ const defaultToken = options.token ?? NATIVE_TOKEN
144
+ const tokens = [...new Set(recipients.map((r) => (r.token ?? defaultToken).toLowerCase()))]
145
+ const balances = tokens.length ? await provider.balances(owner, tokens) : []
146
+ const info = new Map(balances.map((b, i) => [tokens[i] as string, b]))
147
+ const planned = recipients.map((item, index): Planned => {
148
+ const tokenKey = (item.token ?? defaultToken).toLowerCase()
149
+ const balance = info.get(tokenKey)
150
+ const native = provider.isNativeToken(tokenKey)
151
+ const decimals = balance?.success ? balance.decimals : null
152
+ if ((item.amount === undefined) === (item.formatted === undefined)) {
153
+ throw new Error(`recipients[${index}]: pass exactly one of amount / formatted`)
154
+ }
155
+ let amount: bigint
156
+ if (item.formatted !== undefined) {
157
+ if (decimals === null) {
158
+ throw new Error(`recipients[${index}]: cannot read decimals of ${item.token ?? defaultToken}, pass amount instead of formatted`)
159
+ }
160
+ amount = parseUnits(item.formatted, decimals)
161
+ } else {
162
+ amount = getBigInt(item.amount as BigNumberish)
163
+ }
164
+ if (amount < 0n) {
165
+ throw new Error(`recipients[${index}]: amount must not be negative`)
166
+ }
167
+ return { index, to: getAddress(item.to), token: native ? (item.token ?? defaultToken) : getAddress(tokenKey), native, amount, decimals }
168
+ })
169
+ if (options.checkBalance ?? true) {
170
+ const totals = new Map<string, bigint>()
171
+ planned.forEach((p) => totals.set(p.token.toLowerCase(), (totals.get(p.token.toLowerCase()) ?? 0n) + p.amount))
172
+ for (const [token, total] of totals) {
173
+ const balance = info.get(token)
174
+ if (!balance?.success) {
175
+ throw new Error(`Cannot read the balance of ${token} (${balance?.error ?? 'unknown error'}); pass checkBalance: false to skip the check`)
176
+ }
177
+ if (BigInt(balance.balance) < total) {
178
+ throw new Error(`Insufficient balance of ${token}: need ${total}, have ${balance.balance}`)
179
+ }
180
+ }
181
+ }
182
+ return planned
183
+ }
184
+
185
+ /** 收款项对应的调用:主币直接转,代币调 transfer */
186
+ function transferCall(signer: ResolvedSigner, p: Planned): NormalizedCall {
187
+ return p.native
188
+ ? { signer, to: p.to, data: '0x', value: p.amount }
189
+ : { signer, to: p.token, data: erc20Interface.encodeFunctionData('transfer', [p.to, p.amount]), value: 0n }
190
+ }
191
+
192
+ function toTransferResult(p: Planned, result: WriteResult, via: TransferMode): TransferResult {
193
+ return {
194
+ ...result,
195
+ index: p.index,
196
+ to: p.to,
197
+ token: p.token,
198
+ native: p.native,
199
+ amount: p.amount.toString(),
200
+ formatted: p.decimals === null ? null : formatUnits(p.amount, p.decimals),
201
+ via,
202
+ approvals: [],
203
+ }
204
+ }
205
+
206
+ /** 'tx' / '7702':直接转账 */
207
+ async function sendDirect(rpc: WriteRpc, signer: ResolvedSigner, planned: readonly Planned[], mode: 'tx' | '7702', options: BatchTransferOptions): Promise<TransferResult[]> {
208
+ const calls = planned.map((p) => transferCall(signer, p))
209
+ const callOptions = {
210
+ ...options,
211
+ mode,
212
+ continueOnError: true,
213
+ onUpdate: options.onUpdate && ((r: WriteResult) => options.onUpdate?.(toTransferResult(planned[r.index] as Planned, r, mode))),
214
+ }
215
+ const results = mode === '7702' ? await send7702(rpc, calls, callOptions) : await sendEach(rpc, calls, callOptions)
216
+ return results.map((r, i) => toTransferResult(planned[i] as Planned, r, mode))
217
+ }
218
+
219
+ /** 预估 / 执行时合约 revert(换成逐笔发送可能成功,或至少能定位到是哪个收款项) */
220
+ function isReverted(outcome: TxOutcome): boolean {
221
+ return outcome.status === 'reverted' || (outcome.status === 'failed' && (outcome.revertData !== null || /revert/i.test(outcome.error ?? '')))
222
+ }
223
+
224
+ /** 'disperse':每种代币每 batchSize 个收款人一笔;不适用 / 失败的部分逐笔发送 */
225
+ async function sendDisperse(rpc: WriteRpc, provider: Provider, signer: ResolvedSigner, planned: readonly Planned[], options: BatchTransferOptions): Promise<TransferResult[]> {
226
+ const disperse = getAddress(options.disperse ?? DISPERSE_ADDRESS)
227
+ const batchSize = Math.max(1, options.batchSize ?? 200)
228
+ // 初始结果(没有执行到的项保持 skipped),不触发 onUpdate
229
+ const results = planned.map((p) => toTransferResult(p, toWriteResult(p.index, signer.address, p.to, emptyOutcome('skipped')), 'disperse'))
230
+ const report = (p: Planned, outcome: TxOutcome, via: TransferMode) => {
231
+ results[p.index] = toTransferResult(p, toWriteResult(p.index, signer.address, p.to, outcome), via)
232
+ options.onUpdate?.(results[p.index] as TransferResult)
233
+ }
234
+
235
+ // 按代币分组(数量为 0 的项也照常转,与逐笔发送一致)
236
+ const groups = new Map<string, Planned[]>()
237
+ planned.forEach((p) => groups.set(p.token.toLowerCase(), [...(groups.get(p.token.toLowerCase()) ?? []), p]))
238
+ const direct: Planned[] = []
239
+ const useDisperse = planned.length > 1 && (await hasCode(rpc, disperse))
240
+ const jobs: TxJob[] = []
241
+ /** 每个 disperse 批次的收款项(与 jobs 里的位置对应,approve 为 null) */
242
+ const batches: Array<Planned[] | null> = []
243
+ /** 代币(小写)→ 发出的 approve */
244
+ const approvals = new Map<string, ApproveResult[]>()
245
+ const approve = (token: string, amount: bigint) => {
246
+ const list = approvals.get(token.toLowerCase()) ?? []
247
+ approvals.set(token.toLowerCase(), list)
248
+ const k = list.length
249
+ const result = (outcome: TxOutcome): ApproveResult => {
250
+ const { index: _index, ...rest } = toWriteResult(0, signer.address, token, outcome)
251
+ return { ...rest, spender: disperse, amount: amount.toString() }
252
+ }
253
+ list.push(result(emptyOutcome('skipped')))
254
+ jobs.push({
255
+ signer,
256
+ continueOnError: true,
257
+ build: async () => ({ to: token, data: erc20Interface.encodeFunctionData('approve', [disperse, amount]), value: 0n }),
258
+ onUpdate: (outcome) => (list[k] = result(outcome)),
259
+ })
260
+ batches.push(null)
261
+ }
262
+
263
+ for (const items of groups.values()) {
264
+ if (!useDisperse || items.length < 2) {
265
+ direct.push(...items)
266
+ continue
267
+ }
268
+ const first = items[0] as Planned
269
+ if (!first.native) {
270
+ const total = items.reduce((sum, p) => sum + p.amount, 0n)
271
+ const [allowance] = await provider.allowances(signer.address, disperse, [first.token], { decimals: false })
272
+ const current = allowance?.success ? BigInt(allowance.allowance) : 0n
273
+ if (current < total) {
274
+ if (options.dryRun) {
275
+ // 还没 approve,Disperse 的调用没法预估:只预估 approve,分发批次直接标记 simulated
276
+ approve(first.token, total)
277
+ items.forEach((p) => report(p, { ...emptyOutcome('simulated') }, 'disperse'))
278
+ continue
279
+ }
280
+ // USDT 等代币要求额度先归零才能改成另一个非零值
281
+ if (current > 0n) {
282
+ approve(first.token, 0n)
283
+ }
284
+ approve(first.token, total)
285
+ }
286
+ }
287
+ for (let start = 0; start < items.length; start += batchSize) {
288
+ const chunk = items.slice(start, start + batchSize)
289
+ const recipients = chunk.map((p) => p.to)
290
+ const values = chunk.map((p) => p.amount)
291
+ const value = first.native ? values.reduce((a, b) => a + b, 0n) : 0n
292
+ const data = first.native
293
+ ? disperseInterface.encodeFunctionData('disperseEther', [recipients, values])
294
+ : disperseInterface.encodeFunctionData('disperseToken', [first.token, recipients, values])
295
+ jobs.push({
296
+ signer,
297
+ continueOnError: true,
298
+ build: async () => ({ to: disperse, data, value }),
299
+ onUpdate: (outcome) => {
300
+ // 预估 revert 的批次稍后逐笔重发,先不报告失败
301
+ if (!(outcome.status === 'failed' && isReverted(outcome))) {
302
+ chunk.forEach((p) => report(p, outcome, 'disperse'))
303
+ }
304
+ },
305
+ })
306
+ batches.push(chunk)
307
+ }
308
+ }
309
+
310
+ const outcomes = await sendJobs(rpc, jobs, options)
311
+ // 预估就 revert 的批次(没有花手续费)改为逐笔发送;已上链但 revert 的批次不重发,避免重复支付
312
+ outcomes.forEach((outcome, i) => {
313
+ const chunk = batches[i]
314
+ if (chunk && outcome.status === 'failed' && isReverted(outcome)) {
315
+ direct.push(...chunk)
316
+ }
317
+ })
318
+ if (direct.length && !options.signal?.aborted) {
319
+ direct.sort((a, b) => a.index - b.index)
320
+ const calls = direct.map((p) => transferCall(signer, p))
321
+ await sendEach(rpc, calls, {
322
+ ...options,
323
+ mode: 'tx',
324
+ continueOnError: true,
325
+ onUpdate: (r) => report(direct[r.index] as Planned, outcomeOf(r), 'tx'),
326
+ })
327
+ }
328
+ return results.map((r) => ({ ...r, approvals: [...(approvals.get(r.token.toLowerCase()) ?? [])] }))
329
+ }
330
+
331
+ /** WriteResult 转回 TxOutcome(逐笔重发的结果并入 report) */
332
+ function outcomeOf(r: WriteResult): TxOutcome {
333
+ return {
334
+ status: r.status,
335
+ hash: r.hash,
336
+ nonce: r.nonce,
337
+ gasLimit: r.gasLimit === null ? null : BigInt(r.gasLimit),
338
+ gasUsed: r.gasUsed === null ? null : BigInt(r.gasUsed),
339
+ fee: r.fee === null ? null : BigInt(r.fee),
340
+ blockNumber: r.blockNumber,
341
+ error: r.error,
342
+ revertData: null,
343
+ }
344
+ }