@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.
- package/CHANGELOG.md +15 -0
- package/README.md +165 -2
- package/dist/fallback.d.ts +5 -0
- package/dist/fallback.d.ts.map +1 -1
- package/dist/fallback.js +7 -0
- 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/fallback.ts +8 -0
- 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,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
|
+
}
|