@w3lib/evm-batch-call 0.4.1 → 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.
- package/CHANGELOG.md +21 -0
- package/README.md +166 -3
- package/dist/aggregate.d.ts.map +1 -1
- package/dist/aggregate.js +153 -40
- package/dist/aggregate.js.map +1 -1
- package/dist/fallback.d.ts +20 -0
- package/dist/fallback.d.ts.map +1 -1
- package/dist/fallback.js +36 -5
- package/dist/fallback.js.map +1 -1
- package/dist/lp.d.ts +149 -0
- package/dist/lp.d.ts.map +1 -0
- package/dist/lp.js +499 -0
- package/dist/lp.js.map +1 -0
- package/dist/lpMath.d.ts +25 -0
- package/dist/lpMath.d.ts.map +1 -0
- package/dist/lpMath.js +103 -0
- package/dist/lpMath.js.map +1 -0
- package/dist/lpV4.d.ts +108 -0
- package/dist/lpV4.d.ts.map +1 -0
- package/dist/lpV4.js +283 -0
- package/dist/lpV4.js.map +1 -0
- package/dist/owner.d.ts +1 -1
- package/dist/owner.d.ts.map +1 -1
- package/dist/owner.js +18 -5
- package/dist/owner.js.map +1 -1
- package/dist/scan.d.ts +54 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +124 -92
- package/dist/scan.js.map +1 -1
- package/dist/source.d.ts +2 -0
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +4 -0
- package/dist/source.js.map +1 -1
- package/dist/subpaths/lp.d.ts +33 -0
- package/dist/subpaths/lp.d.ts.map +1 -0
- package/dist/subpaths/lp.js +47 -0
- package/dist/subpaths/lp.js.map +1 -0
- package/dist/subpaths/write.d.ts +20 -0
- package/dist/subpaths/write.d.ts.map +1 -0
- package/dist/subpaths/write.js +18 -0
- package/dist/subpaths/write.js.map +1 -0
- package/dist/tron.d.ts +2 -0
- package/dist/tron.d.ts.map +1 -1
- package/dist/tron.js +4 -0
- package/dist/tron.js.map +1 -1
- package/dist/write/batch.d.ts +88 -0
- package/dist/write/batch.d.ts.map +1 -0
- package/dist/write/batch.js +231 -0
- package/dist/write/batch.js.map +1 -0
- package/dist/write/collect.d.ts +53 -0
- package/dist/write/collect.d.ts.map +1 -0
- package/dist/write/collect.js +255 -0
- package/dist/write/collect.js.map +1 -0
- package/dist/write/rpc.d.ts +68 -0
- package/dist/write/rpc.d.ts.map +1 -0
- package/dist/write/rpc.js +236 -0
- package/dist/write/rpc.js.map +1 -0
- package/dist/write/send.d.ts +124 -0
- package/dist/write/send.d.ts.map +1 -0
- package/dist/write/send.js +373 -0
- package/dist/write/send.js.map +1 -0
- package/dist/write/signer.d.ts +58 -0
- package/dist/write/signer.d.ts.map +1 -0
- package/dist/write/signer.js +41 -0
- package/dist/write/signer.js.map +1 -0
- package/dist/write/transfer.d.ts +83 -0
- package/dist/write/transfer.d.ts.map +1 -0
- package/dist/write/transfer.js +259 -0
- package/dist/write/transfer.js.map +1 -0
- package/dist/write/tron.d.ts +58 -0
- package/dist/write/tron.d.ts.map +1 -0
- package/dist/write/tron.js +956 -0
- package/dist/write/tron.js.map +1 -0
- package/package.json +27 -14
- package/src/aggregate.ts +168 -36
- package/src/fallback.ts +55 -11
- package/src/lp.ts +721 -0
- package/src/lpMath.ts +113 -0
- package/src/lpV4.ts +406 -0
- package/src/owner.ts +19 -6
- package/src/scan.ts +155 -86
- package/src/source.ts +5 -0
- package/src/subpaths/lp.ts +71 -0
- package/src/subpaths/write.ts +26 -0
- package/src/tron.ts +5 -0
- package/src/write/batch.ts +302 -0
- package/src/write/collect.ts +312 -0
- package/src/write/rpc.ts +277 -0
- package/src/write/send.ts +490 -0
- package/src/write/signer.ts +90 -0
- package/src/write/transfer.ts +344 -0
- package/src/write/tron.ts +1100 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { Interface, ZeroAddress, getAddress, getBigInt, hexlify, type Authorization, type BigNumberish } from 'ethers'
|
|
2
|
+
|
|
3
|
+
import { encodeCall, type Call } from '../call.js'
|
|
4
|
+
import { decodeRevertReason } from '../errors.js'
|
|
5
|
+
import type { Provider } from '../provider.js'
|
|
6
|
+
import { isTronChain } from '../tron.js'
|
|
7
|
+
import { WriteRpc } from './rpc.js'
|
|
8
|
+
import { emptyOutcome, sendJobs, toWriteResult, type SendOptions, type TronSendOptions, type TxJob, type TxOutcome, type WriteResult } from './send.js'
|
|
9
|
+
import { resolveSigner, type ResolvedSigner, type SignerInput } from './signer.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* EIP-7702 批量执行合约:eth-infinitism 的 Simple7702Account(account-abstraction v0.8),各主流链上地址相同。
|
|
13
|
+
* executeBatch 只允许账户自己(或 EntryPoint)调用,委托后别人无法通过它动用钱包里的资产
|
|
14
|
+
*/
|
|
15
|
+
export const SIMPLE_7702_ACCOUNT = '0x4Cd241E8d1510e30b2076397afc7508Ae59C66c9'
|
|
16
|
+
|
|
17
|
+
const accountInterface = new Interface([
|
|
18
|
+
'function executeBatch((address target, uint256 value, bytes data)[] calls)',
|
|
19
|
+
'error ExecuteError(uint256 index, bytes reason)',
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
/** 7702 委托后账户上的代码:0xef0100 + 委托合约地址 */
|
|
23
|
+
const DELEGATION_PREFIX = '0xef0100'
|
|
24
|
+
|
|
25
|
+
/** 一个写调用:目标地址 + calldata,或 Contract 生成的 Call */
|
|
26
|
+
export interface WriteCall {
|
|
27
|
+
/** 发送的钱包:私钥或 ethers Signer */
|
|
28
|
+
from: SignerInput
|
|
29
|
+
/** 目标地址(与 data 一起用;不传 call 时必填) */
|
|
30
|
+
to?: string
|
|
31
|
+
/** calldata,默认 '0x'(普通转账) */
|
|
32
|
+
data?: string
|
|
33
|
+
/** 附带的主币(wei) */
|
|
34
|
+
value?: BigNumberish
|
|
35
|
+
/** 或者传 Contract 生成的 Call:`new Contract(token, ERC20_ABI).transfer(to, amount)`;`{ value }` overrides 作为 value */
|
|
36
|
+
call?: Call
|
|
37
|
+
/** 指定 gasLimit 时不预估(7702 模式下忽略,整笔交易统一预估) */
|
|
38
|
+
gasLimit?: bigint
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type BatchCallMode = 'tx' | '7702'
|
|
42
|
+
|
|
43
|
+
export interface BatchCallOptions extends SendOptions, TronSendOptions {
|
|
44
|
+
/**
|
|
45
|
+
* - 'tx'(默认):每个调用一笔交易,所有链都支持
|
|
46
|
+
* - '7702':钱包通过 EIP-7702 委托给批量执行合约,同一钱包的调用合成一笔交易(按 batchSize 拆分),一笔里的调用要么全部成功要么全部失败。
|
|
47
|
+
* 需要链支持 EIP-7702,钱包需要能本地签名(私钥 / ethers Wallet)。委托会一直保留,可用 revokeDelegation 撤销
|
|
48
|
+
*/
|
|
49
|
+
mode?: BatchCallMode
|
|
50
|
+
/**
|
|
51
|
+
* 某个调用发送前失败(预估 revert、节点拒绝等)后,同一钱包后面的调用是否继续。默认 false(后面的标记为 skipped)。
|
|
52
|
+
* 交易不等上一笔确认就连续发出,所以已经发出、之后在链上 revert 的交易拦不住后面已发出的交易
|
|
53
|
+
*/
|
|
54
|
+
continueOnError?: boolean
|
|
55
|
+
/** 7702:委托的合约,默认 SIMPLE_7702_ACCOUNT */
|
|
56
|
+
delegate?: string
|
|
57
|
+
/** 7702:每笔交易最多打包的调用数,默认 50 */
|
|
58
|
+
batchSize?: number
|
|
59
|
+
/** 7702:钱包已委托给其他合约时改为委托给 delegate。默认 false(报错,不动已有的委托) */
|
|
60
|
+
redelegate?: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 归一化后的调用 */
|
|
64
|
+
export interface NormalizedCall {
|
|
65
|
+
signer: ResolvedSigner
|
|
66
|
+
to: string
|
|
67
|
+
data: string
|
|
68
|
+
value: bigint
|
|
69
|
+
gasLimit?: bigint
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function normalizeCalls(calls: readonly WriteCall[]): Promise<NormalizedCall[]> {
|
|
73
|
+
const cache = new Map()
|
|
74
|
+
return Promise.all(
|
|
75
|
+
calls.map(async (item, i) => {
|
|
76
|
+
const signer = await resolveSigner(item.from, cache)
|
|
77
|
+
if (item.call) {
|
|
78
|
+
const request = encodeCall(item.call, false)
|
|
79
|
+
const value = item.value ?? item.call.overrides?.value ?? 0n
|
|
80
|
+
return { signer, to: request.target, data: request.callData, value: getBigInt(value), gasLimit: item.gasLimit }
|
|
81
|
+
}
|
|
82
|
+
if (!item.to) {
|
|
83
|
+
throw new Error(`calls[${i}]: either to or call is required`)
|
|
84
|
+
}
|
|
85
|
+
return { signer, to: getAddress(item.to), data: item.data ? hexlify(item.data) : '0x', value: getBigInt(item.value ?? 0n), gasLimit: item.gasLimit }
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 批量调用合约(写操作)。结果与 calls 一一对应。
|
|
92
|
+
*
|
|
93
|
+
* ```ts
|
|
94
|
+
* const usdt = new Contract(USDT, ERC20_ABI)
|
|
95
|
+
* await batchCall(multi, [
|
|
96
|
+
* { from: key1, call: usdt.approve(router, amount) },
|
|
97
|
+
* { from: key1, to: router, data: swapData }, // 依赖上一笔:预估失败时先等 approve 确认再预估
|
|
98
|
+
* { from: key2, to: someone, value: 10n ** 16n }, // 不同钱包并发发送
|
|
99
|
+
* ])
|
|
100
|
+
* // [{ index: 0, from, to, status: 'confirmed', hash: '0x…', nonce: 7, gasUsed: '46109', fee: '…', … }, …]
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* - 'tx' 模式:同一钱包按 nonce 顺序连续发出(不等上一笔确认),不同钱包并发(concurrency)
|
|
104
|
+
* - 依赖前一笔结果的调用:预估失败且同一钱包还有未确认的交易时,先等它们确认再预估一次
|
|
105
|
+
* - 结果的 status 见 WriteStatus;未上链的失败(预估 revert 等)不花手续费
|
|
106
|
+
*/
|
|
107
|
+
export async function batchCall(provider: Provider, calls: readonly WriteCall[], options: BatchCallOptions = {}): Promise<WriteResult[]> {
|
|
108
|
+
const tron = await tronModule(provider)
|
|
109
|
+
if (tron) {
|
|
110
|
+
return tron.tronBatchCall(provider, calls, options)
|
|
111
|
+
}
|
|
112
|
+
const rpc = await WriteRpc.from(provider)
|
|
113
|
+
const normalized = await normalizeCalls(calls)
|
|
114
|
+
return (options.mode ?? 'tx') === '7702' ? send7702(rpc, normalized, options) : sendEach(rpc, normalized, options)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Tron 链上用 Tron 的实现(按需加载:只用 EVM 时打包不会带上) */
|
|
118
|
+
export async function tronModule(provider: Provider): Promise<typeof import('./tron.js') | null> {
|
|
119
|
+
return isTronChain(await provider.getChainId()) ? import('./tron.js') : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 每个调用一笔交易 */
|
|
123
|
+
export async function sendEach(rpc: WriteRpc, calls: readonly NormalizedCall[], options: BatchCallOptions): Promise<WriteResult[]> {
|
|
124
|
+
const results: WriteResult[] = calls.map((c, i) => toWriteResult(i, c.signer.address, c.to, emptyOutcome('skipped')))
|
|
125
|
+
const jobs: TxJob[] = calls.map((c, i) => ({
|
|
126
|
+
signer: c.signer,
|
|
127
|
+
gasLimit: c.gasLimit,
|
|
128
|
+
continueOnError: options.continueOnError,
|
|
129
|
+
build: async () => ({ to: c.to, data: c.data, value: c.value }),
|
|
130
|
+
onUpdate: (outcome) => {
|
|
131
|
+
results[i] = toWriteResult(i, c.signer.address, c.to, outcome)
|
|
132
|
+
options.onUpdate?.(results[i] as WriteResult)
|
|
133
|
+
},
|
|
134
|
+
}))
|
|
135
|
+
const outcomes = await sendJobs(rpc, jobs, options)
|
|
136
|
+
return outcomes.map((outcome, i) => toWriteResult(i, (calls[i] as NormalizedCall).signer.address, (calls[i] as NormalizedCall).to, outcome))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 钱包当前的委托状态 */
|
|
140
|
+
async function delegationOf(rpc: WriteRpc, address: string): Promise<{ delegated: boolean; to: string | null }> {
|
|
141
|
+
const code = (await rpc.code(address)).toLowerCase()
|
|
142
|
+
if (code === '0x' || code === '') {
|
|
143
|
+
return { delegated: false, to: null }
|
|
144
|
+
}
|
|
145
|
+
if (code.startsWith(DELEGATION_PREFIX) && code.length === 2 + 46) {
|
|
146
|
+
return { delegated: true, to: getAddress(`0x${code.slice(8)}`) }
|
|
147
|
+
}
|
|
148
|
+
throw new Error(`${address} is a contract, not an EOA`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 7702 授权:发送者自己就是授权人时,授权的 nonce 要用交易 nonce + 1(交易先把 nonce 加一,再处理授权) */
|
|
152
|
+
async function authorize(rpc: WriteRpc, signer: ResolvedSigner, delegate: string, txNonce: number): Promise<Authorization> {
|
|
153
|
+
if (!signer.local || typeof signer.signer.authorize !== 'function') {
|
|
154
|
+
throw new Error("mode '7702' requires a signer that can sign EIP-7702 authorizations locally (a private key or an ethers Wallet)")
|
|
155
|
+
}
|
|
156
|
+
return signer.signer.authorize({ address: delegate, nonce: txNonce + 1, chainId: rpc.chainId })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 7702:同一钱包的调用合成一笔发给自己的 executeBatch 交易 */
|
|
160
|
+
export async function send7702(rpc: WriteRpc, calls: readonly NormalizedCall[], options: BatchCallOptions): Promise<WriteResult[]> {
|
|
161
|
+
const delegate = getAddress(options.delegate ?? SIMPLE_7702_ACCOUNT)
|
|
162
|
+
const batchSize = Math.max(1, options.batchSize ?? 50)
|
|
163
|
+
const results: WriteResult[] = calls.map((c, i) => toWriteResult(i, c.signer.address, c.to, emptyOutcome('skipped')))
|
|
164
|
+
const report = (i: number, outcome: TxOutcome) => {
|
|
165
|
+
const c = calls[i] as NormalizedCall
|
|
166
|
+
results[i] = toWriteResult(i, c.signer.address, c.to, outcome)
|
|
167
|
+
options.onUpdate?.(results[i] as WriteResult)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 按钱包分组、拆成每 batchSize 个一笔
|
|
171
|
+
const byWallet = new Map<string, number[]>()
|
|
172
|
+
calls.forEach((c, i) => {
|
|
173
|
+
const key = c.signer.address.toLowerCase()
|
|
174
|
+
byWallet.set(key, [...(byWallet.get(key) ?? []), i])
|
|
175
|
+
})
|
|
176
|
+
const chunks: number[][] = []
|
|
177
|
+
for (const indexes of byWallet.values()) {
|
|
178
|
+
for (let start = 0; start < indexes.length; start += batchSize) {
|
|
179
|
+
chunks.push(indexes.slice(start, start + batchSize))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 委托合约必须有代码:EIP-7702 委托给没有代码的地址时调用直接“成功”、什么都不执行,结果会全部报 confirmed
|
|
184
|
+
let deployed: Promise<boolean> | null = null
|
|
185
|
+
const assertDeployed = async () => {
|
|
186
|
+
deployed ??= rpc.code(delegate).then((code) => Boolean(code && code !== '0x'))
|
|
187
|
+
if (!(await deployed)) {
|
|
188
|
+
throw new Error(`The delegate contract ${delegate} is not deployed on chain ${rpc.chainId}; pass delegate to use another one`)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 每笔交易发送前查一次委托状态(同一钱包上一笔带授权时,sendJobs 会先等它确认,这时已经委托好了,不必再带授权)
|
|
193
|
+
const checkDelegation = async (signer: ResolvedSigner): Promise<boolean> => {
|
|
194
|
+
const { delegated, to } = await delegationOf(rpc, signer.address)
|
|
195
|
+
if (!delegated) {
|
|
196
|
+
return true
|
|
197
|
+
}
|
|
198
|
+
if (to === delegate) {
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
if (!options.redelegate) {
|
|
202
|
+
throw new Error(`${signer.address} is already delegated to ${to}; pass redelegate: true to replace it`)
|
|
203
|
+
}
|
|
204
|
+
return true
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const jobs: TxJob[] = chunks.map((chunk) => {
|
|
208
|
+
const signer = (calls[chunk[0] as number] as NormalizedCall).signer
|
|
209
|
+
const data = accountInterface.encodeFunctionData('executeBatch', [
|
|
210
|
+
chunk.map((i) => {
|
|
211
|
+
const c = calls[i] as NormalizedCall
|
|
212
|
+
return [c.to, c.value, c.data]
|
|
213
|
+
}),
|
|
214
|
+
])
|
|
215
|
+
return {
|
|
216
|
+
signer,
|
|
217
|
+
continueOnError: options.continueOnError,
|
|
218
|
+
build: async (nonce) => {
|
|
219
|
+
if (!signer.local || nonce === null) {
|
|
220
|
+
throw new Error("mode '7702' requires a signer that can sign locally (a private key or an ethers Wallet)")
|
|
221
|
+
}
|
|
222
|
+
await assertDeployed()
|
|
223
|
+
let auth: Authorization[] | undefined
|
|
224
|
+
if (await checkDelegation(signer)) {
|
|
225
|
+
// 预估 gas 按 latest 状态执行:钱包还有未确认的交易时,授权 nonce(按 pending 算)在预估里对不上、授权被跳过,
|
|
226
|
+
// 预估出来的 gas 不含批量执行,上链后执行失败、手续费白付。要求先等这些交易确认
|
|
227
|
+
if ((await rpc.latestNonce(signer.address)) !== nonce) {
|
|
228
|
+
throw new Error('The wallet has unconfirmed transactions; EIP-7702 delegation needs them to be confirmed first')
|
|
229
|
+
}
|
|
230
|
+
auth = [await authorize(rpc, signer, delegate, nonce)]
|
|
231
|
+
}
|
|
232
|
+
return { to: signer.address, data, value: 0n, authorizationList: auth }
|
|
233
|
+
},
|
|
234
|
+
onUpdate: (outcome) => {
|
|
235
|
+
if (outcome.status === 'failed' && outcome.revertData) {
|
|
236
|
+
// 预估时 revert:找出是第几个调用失败,其他调用标记为 skipped(整笔没有发出)
|
|
237
|
+
const failed = failedIndex(outcome.revertData, chunk.length)
|
|
238
|
+
if (failed) {
|
|
239
|
+
chunk.forEach((i, k) =>
|
|
240
|
+
report(i, k === failed.index ? { ...outcome, error: failed.reason } : { ...outcome, status: 'skipped', error: 'skipped: another call in the same batch failed' }),
|
|
241
|
+
)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
chunk.forEach((i) => report(i, outcome))
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
await sendJobs(rpc, jobs, options)
|
|
250
|
+
return results
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 解析 executeBatch 的 ExecuteError(index, error);只有一个调用时合约直接透传 revert 数据 */
|
|
254
|
+
function failedIndex(data: string, size: number): { index: number; reason: string } | null {
|
|
255
|
+
if (size === 1) {
|
|
256
|
+
const reason = decodeRevertReason(data)
|
|
257
|
+
return { index: 0, reason: reason ? `execution reverted: ${reason}` : 'execution reverted' }
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const parsed = accountInterface.parseError(data)
|
|
261
|
+
if (parsed?.name !== 'ExecuteError') {
|
|
262
|
+
return null
|
|
263
|
+
}
|
|
264
|
+
const index = Number(parsed.args[0])
|
|
265
|
+
const reason = decodeRevertReason(String(parsed.args[1]))
|
|
266
|
+
return index < size ? { index, reason: reason ? `execution reverted: ${reason}` : 'execution reverted' } : null
|
|
267
|
+
} catch {
|
|
268
|
+
return null
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 撤销钱包的 EIP-7702 委托(授权给零地址,账户恢复成普通 EOA)。没有委托时直接返回 null,不发交易。
|
|
274
|
+
*
|
|
275
|
+
* ```ts
|
|
276
|
+
* await revokeDelegation(multi, privateKey)
|
|
277
|
+
* ```
|
|
278
|
+
*/
|
|
279
|
+
export async function revokeDelegation(provider: Provider, from: SignerInput, options: SendOptions = {}): Promise<WriteResult | null> {
|
|
280
|
+
const rpc = await WriteRpc.from(provider) // Tron 没有 EIP-7702:这里会报不支持
|
|
281
|
+
const signer = await resolveSigner(from)
|
|
282
|
+
if (!(await delegationOf(rpc, signer.address)).delegated) {
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
const [outcome] = await sendJobs(
|
|
286
|
+
rpc,
|
|
287
|
+
[
|
|
288
|
+
{
|
|
289
|
+
signer,
|
|
290
|
+
build: async (nonce) => {
|
|
291
|
+
if (nonce === null) {
|
|
292
|
+
throw new Error('revokeDelegation requires a signer that can sign locally (a private key or an ethers Wallet)')
|
|
293
|
+
}
|
|
294
|
+
return { to: signer.address, data: '0x', value: 0n, authorizationList: [await authorize(rpc, signer, ZeroAddress, nonce)] }
|
|
295
|
+
},
|
|
296
|
+
onUpdate: (o) => options.onUpdate?.(toWriteResult(0, signer.address, signer.address, o)),
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
options,
|
|
300
|
+
)
|
|
301
|
+
return toWriteResult(0, signer.address, signer.address, outcome as TxOutcome)
|
|
302
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { Interface, getAddress, getBigInt, type BigNumberish } from 'ethers'
|
|
2
|
+
|
|
3
|
+
import { ERC20_ABI, NATIVE_TOKEN, formatUnits, type TokenBalance } from '../erc20.js'
|
|
4
|
+
import type { Provider } from '../provider.js'
|
|
5
|
+
import { isExecutionError } from '../errors.js'
|
|
6
|
+
import { WriteRpc, failureText, l1Fee } from './rpc.js'
|
|
7
|
+
import { FeeSource, applyGasMultiplier, emptyOutcome, mapPool, sendJobs, toWriteResult, type SendOptions, type TronSendOptions, type TxFees, type TxJob, type TxOutcome, type WriteResult } from './send.js'
|
|
8
|
+
import { resolveSigner, type ResolvedSigner, type SignerInput } from './signer.js'
|
|
9
|
+
import { tronModule } from './batch.js'
|
|
10
|
+
import { batchTransfer, type TransferResult } from './transfer.js'
|
|
11
|
+
|
|
12
|
+
const erc20Interface = new Interface(ERC20_ABI as unknown as string[])
|
|
13
|
+
|
|
14
|
+
/** 预估代币转账 gas 失败(有的节点对没有主币的地址预估时报余额不足)时用的 gasLimit */
|
|
15
|
+
const FALLBACK_TOKEN_GAS = 120_000n
|
|
16
|
+
|
|
17
|
+
export interface BatchCollectOptions extends SendOptions, TronSendOptions {
|
|
18
|
+
/**
|
|
19
|
+
* 要归集的代币。包含主币(NATIVE_TOKEN)时,在代币归集确认后把剩余主币扣掉手续费全部转走
|
|
20
|
+
* (OP Stack / Scroll 链上同时预留 L1 数据费;EIP-1559 链上按 maxFeePerGas 预留,实际手续费更低,会留下少量零头)
|
|
21
|
+
*/
|
|
22
|
+
tokens: readonly string[]
|
|
23
|
+
/**
|
|
24
|
+
* 补 gas 的钱包:子钱包主币不够付归集手续费时,先从它转入刚好够用的主币(按 gasLimit × maxFeePerGas 计算,已含余量),确认后再归集。
|
|
25
|
+
* 不传时,主币不够的子钱包对应的项 status 为 'failed'
|
|
26
|
+
*/
|
|
27
|
+
gasWallet?: SignerInput
|
|
28
|
+
/** 补 gas 的方式,见 batchTransfer 的 mode,默认 'disperse'(一笔交易给所有子钱包补) */
|
|
29
|
+
gasMode?: 'disperse' | 'tx'
|
|
30
|
+
/** 补 gas 数量的额外倍数,默认 1 */
|
|
31
|
+
gasBuffer?: number
|
|
32
|
+
/** 各代币的最小归集数量(最小单位),余额低于它的不归集:`{ [USDT]: 10n ** 6n }` */
|
|
33
|
+
minAmount?: Readonly<Record<string, BigNumberish>>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 单个钱包单个代币的归集结果;from 是子钱包,to 是归集地址 */
|
|
37
|
+
export interface CollectItemResult extends WriteResult {
|
|
38
|
+
token: string
|
|
39
|
+
native: boolean
|
|
40
|
+
/** 归集的数量(最小单位);主币扫尾在发送前为 '0' */
|
|
41
|
+
amount: string
|
|
42
|
+
formatted: string | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CollectResult {
|
|
46
|
+
/** 按 wallets × tokens 顺序(第 i 个钱包第 j 个代币在 i × tokens.length + j) */
|
|
47
|
+
results: CollectItemResult[]
|
|
48
|
+
/** 补 gas 的转账(没有需要补的钱包时为空) */
|
|
49
|
+
topUps: TransferResult[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Item {
|
|
53
|
+
index: number
|
|
54
|
+
signer: ResolvedSigner
|
|
55
|
+
token: string
|
|
56
|
+
native: boolean
|
|
57
|
+
amount: bigint
|
|
58
|
+
decimals: number | null
|
|
59
|
+
gasLimit: bigint | null
|
|
60
|
+
result: CollectItemResult
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 归集:多个钱包的代币 / 主币转到同一个地址。每个钱包内按代币顺序逐笔发送,钱包之间并发。
|
|
65
|
+
*
|
|
66
|
+
* ```ts
|
|
67
|
+
* const { results, topUps } = await batchCollect(multi, [key1, key2, key3], hotWallet, {
|
|
68
|
+
* tokens: [USDT, NATIVE_TOKEN], // 主币放在里面表示代币归集完后扫空剩余主币
|
|
69
|
+
* gasWallet: gasKey, // 子钱包主币不够付手续费时先从这里补
|
|
70
|
+
* })
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* 流程:一次 multicall 读出所有钱包的余额 → 预估每笔归集的手续费 → 给不够的钱包补 gas(等确认)
|
|
74
|
+
* → 发代币归集 → (包含主币时)等代币归集确认、按最新余额扫空主币。
|
|
75
|
+
* dryRun 时只做到预估,返回计划(补多少 gas、每笔 gasLimit),不发任何交易
|
|
76
|
+
*/
|
|
77
|
+
export async function batchCollect(provider: Provider, wallets: readonly SignerInput[], to: string, options: BatchCollectOptions): Promise<CollectResult> {
|
|
78
|
+
const tron = await tronModule(provider)
|
|
79
|
+
if (tron) {
|
|
80
|
+
return tron.tronBatchCollect(provider, wallets, to, options)
|
|
81
|
+
}
|
|
82
|
+
const rpc = await WriteRpc.from(provider)
|
|
83
|
+
const target = getAddress(to)
|
|
84
|
+
const cache = new Map()
|
|
85
|
+
const signers = await Promise.all(wallets.map((w) => resolveSigner(w, cache)))
|
|
86
|
+
const tokens = options.tokens
|
|
87
|
+
const nativeIndex = tokens.findIndex((t) => provider.isNativeToken(t))
|
|
88
|
+
const sweep = nativeIndex >= 0
|
|
89
|
+
const multiplier = options.gasMultiplier
|
|
90
|
+
|
|
91
|
+
// 一次 multicall 读出所有钱包的代币余额 + 主币余额(算手续费要用)
|
|
92
|
+
const queryTokens = sweep ? [...tokens] : [...tokens, NATIVE_TOKEN]
|
|
93
|
+
const nativeAt = sweep ? nativeIndex : tokens.length
|
|
94
|
+
const balances = await provider.multiBalances(signers.map((s) => ({ owner: s.address, tokens: queryTokens })))
|
|
95
|
+
const minAmount = new Map(Object.entries(options.minAmount ?? {}).map(([k, v]) => [k.toLowerCase(), getBigInt(v)]))
|
|
96
|
+
|
|
97
|
+
const items: Item[] = []
|
|
98
|
+
const seen = new Set<string>()
|
|
99
|
+
signers.forEach((signer, w) => {
|
|
100
|
+
const duplicate = seen.has(signer.address.toLowerCase())
|
|
101
|
+
seen.add(signer.address.toLowerCase())
|
|
102
|
+
tokens.forEach((token, t) => {
|
|
103
|
+
const balance = balances[w]?.[t] as TokenBalance
|
|
104
|
+
const native = t === nativeIndex
|
|
105
|
+
const decimals = balance.success ? balance.decimals : null
|
|
106
|
+
const item: Item = {
|
|
107
|
+
index: w * tokens.length + t,
|
|
108
|
+
signer,
|
|
109
|
+
token: native ? token : getAddress(token),
|
|
110
|
+
native,
|
|
111
|
+
amount: native ? 0n : balance.success ? BigInt(balance.balance) : 0n,
|
|
112
|
+
decimals,
|
|
113
|
+
gasLimit: null,
|
|
114
|
+
result: null as unknown as CollectItemResult,
|
|
115
|
+
}
|
|
116
|
+
const skip = !balance.success
|
|
117
|
+
? `cannot read balance: ${balance.error ?? 'unknown error'}`
|
|
118
|
+
: signer.address === target
|
|
119
|
+
? 'skipped: wallet is the collect address'
|
|
120
|
+
: duplicate
|
|
121
|
+
? 'skipped: duplicate wallet'
|
|
122
|
+
: !native && item.amount === 0n
|
|
123
|
+
? 'skipped: zero balance'
|
|
124
|
+
: !native && item.amount < (minAmount.get(token.toLowerCase()) ?? 0n)
|
|
125
|
+
? 'skipped: below minAmount'
|
|
126
|
+
: null
|
|
127
|
+
item.result = toCollectResult(item, target, emptyOutcome(skip ? (balance.success ? 'skipped' : 'failed') : 'skipped', skip))
|
|
128
|
+
items.push(item)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
const results = items.map((i) => i.result)
|
|
132
|
+
const report = (item: Item, outcome: TxOutcome) => {
|
|
133
|
+
item.result = toCollectResult(item, target, outcome)
|
|
134
|
+
results[item.index] = item.result
|
|
135
|
+
options.onUpdate?.(item.result)
|
|
136
|
+
}
|
|
137
|
+
const active = (item: Item) => item.result.error === null
|
|
138
|
+
|
|
139
|
+
// 预估每个钱包的代币归集 gas,算出需要补多少主币。
|
|
140
|
+
// 手续费价格只取这一次,代币归集也按它发:补 gas 等确认期间节点报价上涨,也不会超出补的数量、因余额不足被拒
|
|
141
|
+
const fee = await new FeeSource(rpc, options).get()
|
|
142
|
+
const fees = FeeSource.fixed(rpc, fee)
|
|
143
|
+
const byWallet = new Map<string, Item[]>()
|
|
144
|
+
items.forEach((item) => {
|
|
145
|
+
if (active(item)) {
|
|
146
|
+
byWallet.set(item.signer.address, [...(byWallet.get(item.signer.address) ?? []), item])
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
const plans = await mapPool([...byWallet.entries()], options.concurrency ?? 8, async ([address, list]) => {
|
|
150
|
+
const native = balances[signers.findIndex((s) => s.address === address)]?.[nativeAt] as TokenBalance
|
|
151
|
+
if (!native.success) {
|
|
152
|
+
// 主币余额读不到:不知道要补多少,不补(按 0 算会多补,而这个钱包也不会扫主币,多补的收不回来)
|
|
153
|
+
list.filter((i) => !i.native).forEach((i) => report(i, emptyOutcome('failed', `cannot read the native balance: ${native.error ?? 'unknown error'}`)))
|
|
154
|
+
return { address, list, shortfall: 0n }
|
|
155
|
+
}
|
|
156
|
+
let need = 0n
|
|
157
|
+
for (const item of list) {
|
|
158
|
+
if (item.native) {
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
const tx = { from: address, to: item.token, data: erc20Interface.encodeFunctionData('transfer', [target, item.amount]) }
|
|
162
|
+
try {
|
|
163
|
+
item.gasLimit = applyGasMultiplier(await rpc.estimateGas(tx), multiplier)
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (!/insufficient funds/i.test(failureText(err))) {
|
|
166
|
+
report(item, { ...emptyOutcome('failed', failureText(err)) })
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
item.gasLimit = FALLBACK_TOKEN_GAS
|
|
170
|
+
}
|
|
171
|
+
need += item.gasLimit * fee.perGas + (await l1Fee(rpc, { ...tx, from: undefined, gasLimit: item.gasLimit, ...feeFields(fee) }))
|
|
172
|
+
}
|
|
173
|
+
const wallet = signers.findIndex((s) => s.address === address)
|
|
174
|
+
const nativeBalance = BigInt((balances[wallet]?.[nativeAt] as TokenBalance).balance)
|
|
175
|
+
const buffered = (need * BigInt(Math.round((options.gasBuffer ?? 1) * 1000))) / 1000n
|
|
176
|
+
return { address, list, shortfall: buffered > nativeBalance ? buffered - nativeBalance : 0n }
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// 补 gas
|
|
180
|
+
let topUps: TransferResult[] = []
|
|
181
|
+
const short = plans.filter((p) => p.shortfall > 0n && p.list.some((i) => !i.native && active(i)))
|
|
182
|
+
if (short.length) {
|
|
183
|
+
if (!options.gasWallet) {
|
|
184
|
+
short.forEach((p) => p.list.filter((i) => !i.native && active(i)).forEach((i) => report(i, emptyOutcome('failed', `insufficient native balance for gas: need ${p.shortfall} more wei (pass gasWallet to top up automatically)`))))
|
|
185
|
+
} else {
|
|
186
|
+
topUps = await batchTransfer(
|
|
187
|
+
provider,
|
|
188
|
+
options.gasWallet,
|
|
189
|
+
short.map((p) => ({ to: p.address, amount: p.shortfall, token: NATIVE_TOKEN })),
|
|
190
|
+
{ ...sendOptions(options), mode: options.gasMode ?? 'disperse', wait: true },
|
|
191
|
+
)
|
|
192
|
+
topUps.forEach((t, k) => {
|
|
193
|
+
const ok = t.status === 'confirmed' || (options.dryRun && t.status === 'simulated')
|
|
194
|
+
if (!ok) {
|
|
195
|
+
;(short[k] as (typeof short)[number]).list.filter((i) => !i.native && active(i)).forEach((i) => report(i, emptyOutcome('failed', `gas top-up ${t.status}: ${t.error ?? ''}`.trim())))
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 代币归集
|
|
202
|
+
const tokenItems = items.filter((i) => !i.native && active(i))
|
|
203
|
+
if (options.dryRun) {
|
|
204
|
+
tokenItems.forEach((i) => report(i, { ...emptyOutcome('simulated'), gasLimit: i.gasLimit }))
|
|
205
|
+
if (sweep) {
|
|
206
|
+
items.filter((i) => i.native && active(i)).forEach((i) => report(i, { ...emptyOutcome('simulated'), gasLimit: 21_000n }))
|
|
207
|
+
}
|
|
208
|
+
return { results, topUps }
|
|
209
|
+
}
|
|
210
|
+
const minBlock = new Map<string, number>()
|
|
211
|
+
topUps.forEach((t) => t.blockNumber !== null && minBlock.set(getAddress(t.to), t.blockNumber))
|
|
212
|
+
const track = (item: Item, outcome: TxOutcome) => {
|
|
213
|
+
if (outcome.blockNumber !== null) {
|
|
214
|
+
minBlock.set(item.signer.address, Math.max(minBlock.get(item.signer.address) ?? 0, outcome.blockNumber))
|
|
215
|
+
}
|
|
216
|
+
report(item, outcome)
|
|
217
|
+
}
|
|
218
|
+
// 发送前重新预估(不用补 gas 前的 gasLimit):预估时余额不足、用了兜底值的项按实际需要发;余额变了的项预估失败,不会上链 revert 白付手续费
|
|
219
|
+
const jobs: TxJob[] = tokenItems.map((item) => ({
|
|
220
|
+
signer: item.signer,
|
|
221
|
+
continueOnError: true,
|
|
222
|
+
build: async () => ({ to: item.token, data: erc20Interface.encodeFunctionData('transfer', [target, item.amount]), value: 0n }),
|
|
223
|
+
onUpdate: (outcome) => track(item, outcome),
|
|
224
|
+
}))
|
|
225
|
+
// 要扫主币时必须等代币归集确认(扫的是扣完手续费后的余额)
|
|
226
|
+
await sendJobs(rpc, jobs, sweep ? { ...options, wait: true } : options, fees)
|
|
227
|
+
|
|
228
|
+
// 代币没有全部归集成功的钱包不扫主币:留着付之后重新归集的手续费;还没确认的交易也要用这部分主币付手续费
|
|
229
|
+
items
|
|
230
|
+
.filter((i) => i.native && active(i))
|
|
231
|
+
.forEach((native) => {
|
|
232
|
+
const unfinished = items.find((i) => !i.native && i.signer.address === native.signer.address && i.result.status !== 'confirmed' && i.result.status !== 'skipped')
|
|
233
|
+
if (unfinished) {
|
|
234
|
+
report(native, emptyOutcome('skipped', `skipped: collecting ${unfinished.token} did not succeed (${unfinished.result.status})`))
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// 扫空主币:按最新余额(不早于该钱包最后一笔确认交易的区块)减去这笔的手续费
|
|
239
|
+
const sweepItems = items.filter((i) => i.native && active(i))
|
|
240
|
+
if (sweep && sweepItems.length && !options.signal?.aborted) {
|
|
241
|
+
// 代币归集已经发出:这一阶段出错不能整体抛错(调用方会拿不到这些结果),扫主币的项标记失败
|
|
242
|
+
const failSweep = (error: string) => {
|
|
243
|
+
sweepItems.forEach((i) => report(i, emptyOutcome('failed', error)))
|
|
244
|
+
return { results, topUps }
|
|
245
|
+
}
|
|
246
|
+
let sweepFee: TxFees
|
|
247
|
+
try {
|
|
248
|
+
sweepFee = await new FeeSource(rpc, options).get()
|
|
249
|
+
} catch (err) {
|
|
250
|
+
return failSweep(`cannot get the fee data: ${failureText(err)}`)
|
|
251
|
+
}
|
|
252
|
+
let gasLimit = 21_000n
|
|
253
|
+
try {
|
|
254
|
+
gasLimit = applyGasMultiplier(await rpc.estimateGas({ from: (sweepItems[0] as Item).signer.address, to: target, value: 1n }), multiplier)
|
|
255
|
+
} catch (err) {
|
|
256
|
+
// 归集地址拒收主币(如没有 receive 的合约):发了也必然 revert,白付手续费
|
|
257
|
+
if (isExecutionError(err)) return failSweep(`the collect address cannot receive the native coin: ${failureText(err)}`)
|
|
258
|
+
// 节点暂时出错:按普通转账
|
|
259
|
+
}
|
|
260
|
+
const sweepJobs = await mapPool(sweepItems, options.concurrency ?? 8, async (item): Promise<TxJob | null> => {
|
|
261
|
+
const block = minBlock.get(item.signer.address)
|
|
262
|
+
let balance: TokenBalance | undefined
|
|
263
|
+
try {
|
|
264
|
+
;[balance] = await provider.balances(item.signer.address, [NATIVE_TOKEN], block === undefined ? {} : { minBlock: block })
|
|
265
|
+
} catch (err) {
|
|
266
|
+
// 代币归集已经发出:不能整体抛错(调用方会拿不到这些结果),这一项标记失败
|
|
267
|
+
report(item, emptyOutcome('failed', `cannot read balance: ${failureText(err)}`))
|
|
268
|
+
return null
|
|
269
|
+
}
|
|
270
|
+
if (!balance?.success) {
|
|
271
|
+
report(item, emptyOutcome('failed', `cannot read balance: ${balance?.error ?? 'unknown error'}`))
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
const reserve = gasLimit * sweepFee.perGas + (await l1Fee(rpc, { to: target, value: BigInt(balance.balance), gasLimit, ...feeFields(sweepFee) }))
|
|
275
|
+
const amount = BigInt(balance.balance) - reserve
|
|
276
|
+
if (amount <= 0n) {
|
|
277
|
+
report(item, emptyOutcome('skipped', 'skipped: native balance does not cover the gas fee'))
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
item.amount = amount
|
|
281
|
+
return {
|
|
282
|
+
signer: item.signer,
|
|
283
|
+
gasLimit,
|
|
284
|
+
continueOnError: true,
|
|
285
|
+
build: async () => ({ to: target, data: '0x', value: amount }),
|
|
286
|
+
onUpdate: (outcome) => report(item, outcome),
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
await sendJobs(rpc, sweepJobs.filter((j): j is TxJob => j !== null), options, FeeSource.fixed(rpc, sweepFee))
|
|
290
|
+
}
|
|
291
|
+
return { results, topUps }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** 只保留发送参数(补 gas 时不带 tokens / onUpdate 等归集参数) */
|
|
295
|
+
function sendOptions(options: BatchCollectOptions): SendOptions {
|
|
296
|
+
const { tokens: _tokens, gasWallet: _gasWallet, gasMode: _gasMode, gasBuffer: _gasBuffer, minAmount: _minAmount, onUpdate: _onUpdate, ...rest } = options
|
|
297
|
+
return rest
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function feeFields(fee: TxFees) {
|
|
301
|
+
return fee.type === 2 ? { type: 2, maxFeePerGas: fee.maxFeePerGas, maxPriorityFeePerGas: fee.maxPriorityFeePerGas } : { type: 0, gasPrice: fee.gasPrice }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function toCollectResult(item: Item, to: string, outcome: TxOutcome): CollectItemResult {
|
|
305
|
+
return {
|
|
306
|
+
...toWriteResult(item.index, item.signer.address, to, outcome),
|
|
307
|
+
token: item.token,
|
|
308
|
+
native: item.native,
|
|
309
|
+
amount: item.amount.toString(),
|
|
310
|
+
formatted: item.decimals === null ? null : formatUnits(item.amount, item.decimals),
|
|
311
|
+
}
|
|
312
|
+
}
|