@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,1100 @@
1
+ import { Interface, Signature, SigningKey, computeAddress, getBytes, hexlify, parseUnits, sha256, toBeHex, toUtf8Bytes, toUtf8String, type BigNumberish } from 'ethers'
2
+
3
+ import type { EthersLikeProvider } from '../aggregate.js'
4
+ import { encodeCall } from '../call.js'
5
+ import { ERC20_ABI, NATIVE_TOKEN, formatUnits, type TokenBalance } from '../erc20.js'
6
+ import { decodeRevertReason } from '../errors.js'
7
+ import { errorText } from '../fallback.js'
8
+ import type { Provider } from '../provider.js'
9
+ import { TronProvider, isTronAddress, toEvmAddress, toTronAddress } from '../tron.js'
10
+ import type { BatchCallOptions, WriteCall } from './batch.js'
11
+ import type { BatchCollectOptions, CollectItemResult, CollectResult } from './collect.js'
12
+ import { runOnNodes } from './rpc.js'
13
+ import { emptyOutcome, mapPool, toWriteResult, type SendOptions, type TronSendOptions, type TxOutcome, type WriteResult } from './send.js'
14
+ import { isPrivateKey, type SignerInput, type TronSigner } from './signer.js'
15
+ import type { BatchTransferOptions, TransferItem, TransferResult } from './transfer.js'
16
+
17
+ const erc20Interface = new Interface(ERC20_ABI as unknown as string[])
18
+
19
+ /** 要签名的交易 */
20
+ interface Unsigned {
21
+ txId: string
22
+ /** raw_data 的 protobuf 编码 */
23
+ raw: Uint8Array
24
+ contract: TronContract
25
+ ref: RefBlock
26
+ expiration: number
27
+ timestamp: number
28
+ feeLimit: bigint
29
+ }
30
+
31
+ interface TronKey {
32
+ /** T 开头的地址 */
33
+ address: string
34
+ /** 浏览器钱包(签名要用户确认,交易过期时间放宽) */
35
+ wallet: boolean
36
+ /** 返回 65 字节签名的十六进制(不带 0x) */
37
+ sign(tx: Unsigned): Promise<string>
38
+ }
39
+
40
+ /** 注入的 tronWeb(TronLink / OKX 等浏览器钱包):只用到这两个字段 */
41
+ interface TronWebSignerLike {
42
+ defaultAddress?: { base58?: string | boolean }
43
+ trx: { sign(transaction: unknown): Promise<{ signature?: string[] }> }
44
+ }
45
+
46
+ /** @tronweb3/tronwallet-adapter 的 adapter */
47
+ interface TronAdapterLike {
48
+ address: string | null
49
+ signTransaction(transaction: unknown): Promise<{ signature?: string[] }>
50
+ }
51
+
52
+ /**
53
+ * TronWeb 格式的交易 JSON(钱包按 raw_data 展示交易内容,按 raw_data_hex / txID 签名)。
54
+ * raw_data 与 encodeRawData 的编码一一对应(TronWeb 的 txJsonToPb 编码结果与 raw_data_hex 相同)
55
+ */
56
+ export function toTronWebTransaction(tx: Pick<Unsigned, 'txId' | 'raw' | 'contract' | 'ref' | 'expiration' | 'timestamp' | 'feeLimit'>): Record<string, unknown> {
57
+ const c = tx.contract
58
+ const sun = (value: bigint) => {
59
+ if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('Amount too large for a wallet-signed transaction')
60
+ return Number(value)
61
+ }
62
+ const value =
63
+ c.type === 'transfer'
64
+ ? { owner_address: toTronHex(c.owner), to_address: toTronHex(c.to), amount: sun(c.amount) }
65
+ : { data: c.data.replace(/^0x/, ''), owner_address: toTronHex(c.owner), contract_address: toTronHex(c.contract), ...(c.callValue ? { call_value: sun(c.callValue) } : {}) }
66
+ const type = c.type === 'transfer' ? 'TransferContract' : 'TriggerSmartContract'
67
+ const number = new Uint8Array(8)
68
+ new DataView(number.buffer).setBigUint64(0, BigInt(tx.ref.number))
69
+ return {
70
+ visible: false,
71
+ txID: tx.txId,
72
+ raw_data: {
73
+ contract: [{ parameter: { value, type_url: `type.googleapis.com/protocol.${type}` }, type }],
74
+ ref_block_bytes: hexlify(number.slice(6, 8)).slice(2),
75
+ ref_block_hash: tx.ref.hash.slice(16, 32),
76
+ expiration: tx.expiration,
77
+ timestamp: tx.timestamp,
78
+ ...(c.type === 'trigger' && tx.feeLimit ? { fee_limit: sun(tx.feeLimit) } : {}),
79
+ },
80
+ raw_data_hex: hexlify(tx.raw).slice(2),
81
+ }
82
+ }
83
+
84
+ const SECP256K1_N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n
85
+
86
+ /**
87
+ * 外部签名(钱包 / 自定义签名器)的核对和规范化:65 字节 r、s、v;v 可以是 0/1 或 27/28;
88
+ * s 在上半区(旧版 TronWeb 等不做规范化的实现约一半是这样)时换成 n − s 并翻转 v(同一个签名的另一种写法,java-tron 两种都接受)。
89
+ * 从签名恢复出的地址必须是发送方:钱包切了账户、或改了交易内容时对不上
90
+ */
91
+ export function checkedSignature(signature: unknown, txId: string, address: string, who = 'Signer'): string {
92
+ const hex = typeof signature === 'string' ? signature.replace(/^0x/, '') : ''
93
+ if (!/^[0-9a-fA-F]{130}$/.test(hex)) {
94
+ throw new Error(`${who} did not return a 65-byte signature`)
95
+ }
96
+ let s = BigInt(`0x${hex.slice(64, 128)}`)
97
+ let v = Number.parseInt(hex.slice(128), 16)
98
+ if (v < 27) v += 27
99
+ if ((v !== 27 && v !== 28) || s === 0n || s >= SECP256K1_N) {
100
+ throw new Error(`${who} returned an invalid signature`)
101
+ }
102
+ if (s > SECP256K1_N / 2n) {
103
+ s = SECP256K1_N - s
104
+ v = v === 27 ? 28 : 27
105
+ }
106
+ let normalized: Signature
107
+ let signer: string
108
+ try {
109
+ normalized = Signature.from({ r: `0x${hex.slice(0, 64)}`, s: toBeHex(s, 32), v })
110
+ signer = toTronAddress(computeAddress(SigningKey.recoverPublicKey(`0x${txId}`, normalized)))
111
+ } catch {
112
+ throw new Error(`${who} returned an invalid signature`)
113
+ }
114
+ if (signer !== address) {
115
+ throw new Error(`${who} signed with ${signer}, expected ${address}${who === 'Wallet' ? ' (switched account?)' : ''}`)
116
+ }
117
+ return normalized.serialized.slice(2)
118
+ }
119
+
120
+ function walletSignature(signed: { signature?: string[] } | null | undefined, tx: Unsigned, address: string): string {
121
+ return checkedSignature(signed?.signature?.[signed.signature.length - 1], tx.txId, address, 'Wallet')
122
+ }
123
+
124
+ /** 同一个钱包的签名请求排队:钱包同时只能处理一个确认窗口(过期重发时可能和主流程同时请求) */
125
+ const walletQueues = new WeakMap<object, Promise<unknown>>()
126
+
127
+ function queued<T>(owner: object, task: () => Promise<T>): Promise<T> {
128
+ const run = (walletQueues.get(owner) ?? Promise.resolve()).then(task, task)
129
+ walletQueues.set(
130
+ owner,
131
+ run.catch(() => {}),
132
+ )
133
+ return run
134
+ }
135
+
136
+ function isRejection(err: unknown): boolean {
137
+ const text = typeof err === 'string' ? err : `${String((err as { name?: unknown } | null)?.name ?? '')} ${String((err as { message?: unknown } | null)?.message ?? '')}`
138
+ return (err as { code?: unknown } | null)?.code === 4001 || /reject|denied|cancel|declined/i.test(text)
139
+ }
140
+
141
+ function resolveTronSigner(input: SignerInput, cache: Map<unknown, Promise<TronKey>>): Promise<TronKey> {
142
+ let resolved = cache.get(input)
143
+ if (!resolved) {
144
+ resolved = (async (): Promise<TronKey> => {
145
+ const fromKey = (key: SigningKey): TronKey => ({ address: toTronAddress(computeAddress(key.publicKey)), wallet: false, sign: async (tx) => key.sign(`0x${tx.txId}`).serialized.slice(2) })
146
+ if (typeof input === 'string') {
147
+ if (!isPrivateKey(input)) {
148
+ throw new Error('Invalid private key: expected 32 bytes of hex')
149
+ }
150
+ return fromKey(new SigningKey(input.startsWith('0x') ? input : `0x${input}`))
151
+ }
152
+ const signingKey = (input as { signingKey?: unknown }).signingKey
153
+ if (signingKey instanceof SigningKey) {
154
+ return fromKey(signingKey)
155
+ }
156
+ const custom = input as Partial<TronSigner>
157
+ if (typeof custom.address === 'string' && typeof custom.signDigest === 'function') {
158
+ const sign = custom.signDigest.bind(custom)
159
+ const address = canonical(custom.address)
160
+ return { address, wallet: false, sign: async (tx) => checkedSignature(await sign(`0x${tx.txId}`), tx.txId, address) }
161
+ }
162
+ // 浏览器钱包:注入的 tronWeb(TronLink / OKX 等)
163
+ const tronWeb = input as Partial<TronWebSignerLike>
164
+ if (typeof tronWeb.trx?.sign === 'function') {
165
+ const address = tronWeb.defaultAddress?.base58
166
+ if (typeof address !== 'string' || !address) {
167
+ throw new Error('Wallet is not connected (tronWeb.defaultAddress is empty)')
168
+ }
169
+ const trx = tronWeb.trx
170
+ return { address, wallet: true, sign: (tx) => queued(tronWeb, async () => walletSignature(await trx.sign(toTronWebTransaction(tx)), tx, address)) }
171
+ }
172
+ // @tronweb3/tronwallet-adapter 的 adapter(ethers 的 Signer 有 getAddress,不会走到这里)
173
+ const adapter = input as Partial<TronAdapterLike> & { getAddress?: unknown }
174
+ if (typeof adapter.signTransaction === 'function' && 'address' in adapter && typeof adapter.getAddress !== 'function') {
175
+ const address = adapter.address
176
+ if (!address) {
177
+ throw new Error('Wallet is not connected')
178
+ }
179
+ const target = adapter as TronAdapterLike
180
+ return { address: canonical(address), wallet: true, sign: (tx) => queued(target, async () => walletSignature(await target.signTransaction(toTronWebTransaction(tx)), tx, canonical(address))) }
181
+ }
182
+ throw new Error('Invalid Tron signer: expected a private key, an ethers Wallet, { address, signDigest }, tronWeb or a TronWallet adapter')
183
+ })()
184
+ cache.set(input, resolved)
185
+ }
186
+ return resolved
187
+ }
188
+
189
+ // ---- 交易编码(protobuf) ----
190
+
191
+ function varint(value: bigint | number): number[] {
192
+ let v = BigInt(value)
193
+ const out: number[] = []
194
+ while (v >= 0x80n) {
195
+ out.push(Number(v & 0x7fn) | 0x80)
196
+ v >>= 7n
197
+ }
198
+ out.push(Number(v))
199
+ return out
200
+ }
201
+
202
+ /** proto3:默认值(0 / 空)不写 */
203
+ const varintField = (field: number, value: bigint | number) => (BigInt(value) === 0n ? [] : [...varint((field << 3) | 0), ...varint(value)])
204
+ const bytesField = (field: number, bytes: ArrayLike<number>) => (bytes.length === 0 ? [] : [...varint((field << 3) | 2), ...varint(bytes.length), ...Array.from(bytes)])
205
+
206
+ /** 21 字节地址(0x41 + 20 字节) */
207
+ function addressBytes(address: string): Uint8Array {
208
+ return getBytes(`0x41${toEvmAddress(address).slice(2)}`)
209
+ }
210
+
211
+ export type TronContract =
212
+ | { type: 'transfer'; owner: string; to: string; amount: bigint }
213
+ | { type: 'trigger'; owner: string; contract: string; data: string; callValue: bigint }
214
+
215
+ /** int64 字段:0 ≤ v < 2^63(负数或更大的值编出来是畸形或语义不同的交易) */
216
+ function int64(value: bigint, name: string): bigint {
217
+ if (value < 0n || value >= 2n ** 63n) throw new Error(`${name} out of range: ${value}`)
218
+ return value
219
+ }
220
+
221
+ function encodeContract(c: TronContract): number[] {
222
+ if (c.type === 'transfer') int64(c.amount, 'amount')
223
+ else int64(c.callValue, 'value')
224
+ const param =
225
+ c.type === 'transfer'
226
+ ? [...bytesField(1, addressBytes(c.owner)), ...bytesField(2, addressBytes(c.to)), ...varintField(3, c.amount)]
227
+ : [...bytesField(1, addressBytes(c.owner)), ...bytesField(2, addressBytes(c.contract)), ...varintField(3, c.callValue), ...bytesField(4, getBytes(c.data))]
228
+ const typeUrl = `type.googleapis.com/protocol.${c.type === 'transfer' ? 'TransferContract' : 'TriggerSmartContract'}`
229
+ const any = [...bytesField(1, toUtf8Bytes(typeUrl)), ...bytesField(2, param)]
230
+ return [...varintField(1, c.type === 'transfer' ? 1 : 31), ...bytesField(2, any)]
231
+ }
232
+
233
+ interface RefBlock {
234
+ number: number
235
+ hash: string
236
+ timestamp: number
237
+ }
238
+
239
+ /** 交易的 raw_data:参考区块、过期时间、合约、时间戳、fee_limit(字段按编号顺序写,与节点重新序列化的结果一致,txID 才对得上) */
240
+ export function encodeRawData(contract: TronContract, ref: RefBlock, expiration: number, timestamp: number, feeLimit: bigint): Uint8Array {
241
+ const number = new Uint8Array(8)
242
+ new DataView(number.buffer).setBigUint64(0, BigInt(ref.number))
243
+ return new Uint8Array([
244
+ ...bytesField(1, number.slice(6, 8)),
245
+ ...bytesField(4, getBytes(`0x${ref.hash}`).slice(8, 16)),
246
+ ...varintField(8, expiration),
247
+ ...bytesField(11, encodeContract(contract)),
248
+ ...varintField(14, timestamp),
249
+ ...varintField(18, int64(feeLimit, 'fee_limit')),
250
+ ])
251
+ }
252
+
253
+ /** 签名后的完整交易:Transaction { raw_data = 1; signature = 2 } */
254
+ function encodeTransaction(raw: Uint8Array, signature: string): Uint8Array {
255
+ return new Uint8Array([...bytesField(1, raw), ...bytesField(2, getBytes(`0x${signature}`))])
256
+ }
257
+
258
+ // ---- 节点访问 ----
259
+
260
+ interface ChainParams {
261
+ /** 每单位能量烧多少 sun */
262
+ energyFee: bigint
263
+ /** 每字节带宽烧多少 sun */
264
+ bandwidthFee: bigint
265
+ /** 转 TRX 给没激活的地址时的激活费(1 TRX) */
266
+ newAccountFee: bigint
267
+ /** 创建账户的带宽:质押带宽不够时烧的 TRX(0.1 TRX) */
268
+ createAccountFee: bigint
269
+ }
270
+
271
+ /** 账户状态:余额和可用资源 */
272
+ interface Resources {
273
+ exists: boolean
274
+ balance: bigint
275
+ /** 剩余免费带宽(没激活的账户没有) */
276
+ free: number
277
+ /** 剩余质押带宽 */
278
+ staked: number
279
+ /** 剩余能量(质押得到的) */
280
+ energy: number
281
+ }
282
+
283
+ /**
284
+ * 按 java-tron 的顺序用带宽:质押带宽够就用质押的,否则免费带宽够就用免费的,两者不能凑在一起用;都不够时整笔按字节烧 TRX。
285
+ * 会扣减 r 里的剩余带宽,返回要烧的 sun
286
+ */
287
+ function bandwidthBurn(r: { free: number; staked: number }, bytes: number, fee: bigint): bigint {
288
+ if (r.staked >= bytes) {
289
+ r.staked -= bytes
290
+ return 0n
291
+ }
292
+ if (r.free >= bytes) {
293
+ r.free -= bytes
294
+ return 0n
295
+ }
296
+ return BigInt(bytes) * fee
297
+ }
298
+
299
+ /**
300
+ * 转 TRX 的手续费:给没激活的地址转时,java-tron 按创建账户计费:激活费 + 创建账户的带宽(质押带宽够时用质押的,否则烧 createAccountFee),
301
+ * 不再收普通带宽费,也不能用免费带宽;给已激活的地址转时按普通带宽。会扣减 r 里的剩余带宽,返回要烧的 sun
302
+ */
303
+ function transferBurn(r: { free: number; staked: number }, bytes: number, params: ChainParams, activates: boolean): bigint {
304
+ if (!activates) return bandwidthBurn(r, bytes, params.bandwidthFee)
305
+ if (r.staked >= bytes) {
306
+ r.staked -= bytes
307
+ return params.newAccountFee
308
+ }
309
+ return params.newAccountFee + params.createAccountFee
310
+ }
311
+
312
+ /** 按预估能量(× 1.2 余量)扣减账户能量,返回要烧的 sun */
313
+ function energyBurn(r: { energy: number }, energy: number, fee: bigint): bigint {
314
+ const need = Math.ceil(energy * 1.2)
315
+ const burned = Math.max(0, need - r.energy)
316
+ r.energy = Math.max(0, r.energy - need)
317
+ return BigInt(burned) * fee
318
+ }
319
+
320
+ /** 带宽按交易字节数 + 64(节点给交易结果预留的大小)计 */
321
+ const RESULT_RESERVE = 64
322
+
323
+ class TronRpc {
324
+ #ref: { at: number; value: Promise<RefBlock> } | null = null
325
+ #params: Promise<ChainParams> | null = null
326
+ /** 最近发出的交易 ID:同样内容、同一毫秒生成的交易 ID 相同,要错开 */
327
+ readonly #recent = new Set<string>()
328
+
329
+ constructor(
330
+ readonly rpc: EthersLikeProvider,
331
+ readonly options: SendOptions & TronSendOptions,
332
+ ) {}
333
+
334
+ request<T = any>(path: string, body: Record<string, unknown> = {}): Promise<T> {
335
+ return runOnNodes(this.rpc, path, async (node) => tronNode(node).request(path, body) as Promise<T>)
336
+ }
337
+
338
+ /** 参考区块(缓存 3 秒) */
339
+ refBlock(fresh = false): Promise<RefBlock> {
340
+ if (fresh || !this.#ref || Date.now() - this.#ref.at > 3_000) {
341
+ const value = this.request('wallet/getblock', { detail: false })
342
+ .catch(() => this.request('wallet/getnowblock'))
343
+ .then((block: { blockID?: string; block_header?: { raw_data?: { number?: number; timestamp?: number } } }) => {
344
+ const header = block?.block_header?.raw_data
345
+ if (!block?.blockID || header?.number === undefined || header.timestamp === undefined) {
346
+ throw new Error('Unable to read the latest Tron block')
347
+ }
348
+ return { number: header.number, hash: block.blockID, timestamp: header.timestamp }
349
+ })
350
+ value.catch(() => {
351
+ if (this.#ref?.value === value) this.#ref = null
352
+ })
353
+ this.#ref = { at: Date.now(), value }
354
+ }
355
+ return this.#ref.value
356
+ }
357
+
358
+ params(): Promise<ChainParams> {
359
+ this.#params ??= this.request<{ chainParameter?: Array<{ key: string; value?: number }> }>('wallet/getchainparameters').then((res) => {
360
+ const map = new Map((res?.chainParameter ?? []).map((p) => [p.key, BigInt(p.value ?? 0)]))
361
+ return {
362
+ energyFee: map.get('getEnergyFee') ?? 210n,
363
+ bandwidthFee: map.get('getTransactionFee') ?? 1000n,
364
+ newAccountFee: map.get('getCreateNewAccountFeeInSystemContract') ?? 1_000_000n,
365
+ createAccountFee: map.get('getCreateAccountFee') ?? 100_000n,
366
+ }
367
+ })
368
+ this.#params.catch(() => (this.#params = null))
369
+ return this.#params
370
+ }
371
+
372
+ async resources(address: string): Promise<Resources> {
373
+ const hex = toTronHex(address)
374
+ const [account, res] = await Promise.all([
375
+ this.request<{ address?: string; balance?: number }>('wallet/getaccount', { address: hex }),
376
+ this.request<Record<string, number>>('wallet/getaccountresource', { address: hex }),
377
+ ])
378
+ const n = (key: string) => Number(res?.[key] ?? 0)
379
+ const exists = Boolean(account?.address)
380
+ return {
381
+ exists,
382
+ balance: BigInt(account?.balance ?? 0),
383
+ free: exists ? Math.max(0, n('freeNetLimit') - n('freeNetUsed')) : 0,
384
+ staked: Math.max(0, n('NetLimit') - n('NetUsed')),
385
+ energy: Math.max(0, n('EnergyLimit') - n('EnergyUsed')),
386
+ }
387
+ }
388
+
389
+ readonly #exists = new Map<string, Promise<boolean>>()
390
+ readonly #contracts = new Map<string, Promise<boolean>>()
391
+
392
+ /** 账户是否已激活(转 TRX 给没激活的地址要付激活费) */
393
+ accountExists(address: string): Promise<boolean> {
394
+ let cached = this.#exists.get(address)
395
+ if (!cached) {
396
+ cached = this.request<{ address?: string }>('wallet/getaccount', { address: toTronHex(address) }).then((a) => Boolean(a?.address))
397
+ cached.catch(() => this.#exists.delete(address))
398
+ this.#exists.set(address, cached)
399
+ }
400
+ return cached
401
+ }
402
+
403
+ /** 地址是不是合约(主网禁止用 TransferContract 给合约转 TRX,要用合约调用带 call_value) */
404
+ isContract(address: string): Promise<boolean> {
405
+ let cached = this.#contracts.get(address)
406
+ if (!cached) {
407
+ cached = this.request<Record<string, unknown>>('wallet/getcontract', { value: toTronHex(address) }).then((c) => Boolean(c && (c.bytecode || c.contract_address)))
408
+ cached.catch(() => this.#contracts.delete(address))
409
+ this.#contracts.set(address, cached)
410
+ }
411
+ return cached
412
+ }
413
+
414
+ /**
415
+ * 确认交易过期且没有上链:在同一个节点上先读固化的最新区块(时间要超过过期时间 + 60 秒),再查固化的交易信息。
416
+ * 固化的数据不会回滚、也不会落后于先读到的那个区块;查询出错、节点返回错误时一律当作“不确定”(返回 false)
417
+ */
418
+ async expiredForSure(txId: string, expiration: number): Promise<boolean> {
419
+ try {
420
+ return await runOnNodes(this.rpc, 'walletsolidity', async (any) => {
421
+ const node = tronNode(any)
422
+ const block = await node.request('walletsolidity/getblock', { detail: false }).catch(() => node.request('walletsolidity/getnowblock'))
423
+ const time = block?.block_header?.raw_data?.timestamp
424
+ if (typeof time !== 'number' || time <= expiration + 60_000) return false
425
+ const info = await node.request('walletsolidity/gettransactioninfobyid', { value: txId })
426
+ if (!info || typeof info !== 'object' || 'Error' in info) return false
427
+ return !info.id
428
+ })
429
+ } catch {
430
+ return false
431
+ }
432
+ }
433
+
434
+ /** 模拟合约调用:返回消耗的能量;revert 时抛出(带原因) */
435
+ async simulate(c: Extract<TronContract, { type: 'trigger' }>): Promise<number> {
436
+ const body: Record<string, unknown> = { owner_address: toTronHex(c.owner), contract_address: toTronHex(c.contract), data: c.data.replace(/^0x/, '') }
437
+ if (c.callValue) body.call_value = Number(c.callValue)
438
+ const res = await this.request('wallet/triggerconstantcontract', body)
439
+ const result = res?.result ?? {}
440
+ if (result.code || res?.transaction?.ret?.[0]?.ret === 'FAILED' || result.result === false) {
441
+ const output = res?.constant_result?.[0]
442
+ const reason = (output && decodeRevertReason(`0x${output}`)) || (typeof result.message === 'string' ? decodeHex(result.message) : '') || String(result.code ?? 'REVERT')
443
+ throw new TronTxError(`execution reverted: ${reason}`)
444
+ }
445
+ return Number(res?.energy_used ?? 0)
446
+ }
447
+
448
+ /** 广播:'accepted';交易本身的问题抛 TronTxError;节点出错(换了节点也不行)返回说明(交易可能已经发出) */
449
+ async broadcast(signed: Uint8Array): Promise<'accepted' | { uncertain: string }> {
450
+ let res: { result?: boolean; code?: string; message?: string }
451
+ try {
452
+ res = await this.request('wallet/broadcasthex', { transaction: hexlify(signed).slice(2) })
453
+ } catch (err) {
454
+ return { uncertain: `broadcast not acknowledged: ${errorText(err)}` }
455
+ }
456
+ if (res?.result || res?.code === 'DUP_TRANSACTION_ERROR') {
457
+ return 'accepted'
458
+ }
459
+ const message = res?.message ? decodeHex(res.message) : ''
460
+ const text = `${res?.code ?? (res as { Error?: string } | null)?.Error ?? 'unknown response'}${message ? `: ${message}` : ''}`
461
+ // 只有交易本身的问题(签名、合约校验、带宽不够、太大)才确定没发出去。其余(TAPOS 参考区块对不上、节点忙、
462
+ // 未固化等)可能只是这个节点的问题,而前一个超时的节点也许已经收下了这笔:按“不确定”继续跟踪,直到确认过期
463
+ if (res?.code && FINAL_BROADCAST_CODES.has(res.code)) {
464
+ throw new TronTxError(text)
465
+ }
466
+ return { uncertain: `broadcast not acknowledged: ${text}` }
467
+ }
468
+
469
+ remember(txId: string): boolean {
470
+ if (this.#recent.has(txId)) return false
471
+ this.#recent.add(txId)
472
+ return true
473
+ }
474
+ }
475
+
476
+ class TronTxError extends Error {}
477
+
478
+ function tronNode(node: EthersLikeProvider): TronProvider {
479
+ if (!(node instanceof TronProvider)) throw new Error('Not a Tron node')
480
+ return node
481
+ }
482
+
483
+ /** 确定交易没被收下的广播错误码 */
484
+ const FINAL_BROADCAST_CODES = new Set(['SIGERROR', 'CONTRACT_VALIDATE_ERROR', 'CONTRACT_EXE_ERROR', 'BANDWITH_ERROR', 'TOO_BIG_TRANSACTION_ERROR', 'TOO_BIG_TRANSACTION_RESULT_ERROR'])
485
+
486
+ function toTronHex(address: string): string {
487
+ return `41${toEvmAddress(address).slice(2).toLowerCase()}`
488
+ }
489
+
490
+ function decodeHex(hex: string): string {
491
+ try {
492
+ return toUtf8String(`0x${hex}`)
493
+ } catch {
494
+ return hex
495
+ }
496
+ }
497
+
498
+ // ---- 发送 ----
499
+
500
+ /** 交易过期时间:参考区块时间 + 60 秒 */
501
+ const EXPIRATION = 60_000
502
+ /** 浏览器钱包签名的交易:参考区块时间 + 3 分钟(用户确认需要时间) */
503
+ const WALLET_EXPIRATION = 180_000
504
+
505
+ interface TronJob {
506
+ signer: TronKey
507
+ contract: TronContract
508
+ /** 合约调用的 fee_limit;不传时按模拟结果 */
509
+ feeLimit?: bigint
510
+ continueOnError?: boolean
511
+ onUpdate(outcome: TxOutcome): void
512
+ }
513
+
514
+ interface TransactionInfo {
515
+ id?: string
516
+ fee?: number
517
+ blockNumber?: number
518
+ result?: string
519
+ resMessage?: string
520
+ contractResult?: string[]
521
+ receipt?: { result?: string; energy_usage_total?: number }
522
+ }
523
+
524
+ /** 构造并签名交易;txID 与最近发出的重复时(同样内容、同一毫秒)错开时间戳 */
525
+ async function buildSigned(rpc: TronRpc, job: TronJob, feeLimit: bigint, fresh: boolean): Promise<{ txId: string; signed: Uint8Array; expiration: number }> {
526
+ const ref = await rpc.refBlock(fresh)
527
+ // 浏览器钱包要等用户确认,过期时间放宽
528
+ const expiration = ref.timestamp + (job.signer.wallet ? WALLET_EXPIRATION : EXPIRATION)
529
+ for (let timestamp = Date.now(); ; timestamp++) {
530
+ const limit = job.contract.type === 'trigger' ? feeLimit : 0n
531
+ const raw = encodeRawData(job.contract, ref, expiration, timestamp, limit)
532
+ const txId = sha256(raw).slice(2)
533
+ if (!rpc.remember(txId)) continue
534
+ let signature: string
535
+ try {
536
+ signature = await job.signer.sign({ txId, raw, contract: job.contract, ref, expiration, timestamp, feeLimit: limit })
537
+ } catch (err) {
538
+ throw job.signer.wallet && isRejection(err) ? new Error('rejected by user') : err
539
+ }
540
+ return { txId, signed: encodeTransaction(raw, signature), expiration }
541
+ }
542
+ }
543
+
544
+ /** 签名后交易的字节数(估算带宽);参考区块等字段长度固定,用占位值即可 */
545
+ function transactionBytes(contract: TronContract, feeLimit: bigint): number {
546
+ const raw = encodeRawData(contract, { number: 2 ** 40, hash: '00'.repeat(32), timestamp: 2 ** 41 }, 2 ** 41, 2 ** 41, contract.type === 'trigger' ? feeLimit : 0n)
547
+ return encodeTransaction(raw, '00'.repeat(65)).length + RESULT_RESERVE
548
+ }
549
+
550
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
551
+ return new Promise((resolve) => {
552
+ if (signal?.aborted) return resolve()
553
+ const timer = setTimeout(done, ms)
554
+ function done() {
555
+ clearTimeout(timer)
556
+ signal?.removeEventListener('abort', done)
557
+ resolve()
558
+ }
559
+ signal?.addEventListener('abort', done, { once: true })
560
+ })
561
+ }
562
+
563
+ /** 按 fee_limit 选项或模拟的能量算 fee_limit */
564
+ async function feeLimitFor(rpc: TronRpc, job: TronJob, energy: number): Promise<bigint> {
565
+ if (job.feeLimit !== undefined) return job.feeLimit
566
+ if (rpc.options.feeLimit !== undefined) return rpc.options.feeLimit
567
+ const { energyFee } = await rpc.params()
568
+ const limit = (BigInt(Math.ceil(energy * 1.5)) * energyFee) || 1_000_000n
569
+ return limit > 15_000_000_000n ? 15_000_000_000n : limit // 上限 15000 TRX
570
+ }
571
+
572
+ /**
573
+ * 发送一批 Tron 交易:同一钱包按顺序发出(不等上一笔确认),不同钱包并发。Tron 没有 nonce,交易之间互不阻塞;
574
+ * 合约调用先模拟,模拟失败且同一钱包还有未确认的交易时先等它们确认再模拟一次(先 approve 再调用的情况)
575
+ */
576
+ async function sendTronJobs(rpc: TronRpc, jobs: readonly TronJob[], options: SendOptions & TronSendOptions): Promise<void> {
577
+ const groups = new Map<string, TronJob[]>()
578
+ jobs.forEach((job) => {
579
+ const list = groups.get(job.signer.address)
580
+ if (list) list.push(job)
581
+ else groups.set(job.signer.address, [job])
582
+ })
583
+ await mapPool([...groups.values()], options.concurrency ?? 8, (list) => sendTronGroup(rpc, list, options))
584
+ }
585
+
586
+ async function sendTronGroup(rpc: TronRpc, jobs: readonly TronJob[], options: SendOptions & TronSendOptions): Promise<void> {
587
+ const { signal, dryRun = false, wait = true, timeout = 180_000, pollInterval = 3_000, retries = 2 } = options
588
+ const inFlight: Array<{ sent: boolean; watch: () => Promise<void> }> = []
589
+ const settle = async () => {
590
+ for (let seen = -1; seen !== inFlight.length; ) {
591
+ seen = inFlight.length
592
+ await Promise.all(inFlight.map((f) => f.watch()))
593
+ }
594
+ }
595
+ let stopped = false
596
+ const update = (job: TronJob, patch: Partial<TxOutcome> & { status: TxOutcome['status'] }) => {
597
+ try {
598
+ job.onUpdate({ ...emptyOutcome(patch.status), ...patch })
599
+ } catch {
600
+ // 调用方的进度回调出错不能打断发送
601
+ }
602
+ }
603
+ /** 这个钱包的余额和资源(第一次发送前读一次,之后按每笔的预估花费扣减) */
604
+ let account: Promise<Resources> | null = null
605
+ /**
606
+ * 发送前检查 TRX 够不够:Tron 上能量不够的合约调用照样上链、执行失败(OUT_OF_ENERGY)并烧掉 TRX,
607
+ * 不像 EVM 那样在广播时就被拒。按每笔的金额 + 能量(× 1.2)+ 带宽 + 激活费累计;不够时返回原因
608
+ */
609
+ const shortOf = async (job: TronJob, energy: number, feeLimit: bigint): Promise<string | null> => {
610
+ const r = await (account ??= rpc.resources(job.signer.address))
611
+ if (!r.exists) return 'account is not activated (it needs to receive TRX first)'
612
+ const params = await rpc.params()
613
+ const next = { ...r }
614
+ const bytes = transactionBytes(job.contract, feeLimit)
615
+ let spend: bigint
616
+ if (job.contract.type === 'trigger') {
617
+ spend = bandwidthBurn(next, bytes, params.bandwidthFee) + energyBurn(next, energy, params.energyFee) + job.contract.callValue
618
+ } else {
619
+ spend = transferBurn(next, bytes, params, !(await rpc.accountExists(job.contract.to))) + job.contract.amount
620
+ }
621
+ if (next.balance < spend) return `insufficient TRX: this transaction needs about ${spend} sun (amount + fees), the wallet has ${next.balance} left`
622
+ Object.assign(r, next, { balance: next.balance - spend })
623
+ return null
624
+ }
625
+
626
+ /** 发出一笔(attempt:过期后第几次重发) */
627
+ const submit = async (job: TronJob, attempt: number): Promise<void> => {
628
+ let energy = 0
629
+ if (job.contract.type === 'trigger') {
630
+ try {
631
+ energy = await rpc.simulate(job.contract)
632
+ } catch (err) {
633
+ if (!(err instanceof TronTxError) || attempt > 0 || !inFlight.some((f) => f.sent)) throw err
634
+ // 可能依赖前面还没确认的交易:等它们确认后重新模拟
635
+ await settle()
636
+ if (signal?.aborted) {
637
+ update(job, { status: 'skipped', error: 'aborted' })
638
+ return
639
+ }
640
+ energy = await rpc.simulate(job.contract)
641
+ }
642
+ }
643
+ const feeLimit = job.contract.type === 'trigger' ? await feeLimitFor(rpc, job, energy) : 0n
644
+ const gasLimit = job.contract.type === 'trigger' ? BigInt(energy) : null
645
+ // 过期重发的这笔之前已经扣过预估花费(原交易没执行,不再重复扣)
646
+ const short = attempt === 0 ? await shortOf(job, energy, feeLimit) : null
647
+ if (short) throw new TronTxError(short)
648
+ if (dryRun) {
649
+ update(job, { status: 'simulated', gasLimit })
650
+ return
651
+ }
652
+ const { txId, signed, expiration } = await buildSigned(rpc, job, feeLimit, attempt > 0)
653
+ const sent = await rpc.broadcast(signed)
654
+ update(job, { status: 'sent', hash: txId, gasLimit, error: sent === 'accepted' ? null : sent.uncertain })
655
+ let watching: Promise<void> | null = null
656
+ const flight = { sent: true, watch: () => (watching ??= follow()) }
657
+ const follow = async (): Promise<void> => {
658
+ const deadline = Date.now() + timeout
659
+ let polls = 0
660
+ let expiredChecks = 0
661
+ while (!signal?.aborted) {
662
+ await sleep(pollInterval, signal)
663
+ if (signal?.aborted) break
664
+ let info: TransactionInfo | null = null
665
+ try {
666
+ info = await rpc.request<TransactionInfo>('wallet/gettransactioninfobyid', { value: txId })
667
+ } catch {
668
+ // 节点暂时出错:下次再查
669
+ }
670
+ if (info?.id) {
671
+ flight.sent = false
672
+ const result = info.receipt?.result ?? 'SUCCESS'
673
+ const ok = result === 'SUCCESS' && info.result !== 'FAILED'
674
+ const output = info.contractResult?.[0]
675
+ const reason = ok ? null : (output && decodeRevertReason(`0x${output}`)) || (info.resMessage ? decodeHex(info.resMessage) : result)
676
+ update(job, {
677
+ status: ok ? 'confirmed' : 'reverted',
678
+ hash: txId,
679
+ gasLimit,
680
+ gasUsed: info.receipt?.energy_usage_total === undefined ? null : BigInt(info.receipt.energy_usage_total),
681
+ fee: BigInt(info.fee ?? 0),
682
+ blockNumber: info.blockNumber ?? null,
683
+ error: reason === null ? null : `execution reverted: ${reason}`,
684
+ })
685
+ return
686
+ }
687
+ // 过期判断按固化数据(见 expiredForSure),连续两次确认才算:误判过期后重发会重复执行。本地时钟只用来决定什么时候开始查
688
+ if (Date.now() > expiration && (await rpc.expiredForSure(txId, expiration))) {
689
+ expiredChecks++
690
+ } else {
691
+ expiredChecks = 0
692
+ }
693
+ if (expiredChecks >= 2) {
694
+ flight.sent = false
695
+ if (attempt < retries) {
696
+ await submit(job, attempt + 1).catch((err: unknown) => update(job, { status: 'failed', error: errorText(err) }))
697
+ } else {
698
+ update(job, { status: 'failed', error: 'transaction expired before it was confirmed' })
699
+ }
700
+ return
701
+ }
702
+ if (Date.now() >= deadline) {
703
+ flight.sent = false
704
+ update(job, { status: 'pending', hash: txId, gasLimit, error: `not confirmed within ${timeout}ms` })
705
+ return
706
+ }
707
+ if (++polls % 3 === 0) {
708
+ void rpc.broadcast(signed).catch(() => {})
709
+ }
710
+ }
711
+ flight.sent = false
712
+ }
713
+ inFlight.push(flight)
714
+ if (wait) void flight.watch()
715
+ }
716
+
717
+ for (const job of jobs) {
718
+ if (stopped || signal?.aborted) {
719
+ update(job, { status: 'skipped', error: signal?.aborted ? 'aborted' : 'skipped: a previous call from this wallet failed' })
720
+ continue
721
+ }
722
+ try {
723
+ await submit(job, 0)
724
+ } catch (err) {
725
+ update(job, { status: 'failed', error: errorText(err) })
726
+ if (!job.continueOnError) stopped = true
727
+ }
728
+ }
729
+ if (wait) await settle()
730
+ }
731
+
732
+ // ---- 批量调用 / 转账 / 归集 ----
733
+
734
+ async function tronRpc(provider: Provider, options: SendOptions & TronSendOptions): Promise<TronRpc> {
735
+ return new TronRpc(provider.rpc, options)
736
+ }
737
+
738
+ function canonical(address: string): string {
739
+ return toTronAddress(toEvmAddress(address))
740
+ }
741
+
742
+ /** 转 TRX:普通地址用 TransferContract;合约地址用合约调用(空 calldata 带 call_value,主网禁止 TransferContract 转给合约) */
743
+ async function trxContract(rpc: TronRpc, owner: string, to: string, amount: bigint): Promise<TronContract> {
744
+ return (await rpc.isContract(to)) ? { type: 'trigger', owner, contract: to, data: '0x', callValue: amount } : { type: 'transfer', owner, to, amount }
745
+ }
746
+
747
+ export async function tronBatchCall(provider: Provider, calls: readonly WriteCall[], options: BatchCallOptions & TronSendOptions = {}): Promise<WriteResult[]> {
748
+ if (options.mode === '7702') {
749
+ throw new Error("mode '7702' is not supported on Tron")
750
+ }
751
+ const rpc = await tronRpc(provider, options)
752
+ const cache = new Map()
753
+ const prepared = await Promise.all(
754
+ calls.map(async (item, i) => {
755
+ const signer = await resolveTronSigner(item.from, cache)
756
+ let to: string
757
+ let data: string
758
+ let value: bigint
759
+ if (item.call) {
760
+ const request = encodeCall(item.call, false)
761
+ to = request.target
762
+ data = request.callData
763
+ value = BigInt(item.value ?? item.call.overrides?.value ?? 0n)
764
+ } else {
765
+ if (!item.to) throw new Error(`calls[${i}]: either to or call is required`)
766
+ to = item.to
767
+ data = item.data ? hexlify(item.data) : '0x'
768
+ value = BigInt(item.value ?? 0n)
769
+ }
770
+ // 没有 calldata:转 TRX;否则调用合约
771
+ const contract: TronContract =
772
+ data === '0x' ? await trxContract(rpc, signer.address, canonical(to), value) : { type: 'trigger', owner: signer.address, contract: canonical(to), data, callValue: value }
773
+ return { signer, to: canonical(to), contract }
774
+ }),
775
+ )
776
+ const results = prepared.map((p, i) => toWriteResult(i, p.signer.address, p.to, emptyOutcome('skipped')))
777
+ const jobs: TronJob[] = prepared.map((p, i) => ({
778
+ signer: p.signer,
779
+ contract: p.contract,
780
+ continueOnError: options.continueOnError,
781
+ onUpdate: (outcome) => {
782
+ results[i] = toWriteResult(i, p.signer.address, p.to, outcome)
783
+ options.onUpdate?.(results[i] as WriteResult)
784
+ },
785
+ }))
786
+ await sendTronJobs(rpc, jobs, options)
787
+ return results
788
+ }
789
+
790
+ interface Planned {
791
+ index: number
792
+ to: string
793
+ token: string
794
+ native: boolean
795
+ amount: bigint
796
+ decimals: number | null
797
+ }
798
+
799
+ async function plan(provider: Provider, owner: string, recipients: readonly TransferItem[], options: BatchTransferOptions): Promise<Planned[]> {
800
+ const defaultToken = options.token ?? NATIVE_TOKEN
801
+ const keyOf = (token: string) => (provider.isNativeToken(token) ? token.toLowerCase() : canonical(token))
802
+ const tokens = [...new Set(recipients.map((r) => keyOf(r.token ?? defaultToken)))]
803
+ const balances = tokens.length ? await provider.balances(owner, tokens) : []
804
+ const info = new Map(balances.map((b, i) => [tokens[i] as string, b]))
805
+ const planned = recipients.map((item, index): Planned => {
806
+ const token = keyOf(item.token ?? defaultToken)
807
+ const balance = info.get(token)
808
+ const native = provider.isNativeToken(token)
809
+ const decimals = balance?.success ? balance.decimals : null
810
+ if (!isTronAddress(item.to) && !/^0x[0-9a-fA-F]{40}$/.test(item.to)) {
811
+ throw new Error(`recipients[${index}]: invalid address ${item.to}`)
812
+ }
813
+ if ((item.amount === undefined) === (item.formatted === undefined)) {
814
+ throw new Error(`recipients[${index}]: pass exactly one of amount / formatted`)
815
+ }
816
+ let amount: bigint
817
+ if (item.formatted !== undefined) {
818
+ if (decimals === null) {
819
+ throw new Error(`recipients[${index}]: cannot read decimals of ${token}, pass amount instead of formatted`)
820
+ }
821
+ amount = parseUnits(item.formatted, decimals)
822
+ } else {
823
+ amount = BigInt(item.amount as BigNumberish)
824
+ }
825
+ if (amount < 0n || (native && amount === 0n)) {
826
+ throw new Error(`recipients[${index}]: amount must be positive`)
827
+ }
828
+ return { index, to: canonical(item.to), token: native ? (item.token ?? defaultToken) : token, native, amount, decimals }
829
+ })
830
+ if (options.checkBalance ?? true) {
831
+ const totals = new Map<string, bigint>()
832
+ planned.forEach((p) => totals.set(keyOf(p.token), (totals.get(keyOf(p.token)) ?? 0n) + p.amount))
833
+ for (const [token, total] of totals) {
834
+ const balance = info.get(token)
835
+ if (!balance?.success) {
836
+ throw new Error(`Cannot read the balance of ${token} (${balance?.error ?? 'unknown error'}); pass checkBalance: false to skip the check`)
837
+ }
838
+ if (BigInt(balance.balance) < total) {
839
+ throw new Error(`Insufficient balance of ${token}: need ${total}, have ${balance.balance}`)
840
+ }
841
+ }
842
+ }
843
+ return planned
844
+ }
845
+
846
+ function tokenTransfer(owner: string, token: string, to: string, amount: bigint): Extract<TronContract, { type: 'trigger' }> {
847
+ return { type: 'trigger', owner, contract: token, data: erc20Interface.encodeFunctionData('transfer', [toEvmAddress(to), amount]), callValue: 0n }
848
+ }
849
+
850
+ /** Tron 一对多转账:每个收款人一笔交易(Tron 上没有通用的 Disperse 合约),结果的 via 为 'tx' */
851
+ export async function tronBatchTransfer(
852
+ provider: Provider,
853
+ from: SignerInput,
854
+ recipients: readonly TransferItem[],
855
+ options: BatchTransferOptions & TronSendOptions = {},
856
+ ): Promise<TransferResult[]> {
857
+ if (options.mode === '7702') {
858
+ throw new Error("mode '7702' is not supported on Tron")
859
+ }
860
+ const rpc = await tronRpc(provider, options)
861
+ const signer = await resolveTronSigner(from, new Map())
862
+ const planned = await plan(provider, signer.address, recipients, options)
863
+ const toResult = (p: Planned, outcome: TxOutcome): TransferResult => ({
864
+ ...toWriteResult(p.index, signer.address, p.to, outcome),
865
+ token: p.token,
866
+ native: p.native,
867
+ amount: p.amount.toString(),
868
+ formatted: p.decimals === null ? null : formatUnits(p.amount, p.decimals),
869
+ via: 'tx',
870
+ approvals: [],
871
+ })
872
+ const results = planned.map((p) => toResult(p, emptyOutcome('skipped')))
873
+ const contracts = await Promise.all(planned.map((p) => (p.native ? trxContract(rpc, signer.address, p.to, p.amount) : tokenTransfer(signer.address, p.token, p.to, p.amount))))
874
+ const jobs: TronJob[] = planned.map((p, k) => ({
875
+ signer,
876
+ contract: contracts[k] as TronContract,
877
+ continueOnError: true,
878
+ onUpdate: (outcome) => {
879
+ results[p.index] = toResult(p, outcome)
880
+ options.onUpdate?.(results[p.index] as TransferResult)
881
+ },
882
+ }))
883
+ await sendTronJobs(rpc, jobs, options)
884
+ return results
885
+ }
886
+
887
+ interface CollectItem {
888
+ index: number
889
+ signer: TronKey
890
+ token: string
891
+ native: boolean
892
+ amount: bigint
893
+ decimals: number | null
894
+ result: CollectItemResult
895
+ }
896
+
897
+ /**
898
+ * Tron 归集:与 EVM 相同的流程(补 gas → 归集代币 → 扫空 TRX)。手续费按 Tron 的资源模型算:
899
+ * 合约调用烧能量(模拟的能量 × 能量单价,减去账户已有的能量,加 20% 余量),每笔交易烧带宽(免费 / 质押带宽不够时按字节计费)。
900
+ * 没激活的子钱包由补 gas 的转账激活(激活费由 gasWallet 付)
901
+ */
902
+ export async function tronBatchCollect(provider: Provider, wallets: readonly SignerInput[], to: string, options: BatchCollectOptions & TronSendOptions): Promise<CollectResult> {
903
+ const rpc = await tronRpc(provider, options)
904
+ const target = canonical(to)
905
+ const cache = new Map()
906
+ const signers = await Promise.all(wallets.map((w) => resolveTronSigner(w, cache)))
907
+ const tokens = options.tokens
908
+ const nativeIndex = tokens.findIndex((t) => provider.isNativeToken(t))
909
+ const sweep = nativeIndex >= 0
910
+ const queryTokens = sweep ? [...tokens] : [...tokens, NATIVE_TOKEN]
911
+ const nativeAt = sweep ? nativeIndex : tokens.length
912
+ const balances = await provider.multiBalances(signers.map((s) => ({ owner: s.address, tokens: queryTokens })))
913
+ const minAmount = new Map(Object.entries(options.minAmount ?? {}).map(([k, v]) => [provider.isNativeToken(k) ? k.toLowerCase() : canonical(k), BigInt(v)]))
914
+
915
+ const items: CollectItem[] = []
916
+ const seen = new Set<string>()
917
+ signers.forEach((signer, w) => {
918
+ const duplicate = seen.has(signer.address)
919
+ seen.add(signer.address)
920
+ tokens.forEach((token, t) => {
921
+ const balance = balances[w]?.[t] as TokenBalance
922
+ const native = t === nativeIndex
923
+ const item: CollectItem = {
924
+ index: w * tokens.length + t,
925
+ signer,
926
+ token: native ? token : canonical(token),
927
+ native,
928
+ amount: native || !balance.success ? 0n : BigInt(balance.balance),
929
+ decimals: balance.success ? balance.decimals : null,
930
+ result: null as unknown as CollectItemResult,
931
+ }
932
+ const skip = !balance.success
933
+ ? `cannot read balance: ${balance.error ?? 'unknown error'}`
934
+ : signer.address === target
935
+ ? 'skipped: wallet is the collect address'
936
+ : duplicate
937
+ ? 'skipped: duplicate wallet'
938
+ : !native && item.amount === 0n
939
+ ? 'skipped: zero balance'
940
+ : !native && item.amount < (minAmount.get(item.token) ?? 0n)
941
+ ? 'skipped: below minAmount'
942
+ : null
943
+ item.result = collectResult(item, target, emptyOutcome(skip ? (balance.success ? 'skipped' : 'failed') : 'skipped', skip))
944
+ items.push(item)
945
+ })
946
+ })
947
+ const results = items.map((i) => i.result)
948
+ const report = (item: CollectItem, outcome: TxOutcome) => {
949
+ item.result = collectResult(item, target, outcome)
950
+ results[item.index] = item.result
951
+ options.onUpdate?.(item.result)
952
+ }
953
+ const active = (item: CollectItem) => item.result.error === null
954
+
955
+ // 估算每个钱包的手续费:模拟能量 + 带宽
956
+ const params = await rpc.params()
957
+ const { energyFee, bandwidthFee } = params
958
+ const byWallet = new Map<string, CollectItem[]>()
959
+ items.filter((i) => !i.native && active(i)).forEach((i) => byWallet.set(i.signer.address, [...(byWallet.get(i.signer.address) ?? []), i]))
960
+ /** 每项的 fee_limit:按全部预估能量算(fee_limit 限制的是总能量,包括账户质押得到的能量),只是上限,实际按用量扣 */
961
+ const feeLimits = new Map<CollectItem, bigint>()
962
+ const plans = await mapPool([...byWallet.entries()], options.concurrency ?? 8, async ([address, list]) => {
963
+ const wallet = signers.findIndex((s) => s.address === address)
964
+ const native = balances[wallet]?.[nativeAt] as TokenBalance
965
+ if (!native.success) {
966
+ // TRX 余额读不到:不知道要补多少,不补(多补的 TRX 也不会被扫回)
967
+ list.forEach((item) => report(item, emptyOutcome('failed', `cannot read the TRX balance: ${native.error ?? 'unknown error'}`)))
968
+ return { address, list, shortfall: 0n, exists: true }
969
+ }
970
+ const resources = await rpc.resources(address)
971
+ let need = 0n
972
+ for (const item of list) {
973
+ const contract = tokenTransfer(address, item.token, target, item.amount)
974
+ let energy: number
975
+ try {
976
+ energy = await rpc.simulate(contract)
977
+ } catch (err) {
978
+ report(item, emptyOutcome('failed', errorText(err)))
979
+ continue
980
+ }
981
+ const feeLimit = options.feeLimit ?? (BigInt(Math.ceil(energy * 1.5)) * energyFee || 1_000_000n)
982
+ feeLimits.set(item, feeLimit)
983
+ // 没激活的账户 resources 里没有免费带宽:补 gas 的转账激活它之后才有,这里按要烧 TRX 算(多出来的扫 TRX 时会转走)
984
+ need += bandwidthBurn(resources, transactionBytes(contract, feeLimit), bandwidthFee) + energyBurn(resources, energy, energyFee)
985
+ }
986
+ const nativeBalance = BigInt(native.balance)
987
+ const buffered = (need * BigInt(Math.round((options.gasBuffer ?? 1) * 1000))) / 1000n
988
+ return { address, list, shortfall: buffered > nativeBalance ? buffered - nativeBalance : 0n, exists: resources.exists }
989
+ })
990
+
991
+ // 补 gas(TRX)
992
+ let topUps: TransferResult[] = []
993
+ const short = plans.filter((p) => p.shortfall > 0n && p.list.some(active))
994
+ if (short.length) {
995
+ if (!options.gasWallet) {
996
+ short.forEach((p) => p.list.filter(active).forEach((i) => report(i, emptyOutcome('failed', `insufficient TRX for fees: need ${p.shortfall} more sun (pass gasWallet to top up automatically)`))))
997
+ } else {
998
+ const { tokens: _t, gasWallet, gasMode: _m, gasBuffer: _b, minAmount: _min, onUpdate: _u, ...send } = options
999
+ // 先确认 gasWallet 付得起全部补 gas(金额 + 激活没激活的钱包 + 带宽),不够时什么都不发
1000
+ const gas = await resolveTronSigner(gasWallet, cache)
1001
+ const gasAccount = await rpc.resources(gas.address)
1002
+ const total = short.reduce((sum, p) => {
1003
+ const bytes = transactionBytes({ type: 'transfer', owner: gas.address, to: p.address, amount: p.shortfall }, 0n)
1004
+ return sum + p.shortfall + transferBurn(gasAccount, bytes, params, !p.exists)
1005
+ }, 0n)
1006
+ if (gasAccount.balance < total) {
1007
+ throw new Error(`Insufficient TRX in gasWallet: the top-ups need about ${total} sun (including activation and bandwidth fees), it has ${gasAccount.balance}`)
1008
+ }
1009
+ topUps = await tronBatchTransfer(
1010
+ provider,
1011
+ gasWallet,
1012
+ short.map((p) => ({ to: p.address, amount: p.shortfall, token: NATIVE_TOKEN })),
1013
+ { ...send, mode: 'tx', wait: true },
1014
+ )
1015
+ topUps.forEach((t, k) => {
1016
+ if (!(t.status === 'confirmed' || (options.dryRun && t.status === 'simulated'))) {
1017
+ ;(short[k] as (typeof short)[number]).list.filter(active).forEach((i) => report(i, emptyOutcome('failed', `gas top-up ${t.status}: ${t.error ?? ''}`.trim())))
1018
+ }
1019
+ })
1020
+ }
1021
+ }
1022
+
1023
+ const tokenItems = items.filter((i) => !i.native && active(i))
1024
+ if (options.dryRun) {
1025
+ tokenItems.forEach((i) => report(i, { ...emptyOutcome('simulated') }))
1026
+ if (sweep) items.filter((i) => i.native && active(i)).forEach((i) => report(i, emptyOutcome('simulated')))
1027
+ return { results, topUps }
1028
+ }
1029
+ const jobs: TronJob[] = tokenItems.map((item) => ({
1030
+ signer: item.signer,
1031
+ contract: tokenTransfer(item.signer.address, item.token, target, item.amount),
1032
+ feeLimit: feeLimits.get(item),
1033
+ continueOnError: true,
1034
+ onUpdate: (outcome) => report(item, outcome),
1035
+ }))
1036
+ await sendTronJobs(rpc, jobs, sweep ? { ...options, wait: true } : options)
1037
+
1038
+ if (!sweep) return { results, topUps }
1039
+ // 代币没有全部归集成功的钱包不扫 TRX
1040
+ items
1041
+ .filter((i) => i.native && active(i))
1042
+ .forEach((native) => {
1043
+ const unfinished = items.find((i) => !i.native && i.signer.address === native.signer.address && i.result.status !== 'confirmed' && i.result.status !== 'skipped')
1044
+ if (unfinished) report(native, emptyOutcome('skipped', `skipped: collecting ${unfinished.token} did not succeed (${unfinished.result.status})`))
1045
+ })
1046
+ const sweepItems = items.filter((i) => i.native && active(i))
1047
+ if (!sweepItems.length || options.signal?.aborted) return { results, topUps }
1048
+ // 扫空 TRX:最新余额减去这笔的手续费(带宽够用时为 0;归集地址是合约时用合约调用,还要算能量)
1049
+ let targetIsContract: boolean
1050
+ try {
1051
+ targetIsContract = await rpc.isContract(target)
1052
+ } catch (err) {
1053
+ // 代币归集已经发出:不能整体抛错,扫 TRX 的项标记失败
1054
+ sweepItems.forEach((i) => report(i, emptyOutcome('failed', `cannot check the collect address: ${errorText(err)}`)))
1055
+ return { results, topUps }
1056
+ }
1057
+ const sweepJobs = await mapPool(sweepItems, options.concurrency ?? 8, async (item): Promise<TronJob | null> => {
1058
+ const owner = item.signer.address
1059
+ try {
1060
+ const resources = await rpc.resources(owner)
1061
+ const balance = resources.balance
1062
+ let energy = 0
1063
+ let feeLimit = 0n
1064
+ if (targetIsContract) {
1065
+ energy = await rpc.simulate({ type: 'trigger', owner, contract: target, data: '0x', callValue: 1n })
1066
+ feeLimit = options.feeLimit ?? (BigInt(Math.ceil(energy * 1.5)) * energyFee || 1_000_000n)
1067
+ }
1068
+ const probe: TronContract = targetIsContract ? { type: 'trigger', owner, contract: target, data: '0x', callValue: balance } : { type: 'transfer', owner, to: target, amount: balance }
1069
+ const fee = bandwidthBurn(resources, transactionBytes(probe, feeLimit), bandwidthFee) + (targetIsContract ? energyBurn(resources, energy, energyFee) : 0n)
1070
+ const amount = balance - fee
1071
+ const min = minAmount.get(item.token.toLowerCase()) ?? 0n
1072
+ if (amount <= 0n || amount < min) {
1073
+ report(item, emptyOutcome('skipped', amount <= 0n ? 'skipped: TRX balance does not cover the fee' : 'skipped: below minAmount'))
1074
+ return null
1075
+ }
1076
+ item.amount = amount
1077
+ const contract: TronContract = targetIsContract ? { type: 'trigger', owner, contract: target, data: '0x', callValue: amount } : { type: 'transfer', owner, to: target, amount }
1078
+ return { signer: item.signer, contract, feeLimit: targetIsContract ? feeLimit : undefined, continueOnError: true, onUpdate: (outcome) => report(item, outcome) }
1079
+ } catch (err) {
1080
+ report(item, emptyOutcome('failed', errorText(err)))
1081
+ return null
1082
+ }
1083
+ })
1084
+ await sendTronJobs(
1085
+ rpc,
1086
+ sweepJobs.filter((j): j is TronJob => j !== null),
1087
+ options,
1088
+ )
1089
+ return { results, topUps }
1090
+ }
1091
+
1092
+ function collectResult(item: CollectItem, to: string, outcome: TxOutcome): CollectItemResult {
1093
+ return {
1094
+ ...toWriteResult(item.index, item.signer.address, to, outcome),
1095
+ token: item.token,
1096
+ native: item.native,
1097
+ amount: item.amount.toString(),
1098
+ formatted: item.decimals === null ? null : formatUnits(item.amount, item.decimals),
1099
+ }
1100
+ }