@w3lib/evm-batch-call 0.7.0 → 0.8.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 +30 -0
- package/README.md +206 -1
- package/dist/dividends.d.ts +135 -0
- package/dist/dividends.d.ts.map +1 -0
- package/dist/dividends.js +253 -0
- package/dist/dividends.js.map +1 -0
- package/dist/history.d.ts +154 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +247 -0
- package/dist/history.js.map +1 -0
- package/dist/historySources.d.ts +32 -0
- package/dist/historySources.d.ts.map +1 -0
- package/dist/historySources.js +319 -0
- package/dist/historySources.js.map +1 -0
- package/dist/liquidity/v3.d.ts +2 -0
- package/dist/liquidity/v3.d.ts.map +1 -1
- package/dist/liquidity/v3.js +1 -1
- package/dist/liquidity/v3.js.map +1 -1
- package/dist/liquidity/v4.d.ts +21 -0
- package/dist/liquidity/v4.d.ts.map +1 -1
- package/dist/liquidity/v4.js +4 -3
- package/dist/liquidity/v4.js.map +1 -1
- package/dist/lp.d.ts +8 -0
- package/dist/lp.d.ts.map +1 -1
- package/dist/lp.js +17 -1
- package/dist/lp.js.map +1 -1
- package/dist/lpV4.d.ts +4 -0
- package/dist/lpV4.d.ts.map +1 -1
- package/dist/lpV4.js +27 -3
- package/dist/lpV4.js.map +1 -1
- package/dist/permit.d.ts +137 -0
- package/dist/permit.d.ts.map +1 -0
- package/dist/permit.js +355 -0
- package/dist/permit.js.map +1 -0
- package/dist/portfolio.d.ts +2 -0
- package/dist/portfolio.d.ts.map +1 -1
- package/dist/portfolio.js +9 -4
- package/dist/portfolio.js.map +1 -1
- package/dist/rewards/farms.d.ts +26 -0
- package/dist/rewards/farms.d.ts.map +1 -0
- package/dist/rewards/farms.js +210 -0
- package/dist/rewards/farms.js.map +1 -0
- package/dist/rewards/index.d.ts +42 -0
- package/dist/rewards/index.d.ts.map +1 -0
- package/dist/rewards/index.js +382 -0
- package/dist/rewards/index.js.map +1 -0
- package/dist/rewards/types.d.ts +170 -0
- package/dist/rewards/types.d.ts.map +1 -0
- package/dist/rewards/types.js +2 -0
- package/dist/rewards/types.js.map +1 -0
- package/dist/simulate.d.ts +186 -0
- package/dist/simulate.d.ts.map +1 -0
- package/dist/simulate.js +559 -0
- package/dist/simulate.js.map +1 -0
- package/dist/simulateDecode.d.ts +33 -0
- package/dist/simulateDecode.d.ts.map +1 -0
- package/dist/simulateDecode.js +321 -0
- package/dist/simulateDecode.js.map +1 -0
- package/dist/smartAccount/account.d.ts +31 -0
- package/dist/smartAccount/account.d.ts.map +1 -0
- package/dist/smartAccount/account.js +33 -0
- package/dist/smartAccount/account.js.map +1 -0
- package/dist/smartAccount/clients.d.ts +77 -0
- package/dist/smartAccount/clients.d.ts.map +1 -0
- package/dist/smartAccount/clients.js +122 -0
- package/dist/smartAccount/clients.js.map +1 -0
- package/dist/smartAccount/eip5792.d.ts +77 -0
- package/dist/smartAccount/eip5792.d.ts.map +1 -0
- package/dist/smartAccount/eip5792.js +152 -0
- package/dist/smartAccount/eip5792.js.map +1 -0
- package/dist/smartAccount/send.d.ts +40 -0
- package/dist/smartAccount/send.d.ts.map +1 -0
- package/dist/smartAccount/send.js +271 -0
- package/dist/smartAccount/send.js.map +1 -0
- package/dist/smartAccount/userOp.d.ts +91 -0
- package/dist/smartAccount/userOp.d.ts.map +1 -0
- package/dist/smartAccount/userOp.js +124 -0
- package/dist/smartAccount/userOp.js.map +1 -0
- package/dist/subpaths/dividends.d.ts +28 -0
- package/dist/subpaths/dividends.d.ts.map +1 -0
- package/dist/subpaths/dividends.js +29 -0
- package/dist/subpaths/dividends.js.map +1 -0
- package/dist/subpaths/history.d.ts +22 -0
- package/dist/subpaths/history.d.ts.map +1 -0
- package/dist/subpaths/history.js +23 -0
- package/dist/subpaths/history.js.map +1 -0
- package/dist/subpaths/permit.d.ts +14 -0
- package/dist/subpaths/permit.d.ts.map +1 -0
- package/dist/subpaths/permit.js +14 -0
- package/dist/subpaths/permit.js.map +1 -0
- package/dist/subpaths/price.js +2 -2
- package/dist/subpaths/price.js.map +1 -1
- package/dist/subpaths/rewards.d.ts +27 -0
- package/dist/subpaths/rewards.d.ts.map +1 -0
- package/dist/subpaths/rewards.js +25 -0
- package/dist/subpaths/rewards.js.map +1 -0
- package/dist/subpaths/simulate.d.ts +25 -0
- package/dist/subpaths/simulate.d.ts.map +1 -0
- package/dist/subpaths/simulate.js +28 -0
- package/dist/subpaths/simulate.js.map +1 -0
- package/dist/subpaths/smart-account.d.ts +24 -0
- package/dist/subpaths/smart-account.d.ts.map +1 -0
- package/dist/subpaths/smart-account.js +24 -0
- package/dist/subpaths/smart-account.js.map +1 -0
- package/dist/swap.d.ts +45 -5
- package/dist/swap.d.ts.map +1 -1
- package/dist/swap.js +108 -12
- package/dist/swap.js.map +1 -1
- package/dist/write/batch.d.ts +34 -2
- package/dist/write/batch.d.ts.map +1 -1
- package/dist/write/batch.js +22 -3
- package/dist/write/batch.js.map +1 -1
- package/dist/write/collect.d.ts +9 -0
- package/dist/write/collect.d.ts.map +1 -1
- package/dist/write/collect.js +41 -1
- package/dist/write/collect.js.map +1 -1
- package/dist/write/send.d.ts +4 -0
- package/dist/write/send.d.ts.map +1 -1
- package/dist/write/send.js.map +1 -1
- package/dist/write/signer.d.ts +5 -1
- package/dist/write/signer.d.ts.map +1 -1
- package/dist/write/signer.js.map +1 -1
- package/dist/write/transfer.d.ts +5 -3
- package/dist/write/transfer.d.ts.map +1 -1
- package/dist/write/transfer.js +12 -6
- package/dist/write/transfer.js.map +1 -1
- package/package.json +35 -1
- package/src/dividends.ts +360 -0
- package/src/history.ts +367 -0
- package/src/historySources.ts +360 -0
- package/src/liquidity/v3.ts +1 -1
- package/src/liquidity/v4.ts +4 -3
- package/src/lp.ts +18 -1
- package/src/lpV4.ts +32 -2
- package/src/permit.ts +457 -0
- package/src/portfolio.ts +11 -4
- package/src/rewards/farms.ts +229 -0
- package/src/rewards/index.ts +431 -0
- package/src/rewards/types.ts +179 -0
- package/src/simulate.ts +727 -0
- package/src/simulateDecode.ts +352 -0
- package/src/smartAccount/account.ts +63 -0
- package/src/smartAccount/clients.ts +180 -0
- package/src/smartAccount/eip5792.ts +187 -0
- package/src/smartAccount/send.ts +341 -0
- package/src/smartAccount/userOp.ts +180 -0
- package/src/subpaths/dividends.ts +47 -0
- package/src/subpaths/history.ts +41 -0
- package/src/subpaths/permit.ts +39 -0
- package/src/subpaths/price.ts +2 -2
- package/src/subpaths/rewards.ts +46 -0
- package/src/subpaths/simulate.ts +51 -0
- package/src/subpaths/smart-account.ts +40 -0
- package/src/swap.ts +139 -18
- package/src/write/batch.ts +44 -5
- package/src/write/collect.ts +60 -1
- package/src/write/send.ts +4 -0
- package/src/write/signer.ts +5 -1
- package/src/write/transfer.ts +17 -10
package/src/simulate.ts
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 交易模拟(子路径 `@w3lib/evm-batch-call/simulate`):发送前先看会发生什么——成不成功、花多少 gas、哪些资产进出、给了什么授权、风险提示。
|
|
3
|
+
*
|
|
4
|
+
* - EVM:`eth_simulateV1`(公共节点 publicnode / drpc / mainnet.base.org / bsc-dataseed 等都支持):一次请求执行一串交易、状态逐笔延续,
|
|
5
|
+
* `traceTransfers` 把主币转账也变成日志;资产变化和授权变化都从日志里算(ERC20 / 721 / 1155 Transfer、WETH Deposit / Withdrawal、Approval、
|
|
6
|
+
* ApprovalForAll、Permit2 Approval)。不支持时退回 `eth_call` + `eth_estimateGas`:只能知道成不成功和 gas,资产变化只能按 calldata 估
|
|
7
|
+
* - Tron:`triggerconstantcontract`(能量、日志里的 TRC20 Transfer / Approval);TRX 的变化只有 call_value
|
|
8
|
+
*/
|
|
9
|
+
import { getBigInt, id, toBeHex, type BigNumberish } from 'ethers'
|
|
10
|
+
|
|
11
|
+
import { knownSpenders } from './approvals.js'
|
|
12
|
+
import type { BlockTag } from './aggregate.js'
|
|
13
|
+
import { getNativeCurrency } from './chains.js'
|
|
14
|
+
import { NATIVE_TOKEN, UNLIMITED_ALLOWANCE_THRESHOLD, formatUnits } from './erc20.js'
|
|
15
|
+
import { isExecutionError } from './errors.js'
|
|
16
|
+
import { errorText } from './fallback.js'
|
|
17
|
+
import type { Provider } from './provider.js'
|
|
18
|
+
import { decodeCalldata, decodeRevert, type DecodedCall } from './simulateDecode.js'
|
|
19
|
+
import { TronProvider, isTronChain, toEvmAddress, toTronAddress } from './tron.js'
|
|
20
|
+
|
|
21
|
+
export { decodeCalldata, decodeRevert, type DecodedCall, type DecodedType } from './simulateDecode.js'
|
|
22
|
+
|
|
23
|
+
/** 要模拟的交易,字段与 buildSwap / buildRevokes / 流动性构建的 transactions 里的一致(多出来的字段忽略) */
|
|
24
|
+
export interface SimulateTx {
|
|
25
|
+
from?: string
|
|
26
|
+
to?: string
|
|
27
|
+
data?: string
|
|
28
|
+
/** 附带的主币(最小单位;Tron 是 sun) */
|
|
29
|
+
value?: BigNumberish
|
|
30
|
+
gas?: BigNumberish
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 状态覆盖(eth_call / eth_simulateV1 的格式):改余额、nonce、代码、存储 */
|
|
34
|
+
export type StateOverrides = Record<string, { balance?: string; nonce?: string; code?: string; state?: Record<string, string>; stateDiff?: Record<string, string> }>
|
|
35
|
+
|
|
36
|
+
export interface SimulateOptions {
|
|
37
|
+
/** 发送者(交易里没有 from 时用),也是资产变化 / 授权变化的主体 */
|
|
38
|
+
from?: string
|
|
39
|
+
/** 另外要统计资产变化、授权的地址(默认只有 from) */
|
|
40
|
+
watch?: readonly string[]
|
|
41
|
+
/** 按这个区块的状态模拟(默认最新) */
|
|
42
|
+
blockTag?: BlockTag
|
|
43
|
+
stateOverrides?: StateOverrides
|
|
44
|
+
/** 每笔交易的 gas 上限,默认 min(1000 万, 2500 万 / 笔数):不同节点限制一个模拟里的总 gas */
|
|
45
|
+
gas?: bigint
|
|
46
|
+
/** 估算手续费(gasUsed × 当前 gas 价格;不含 L1 数据费),默认 true */
|
|
47
|
+
fee?: boolean
|
|
48
|
+
/** 给资产变化带美元价值(用 /price,按需加载):true 用默认价格源,或传 tokenPrices 的 sources */
|
|
49
|
+
usd?: boolean | import('./price.js').TokenPricesOptions['sources']
|
|
50
|
+
/** 对收到的代币做风险检查(用 /risk,按需加载,只支持 EVM 的 V2 池子能买卖的代币),有问题时加进 warnings */
|
|
51
|
+
risk?: boolean
|
|
52
|
+
/** 不用 eth_simulateV1,直接 eth_call + estimateGas(调试 / 节点行为不一致时) */
|
|
53
|
+
forceCall?: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type WarningSeverity = 'info' | 'warn' | 'danger'
|
|
57
|
+
|
|
58
|
+
export interface SimulationWarning {
|
|
59
|
+
severity: WarningSeverity
|
|
60
|
+
/**
|
|
61
|
+
* unlimited-approval / unknown-spender / approve-to-eoa / unlimited-nft-approval / no-code / large-value /
|
|
62
|
+
* token-risk / unknown-method / no-logs / no-state / reverted
|
|
63
|
+
*/
|
|
64
|
+
code: string
|
|
65
|
+
message: string
|
|
66
|
+
/** 相关地址:授权类提示是被授权方,token-risk 是代币 */
|
|
67
|
+
address?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface BalanceChange {
|
|
71
|
+
address: string
|
|
72
|
+
/** 代币地址;主币是 'native' */
|
|
73
|
+
asset: string
|
|
74
|
+
symbol: string | null
|
|
75
|
+
decimals: number
|
|
76
|
+
/** 带符号的最小单位数量 */
|
|
77
|
+
delta: string
|
|
78
|
+
formatted: string
|
|
79
|
+
usd?: number | null
|
|
80
|
+
/** NFT(721 / 1155):tokenId,数量是个数 */
|
|
81
|
+
tokenId?: string
|
|
82
|
+
standard: 'native' | 'erc20' | 'erc721' | 'erc1155'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ApprovalChange {
|
|
86
|
+
type: 'erc20' | 'permit2' | 'nft' | 'nft-token'
|
|
87
|
+
owner: string
|
|
88
|
+
/** 代币 / NFT 合约 */
|
|
89
|
+
token: string
|
|
90
|
+
symbol: string | null
|
|
91
|
+
/** 被授权方(ERC20 的 spender、Permit2 的 spender、NFT 的 operator / approved) */
|
|
92
|
+
spender: string
|
|
93
|
+
spenderName?: string
|
|
94
|
+
/** 新的额度(最小单位);NFT 的 ApprovalForAll 是 '1' / '0',单个 NFT 是 tokenId */
|
|
95
|
+
amount: string
|
|
96
|
+
unlimited: boolean
|
|
97
|
+
/** 授权被取消(额度 0 / setApprovalForAll false) */
|
|
98
|
+
revoked: boolean
|
|
99
|
+
/** Permit2:授权到期的时间(秒) */
|
|
100
|
+
expiration?: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 一条转账(所有地址之间的,不只是 from 的) */
|
|
104
|
+
export interface SimulatedTransfer {
|
|
105
|
+
asset: string
|
|
106
|
+
from: string
|
|
107
|
+
to: string
|
|
108
|
+
amount: string
|
|
109
|
+
tokenId?: string
|
|
110
|
+
standard: BalanceChange['standard']
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface SimulationResult {
|
|
114
|
+
success: boolean
|
|
115
|
+
/** 失败原因:解码后的 revert 原因(Error / Panic / 常见自定义错误)或节点给的错误信息 */
|
|
116
|
+
error?: string
|
|
117
|
+
/** revert 的原始数据 */
|
|
118
|
+
revertData?: string
|
|
119
|
+
/** 消耗的 gas(Tron 是能量) */
|
|
120
|
+
gasUsed: string
|
|
121
|
+
/** 估算的手续费(wei),不含 L1 数据费;Tron 没有 */
|
|
122
|
+
fee?: string
|
|
123
|
+
balanceChanges: BalanceChange[]
|
|
124
|
+
approvalChanges: ApprovalChange[]
|
|
125
|
+
decoded: {
|
|
126
|
+
type: DecodedCall['type']
|
|
127
|
+
protocol?: string
|
|
128
|
+
method?: string
|
|
129
|
+
summary: string
|
|
130
|
+
args?: Record<string, unknown>
|
|
131
|
+
calls?: DecodedCall[]
|
|
132
|
+
}
|
|
133
|
+
warnings: SimulationWarning[]
|
|
134
|
+
transfers: SimulatedTransfer[]
|
|
135
|
+
/** 原始日志 */
|
|
136
|
+
logs?: Array<{ address: string; topics: string[]; data: string }>
|
|
137
|
+
/** 用的模拟方式:'simulateV1'(有日志)、'eth_call'(只有成败和 gas)、'tron' */
|
|
138
|
+
mode: 'simulateV1' | 'eth_call' | 'tron'
|
|
139
|
+
returnData?: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface BatchSimulationResult {
|
|
143
|
+
/** 全部成功 */
|
|
144
|
+
success: boolean
|
|
145
|
+
/** 每笔交易的结果,与输入一一对应 */
|
|
146
|
+
results: SimulationResult[]
|
|
147
|
+
/** 第一笔失败的下标 */
|
|
148
|
+
failedIndex?: number
|
|
149
|
+
gasUsed: string
|
|
150
|
+
fee?: string
|
|
151
|
+
/** 汇总(同一个资产、同一个地址的变化相加;授权取最后的状态) */
|
|
152
|
+
balanceChanges: BalanceChange[]
|
|
153
|
+
approvalChanges: ApprovalChange[]
|
|
154
|
+
warnings: SimulationWarning[]
|
|
155
|
+
mode: SimulationResult['mode']
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
type RawLog = { address: string; topics: string[]; data: string }
|
|
159
|
+
|
|
160
|
+
interface RawCall {
|
|
161
|
+
success: boolean
|
|
162
|
+
gasUsed: bigint
|
|
163
|
+
returnData: string
|
|
164
|
+
error?: string
|
|
165
|
+
logs: RawLog[]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const NATIVE_LOG_ADDRESS = NATIVE_TOKEN.toLowerCase()
|
|
169
|
+
const ZERO = '0x0000000000000000000000000000000000000000'
|
|
170
|
+
const T = {
|
|
171
|
+
transfer: id('Transfer(address,address,uint256)'),
|
|
172
|
+
single: id('TransferSingle(address,address,address,uint256,uint256)'),
|
|
173
|
+
batch: id('TransferBatch(address,address,address,uint256[],uint256[])'),
|
|
174
|
+
deposit: id('Deposit(address,uint256)'),
|
|
175
|
+
withdrawal: id('Withdrawal(address,uint256)'),
|
|
176
|
+
approval: id('Approval(address,address,uint256)'),
|
|
177
|
+
approvalAll: id('ApprovalForAll(address,address,bool)'),
|
|
178
|
+
permit2Approval: id('Approval(address,address,address,uint160,uint48)'),
|
|
179
|
+
permit2Permit: id('Permit(address,address,address,uint160,uint48,uint48)'),
|
|
180
|
+
lockdown: id('Lockdown(address,address,address)'),
|
|
181
|
+
}
|
|
182
|
+
const PERMIT2 = '0x000000000022d473030f116ddee9f6b43ac78ba3'
|
|
183
|
+
|
|
184
|
+
const addr = (topic: string | undefined) => `0x${(topic ?? '').slice(-40)}`.toLowerCase()
|
|
185
|
+
const num = (hex: string | undefined) => (hex && hex !== '0x' ? BigInt(hex) : 0n)
|
|
186
|
+
const word = (data: string, i: number) => `0x${data.slice(2 + i * 64, 2 + (i + 1) * 64)}`
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// 日志 → 转账 / 授权
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
type Move = SimulatedTransfer & { fromL: string; toL: string; value: bigint }
|
|
193
|
+
|
|
194
|
+
function parseTransfers(logs: readonly RawLog[]): Move[] {
|
|
195
|
+
const moves: Move[] = []
|
|
196
|
+
const push = (asset: string, from: string, to: string, value: bigint, standard: Move['standard'], tokenId?: string) =>
|
|
197
|
+
moves.push({ asset, from, to, amount: value.toString(), standard, ...(tokenId === undefined ? {} : { tokenId }), fromL: from.toLowerCase(), toL: to.toLowerCase(), value })
|
|
198
|
+
const has = (log: RawLog, from: string, to: string, value: bigint) =>
|
|
199
|
+
logs.some((l) => l.address.toLowerCase() === log.address && l.topics[0] === T.transfer && l.topics.length === 3 && addr(l.topics[1]) === from && addr(l.topics[2]) === to && num(l.data) === value)
|
|
200
|
+
for (const log of logs) {
|
|
201
|
+
const [topic0] = log.topics
|
|
202
|
+
const address = log.address.toLowerCase()
|
|
203
|
+
if (topic0 === T.transfer && log.topics.length === 3) {
|
|
204
|
+
push(address === NATIVE_LOG_ADDRESS ? 'native' : address, addr(log.topics[1]), addr(log.topics[2]), num(log.data), address === NATIVE_LOG_ADDRESS ? 'native' : 'erc20')
|
|
205
|
+
} else if (topic0 === T.transfer && log.topics.length === 4) {
|
|
206
|
+
push(address, addr(log.topics[1]), addr(log.topics[2]), 1n, 'erc721', BigInt(log.topics[3] as string).toString())
|
|
207
|
+
} else if (topic0 === T.single && log.topics.length === 4) {
|
|
208
|
+
push(address, addr(log.topics[2]), addr(log.topics[3]), num(word(log.data, 1)), 'erc1155', num(word(log.data, 0)).toString())
|
|
209
|
+
} else if (topic0 === T.batch && log.topics.length === 4) {
|
|
210
|
+
// data: ids 偏移、values 偏移、ids 长度 + 元素、values 长度 + 元素
|
|
211
|
+
const n = Number(num(word(log.data, 2)))
|
|
212
|
+
if (n > 0 && n < 1000) for (let i = 0; i < n; i++) push(address, addr(log.topics[2]), addr(log.topics[3]), num(word(log.data, 4 + n + i)), 'erc1155', num(word(log.data, 3 + i)).toString())
|
|
213
|
+
} else if ((topic0 === T.deposit || topic0 === T.withdrawal) && log.topics.length === 2) {
|
|
214
|
+
// WETH9 的存入 / 取出只有 Deposit / Withdrawal 事件,没有 Transfer;同时发了 Transfer 的合约不重复算
|
|
215
|
+
const who = addr(log.topics[1])
|
|
216
|
+
const value = num(log.data)
|
|
217
|
+
const [from, to] = topic0 === T.deposit ? [ZERO, who] : [who, ZERO]
|
|
218
|
+
if (!has({ ...log, address }, from, to, value)) push(address, from, to, value, 'erc20')
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return moves
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
type Approve = Omit<ApprovalChange, 'symbol' | 'spenderName'>
|
|
225
|
+
|
|
226
|
+
function parseApprovals(logs: readonly RawLog[]): Approve[] {
|
|
227
|
+
const out: Approve[] = []
|
|
228
|
+
for (const log of logs) {
|
|
229
|
+
const [topic0] = log.topics
|
|
230
|
+
const token = log.address.toLowerCase()
|
|
231
|
+
if (topic0 === T.approval && log.topics.length === 3) {
|
|
232
|
+
const amount = num(log.data)
|
|
233
|
+
out.push({ type: 'erc20', owner: addr(log.topics[1]), token, spender: addr(log.topics[2]), amount: amount.toString(), unlimited: amount >= UNLIMITED_ALLOWANCE_THRESHOLD, revoked: amount === 0n })
|
|
234
|
+
} else if (topic0 === T.approval && log.topics.length === 4) {
|
|
235
|
+
const spender = addr(log.topics[2])
|
|
236
|
+
out.push({ type: 'nft-token', owner: addr(log.topics[1]), token, spender, amount: BigInt(log.topics[3] as string).toString(), unlimited: false, revoked: spender === ZERO })
|
|
237
|
+
} else if (topic0 === T.approvalAll && log.topics.length === 3) {
|
|
238
|
+
const on = num(log.data) !== 0n
|
|
239
|
+
out.push({ type: 'nft', owner: addr(log.topics[1]), token, spender: addr(log.topics[2]), amount: on ? '1' : '0', unlimited: on, revoked: !on })
|
|
240
|
+
} else if (token === PERMIT2 && (topic0 === T.permit2Approval || topic0 === T.permit2Permit) && log.topics.length === 4) {
|
|
241
|
+
const amount = num(word(log.data, 0))
|
|
242
|
+
out.push({
|
|
243
|
+
type: 'permit2',
|
|
244
|
+
owner: addr(log.topics[1]),
|
|
245
|
+
token: addr(log.topics[2]),
|
|
246
|
+
spender: addr(log.topics[3]),
|
|
247
|
+
amount: amount.toString(),
|
|
248
|
+
unlimited: amount >= UNLIMITED_ALLOWANCE_THRESHOLD,
|
|
249
|
+
revoked: amount === 0n,
|
|
250
|
+
expiration: Number(num(word(log.data, 1))),
|
|
251
|
+
})
|
|
252
|
+
} else if (token === PERMIT2 && topic0 === T.lockdown && log.topics.length === 2) {
|
|
253
|
+
// Lockdown(address indexed owner, address token, address spender):只有 owner 是 indexed,token / spender 在 data 里
|
|
254
|
+
out.push({ type: 'permit2', owner: addr(log.topics[1]), token: addr(word(log.data, 0)), spender: addr(word(log.data, 1)), amount: '0', unlimited: false, revoked: true })
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return out
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// 节点请求
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
type Sender = { send(method: string, params: unknown[]): Promise<unknown> }
|
|
265
|
+
|
|
266
|
+
async function nodes() {
|
|
267
|
+
const { runOnNodes } = await import('./write/rpc.js')
|
|
268
|
+
return runOnNodes
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const hex = (value: BigNumberish) => toBeHex(getBigInt(value))
|
|
272
|
+
|
|
273
|
+
interface NormalizedTx {
|
|
274
|
+
from: string
|
|
275
|
+
to?: string
|
|
276
|
+
data: string
|
|
277
|
+
value: bigint
|
|
278
|
+
gas?: bigint
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function normalize(tx: SimulateTx, from: string | undefined, tron: boolean): NormalizedTx {
|
|
282
|
+
const f = tx.from ?? from
|
|
283
|
+
if (!f) throw new Error('simulate requires a sender: pass tx.from or options.from')
|
|
284
|
+
const conv = (a: string) => (tron ? toEvmAddress(a) : a)
|
|
285
|
+
return { from: conv(f), ...(tx.to ? { to: conv(tx.to) } : {}), data: tx.data && tx.data !== '0x' ? tx.data : '0x', value: tx.value === undefined ? 0n : getBigInt(tx.value), ...(tx.gas === undefined ? {} : { gas: getBigInt(tx.gas) }) }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const UNSUPPORTED_RE = /not (supported|available|whitelisted|allowed|found)|does not exist|unknown method|method not|-32601|disabled|discontinued|adjustable limit|invalid (params|argument)|unrecognized|too many|rate.?limit/i
|
|
289
|
+
/** ethers 把节点的错误包成 “…; could not coalesce error”,去掉这段噪音 */
|
|
290
|
+
const cleanError = (err: unknown) => errorText(err).replace(/;\s*could not coalesce error.*$/s, '')
|
|
291
|
+
|
|
292
|
+
// 交易本身的问题(换节点结果一样):直接作为模拟失败的原因
|
|
293
|
+
const TX_ERROR_RE = /insufficient funds|intrinsic gas|max fee per gas|nonce too|execution reverted|invalid sender/i
|
|
294
|
+
|
|
295
|
+
async function simulateV1(provider: Provider, txs: NormalizedTx[], options: SimulateOptions): Promise<RawCall[]> {
|
|
296
|
+
const run = await nodes()
|
|
297
|
+
const perCall = options.gas ?? BigInt(Math.min(10_000_000, Math.floor(25_000_000 / txs.length)))
|
|
298
|
+
const calls = txs.map((t) => ({ from: t.from, ...(t.to ? { to: t.to } : {}), data: t.data, value: hex(t.value), gas: hex(t.gas ?? perCall) }))
|
|
299
|
+
const block = { calls, ...(options.stateOverrides ? { stateOverrides: options.stateOverrides } : {}) }
|
|
300
|
+
const tag = options.blockTag === undefined ? 'latest' : typeof options.blockTag === 'number' ? hex(options.blockTag) : options.blockTag
|
|
301
|
+
const result = (await run(
|
|
302
|
+
provider.rpc,
|
|
303
|
+
'simulate',
|
|
304
|
+
async (node) => {
|
|
305
|
+
if (typeof (node as Partial<Sender>).send !== 'function') throw new Error('eth_simulateV1 is not supported by this node')
|
|
306
|
+
return (node as unknown as Sender).send('eth_simulateV1', [{ blockStateCalls: [block], traceTransfers: true, validation: false }, tag])
|
|
307
|
+
},
|
|
308
|
+
(err) => TX_ERROR_RE.test(errorText(err)) && !UNSUPPORTED_RE.test(errorText(err)),
|
|
309
|
+
)) as Array<{ calls?: Array<{ status?: string; returnData?: string; gasUsed?: string; logs?: RawLog[]; error?: { message?: string; data?: string } }> }>
|
|
310
|
+
const done = result?.[0]?.calls
|
|
311
|
+
if (!Array.isArray(done) || done.length !== txs.length) throw new Error('eth_simulateV1 returned an unexpected result')
|
|
312
|
+
return done.map((c) => {
|
|
313
|
+
const ok = c.status === '0x1'
|
|
314
|
+
const returnData = c.returnData && c.returnData !== '0x' ? c.returnData : (c.error?.data ?? '0x')
|
|
315
|
+
return {
|
|
316
|
+
success: ok,
|
|
317
|
+
gasUsed: num(c.gasUsed),
|
|
318
|
+
returnData,
|
|
319
|
+
...(ok ? {} : { error: decodeRevert(returnData) ?? c.error?.message ?? 'execution reverted' }),
|
|
320
|
+
logs: (c.logs ?? []).map((l) => ({ address: l.address, topics: l.topics, data: l.data })),
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** 退回方式:每笔单独 eth_call + estimateGas(状态不延续) */
|
|
326
|
+
async function callEach(provider: Provider, txs: NormalizedTx[], options: SimulateOptions): Promise<RawCall[]> {
|
|
327
|
+
const run = await nodes()
|
|
328
|
+
const tag = options.blockTag === undefined ? 'latest' : typeof options.blockTag === 'number' ? hex(options.blockTag) : options.blockTag
|
|
329
|
+
return Promise.all(
|
|
330
|
+
txs.map(async (t): Promise<RawCall> => {
|
|
331
|
+
const req = { from: t.from, ...(t.to ? { to: t.to } : {}), data: t.data, value: hex(t.value), ...(t.gas === undefined ? {} : { gas: hex(t.gas) }) }
|
|
332
|
+
const send = (method: string, params: unknown[]) =>
|
|
333
|
+
run(provider.rpc, method, async (node) => {
|
|
334
|
+
if (typeof (node as Partial<Sender>).send !== 'function') throw new Error(`${method} is not supported by this node; pass an RPC URL or an ethers Provider`)
|
|
335
|
+
return (node as unknown as Sender).send(method, params)
|
|
336
|
+
}, isExecutionError)
|
|
337
|
+
try {
|
|
338
|
+
const returnData = (await send('eth_call', options.stateOverrides ? [req, tag, options.stateOverrides] : [req, tag])) as string
|
|
339
|
+
const gas = await send('eth_estimateGas', options.stateOverrides ? [req, tag, options.stateOverrides] : [req, tag]).then((g) => BigInt(g as string), () => 0n)
|
|
340
|
+
return { success: true, gasUsed: gas, returnData, logs: [] }
|
|
341
|
+
} catch (err) {
|
|
342
|
+
const data = (err as { data?: unknown } | null)?.data
|
|
343
|
+
const returnData = typeof data === 'string' ? data : '0x'
|
|
344
|
+
const msg = cleanError(err)
|
|
345
|
+
return { success: false, gasUsed: 0n, returnData, error: decodeRevert(returnData) ?? (isExecutionError(err) ? msg.replace(/^.*?execution reverted:?\s*/i, '') || 'execution reverted' : msg), logs: [] }
|
|
346
|
+
}
|
|
347
|
+
}),
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function simulateTronCalls(provider: Provider, txs: NormalizedTx[]): Promise<RawCall[]> {
|
|
352
|
+
const run = await nodes()
|
|
353
|
+
const hexAddr = (a: string) => `41${toEvmAddress(a).slice(2).toLowerCase()}`
|
|
354
|
+
const out: RawCall[] = []
|
|
355
|
+
for (const t of txs) {
|
|
356
|
+
const body: Record<string, unknown> = { owner_address: hexAddr(t.from), data: t.data.replace(/^0x/, '') }
|
|
357
|
+
if (t.to) body.contract_address = hexAddr(t.to)
|
|
358
|
+
if (t.value) body.call_value = Number(t.value)
|
|
359
|
+
const res = (await run(provider.rpc, 'simulate', async (node) => {
|
|
360
|
+
if (!(node instanceof TronProvider)) throw new Error('This node is not a Tron node')
|
|
361
|
+
return node.request('wallet/triggerconstantcontract', body)
|
|
362
|
+
})) as { result?: { code?: string; message?: string; result?: boolean }; constant_result?: string[]; energy_used?: number; logs?: Array<{ address: string; topics?: string[]; data?: string }>; transaction?: { ret?: Array<{ ret?: string }> } } | undefined
|
|
363
|
+
const result = res?.result ?? {}
|
|
364
|
+
const output = `0x${res?.constant_result?.[0] ?? ''}`
|
|
365
|
+
const message = typeof result.message === 'string' ? safeUtf8(result.message) : ''
|
|
366
|
+
const failed = Boolean(result.code) || result.result === false || res?.transaction?.ret?.[0]?.ret === 'FAILED'
|
|
367
|
+
out.push({
|
|
368
|
+
success: !failed,
|
|
369
|
+
gasUsed: BigInt(res?.energy_used ?? 0),
|
|
370
|
+
returnData: output,
|
|
371
|
+
...(failed ? { error: decodeRevert(output) ?? (message || String(result.code ?? 'REVERT')) } : {}),
|
|
372
|
+
logs: (res?.logs ?? []).map((l) => ({ address: `0x${l.address}`, topics: (l.topics ?? []).map((x) => `0x${x}`), data: `0x${l.data ?? ''}` })),
|
|
373
|
+
})
|
|
374
|
+
// Tron 的模拟不延续状态:每笔都按当前链上状态算
|
|
375
|
+
}
|
|
376
|
+
return out
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function safeUtf8(hexText: string): string {
|
|
380
|
+
try {
|
|
381
|
+
return new TextDecoder().decode(Uint8Array.from(hexText.match(/../g)?.map((b) => Number.parseInt(b, 16)) ?? []))
|
|
382
|
+
} catch {
|
|
383
|
+
return hexText
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// 汇总
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
interface Ctx {
|
|
392
|
+
provider: Provider
|
|
393
|
+
chainId: number
|
|
394
|
+
tron: boolean
|
|
395
|
+
subjects: Set<string>
|
|
396
|
+
meta: Map<string, { symbol: string | null; decimals: number }>
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const outAddr = (ctx: Ctx, a: string) => (ctx.tron && a !== 'native' ? toTronAddress(a) : a)
|
|
400
|
+
|
|
401
|
+
async function loadMeta(ctx: Ctx, assets: readonly string[]): Promise<void> {
|
|
402
|
+
const want = [...new Set(assets)].filter((a) => !ctx.meta.has(a))
|
|
403
|
+
if (!want.length) return
|
|
404
|
+
const native = getNativeCurrency(ctx.chainId)
|
|
405
|
+
const tokens = want.filter((a) => a !== 'native')
|
|
406
|
+
if (want.includes('native')) ctx.meta.set('native', { symbol: native?.symbol ?? null, decimals: native?.decimals ?? 18 })
|
|
407
|
+
if (!tokens.length) return
|
|
408
|
+
const details = await ctx.provider.tokens(tokens.map((a) => outAddr(ctx, a)), { fields: ['symbol', 'decimals'] }).catch(() => [])
|
|
409
|
+
tokens.forEach((a, i) => {
|
|
410
|
+
const d = details[i]
|
|
411
|
+
ctx.meta.set(a, { symbol: d?.symbol ?? null, decimals: typeof d?.decimals === 'number' ? d.decimals : 0 })
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function changesOf(ctx: Ctx, moves: readonly Move[]): BalanceChange[] {
|
|
416
|
+
const net = new Map<string, BalanceChange & { raw: bigint }>()
|
|
417
|
+
for (const m of moves) {
|
|
418
|
+
for (const [who, sign] of [[m.fromL, -1n], [m.toL, 1n]] as const) {
|
|
419
|
+
if (!ctx.subjects.has(who) || who === ZERO) continue
|
|
420
|
+
const key = `${who}|${m.asset}|${m.tokenId ?? ''}`
|
|
421
|
+
const meta = ctx.meta.get(m.asset)
|
|
422
|
+
const entry = net.get(key) ?? {
|
|
423
|
+
address: outAddr(ctx, who),
|
|
424
|
+
asset: outAddr(ctx, m.asset),
|
|
425
|
+
symbol: meta?.symbol ?? null,
|
|
426
|
+
decimals: m.standard === 'erc721' || m.standard === 'erc1155' ? 0 : (meta?.decimals ?? 18),
|
|
427
|
+
delta: '0',
|
|
428
|
+
formatted: '0',
|
|
429
|
+
standard: m.standard,
|
|
430
|
+
...(m.tokenId === undefined ? {} : { tokenId: m.tokenId }),
|
|
431
|
+
raw: 0n,
|
|
432
|
+
}
|
|
433
|
+
entry.raw += sign * m.value
|
|
434
|
+
net.set(key, entry)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return [...net.values()]
|
|
438
|
+
.filter((e) => e.raw !== 0n)
|
|
439
|
+
.map(({ raw, ...e }) => ({ ...e, delta: raw.toString(), formatted: formatUnits(raw, e.decimals) }))
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const label = (c: BalanceChange) => `${c.formatted} ${c.symbol ?? (c.tokenId === undefined ? c.asset.slice(0, 8) : `NFT #${c.tokenId}`)}`
|
|
443
|
+
|
|
444
|
+
function summarize(decoded: DecodedCall, changes: readonly BalanceChange[], from: string, approvals: readonly ApprovalChange[]): string {
|
|
445
|
+
const mine = changes.filter((c) => c.address.toLowerCase() === from.toLowerCase())
|
|
446
|
+
const outs = mine.filter((c) => c.delta.startsWith('-')).map((c) => label({ ...c, formatted: c.formatted.slice(1) }))
|
|
447
|
+
const ins = mine.filter((c) => !c.delta.startsWith('-')).map(label)
|
|
448
|
+
if (decoded.type === 'swap' && outs.length && ins.length) return `Swap ${outs.join(' + ')} for ${ins.join(' + ')}${decoded.protocol ? ` on ${decoded.protocol}` : ''}`
|
|
449
|
+
if (decoded.type === 'transfer' && outs.length && !ins.length) return `Send ${outs.join(' + ')}`
|
|
450
|
+
if (decoded.type === 'approve' && approvals.length) {
|
|
451
|
+
const a = approvals[0] as ApprovalChange
|
|
452
|
+
const what = a.revoked ? 'Revoke' : 'Approve'
|
|
453
|
+
return `${what} ${a.symbol ?? a.token.slice(0, 8)}${a.unlimited ? ' (unlimited)' : ''} ${a.revoked ? 'from' : 'to'} ${a.spenderName ?? a.spender}`
|
|
454
|
+
}
|
|
455
|
+
if (outs.length || ins.length) return `${decoded.summary}: ${[...outs.map((o) => `-${o}`), ...ins.map((i) => `+${i}`)].join(', ')}`
|
|
456
|
+
return decoded.summary
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function codeOf(ctx: Ctx, address: string, cache: Map<string, Promise<boolean | null>>): Promise<boolean | null> {
|
|
460
|
+
const key = address.toLowerCase()
|
|
461
|
+
let hit = cache.get(key)
|
|
462
|
+
if (!hit) {
|
|
463
|
+
const run = nodes()
|
|
464
|
+
hit = run
|
|
465
|
+
.then((r) =>
|
|
466
|
+
r(ctx.provider.rpc, 'getCode', async (node) => {
|
|
467
|
+
if (ctx.tron) {
|
|
468
|
+
if (!(node instanceof TronProvider)) throw new Error('not a Tron node')
|
|
469
|
+
const c = await node.request('wallet/getcontract', { value: `41${key.slice(2)}` })
|
|
470
|
+
return Boolean(c && (c.bytecode || c.contract_address))
|
|
471
|
+
}
|
|
472
|
+
return (await (node as unknown as { getCode(a: string): Promise<string> }).getCode(address)) !== '0x'
|
|
473
|
+
}),
|
|
474
|
+
)
|
|
475
|
+
.catch(() => null)
|
|
476
|
+
cache.set(key, hit)
|
|
477
|
+
}
|
|
478
|
+
return hit
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function buildResult(ctx: Ctx, tx: NormalizedTx, raw: RawCall, mode: SimulationResult['mode'], codes: Map<string, Promise<boolean | null>>, fallbackMoves: Move[] = []): Promise<SimulationResult> {
|
|
482
|
+
const moves = [...parseTransfers(raw.logs), ...fallbackMoves]
|
|
483
|
+
const approvals = parseApprovals(raw.logs).filter((a) => ctx.subjects.has(a.owner))
|
|
484
|
+
// 失败的交易没有效果
|
|
485
|
+
const effective = raw.success ? moves : []
|
|
486
|
+
const decoded = decodeCalldata(tx.data, { to: tx.to, chainId: ctx.chainId, value: tx.value })
|
|
487
|
+
await loadMeta(ctx, [...effective.filter((m) => ctx.subjects.has(m.fromL) || ctx.subjects.has(m.toL)).map((m) => m.asset), ...(raw.success ? approvals.map((a) => a.token) : [])])
|
|
488
|
+
const balanceChanges = changesOf(ctx, effective)
|
|
489
|
+
const known = knownSpenders(ctx.chainId)
|
|
490
|
+
const approvalChanges: ApprovalChange[] = (raw.success ? approvals : []).map((a) => ({
|
|
491
|
+
...a,
|
|
492
|
+
owner: outAddr(ctx, a.owner),
|
|
493
|
+
token: outAddr(ctx, a.token),
|
|
494
|
+
spender: outAddr(ctx, a.spender),
|
|
495
|
+
symbol: ctx.meta.get(a.token)?.symbol ?? null,
|
|
496
|
+
...(known.find((s) => toEvmAddress(s.address).toLowerCase() === a.spender)?.name ? { spenderName: known.find((s) => toEvmAddress(s.address).toLowerCase() === a.spender)?.name } : {}),
|
|
497
|
+
}))
|
|
498
|
+
|
|
499
|
+
const warnings: SimulationWarning[] = []
|
|
500
|
+
if (!raw.success) warnings.push({ severity: 'danger', code: 'reverted', message: raw.error ?? 'execution reverted' })
|
|
501
|
+
if (mode === 'eth_call') warnings.push({ severity: 'info', code: 'no-logs', message: 'The node does not support eth_simulateV1: balance changes are estimated from calldata only' })
|
|
502
|
+
if (decoded.type === 'unknown' && tx.data !== '0x') warnings.push({ severity: 'info', code: 'unknown-method', message: decoded.summary })
|
|
503
|
+
for (const a of approvalChanges) {
|
|
504
|
+
if (a.revoked) continue
|
|
505
|
+
const common = { address: a.spender }
|
|
506
|
+
if (a.unlimited) warnings.push({ severity: 'warn', code: a.type === 'nft' ? 'unlimited-nft-approval' : 'unlimited-approval', message: a.type === 'nft' ? `Approves ${a.spenderName ?? a.spender} to move all of your NFTs in ${a.symbol ?? a.token}` : `Unlimited ${a.symbol ?? a.token} approval to ${a.spenderName ?? a.spender}`, ...common })
|
|
507
|
+
if (!a.spenderName) {
|
|
508
|
+
const hasCode = await codeOf(ctx, a.spender, codes)
|
|
509
|
+
if (hasCode === false) warnings.push({ severity: 'danger', code: 'approve-to-eoa', message: `Approval to ${a.spender}, which has no contract code (an ordinary wallet)`, ...common })
|
|
510
|
+
else warnings.push({ severity: 'warn', code: 'unknown-spender', message: `${a.spender} is not a known router / spender on this chain`, ...common })
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (tx.to && tx.data !== '0x') {
|
|
514
|
+
if ((await codeOf(ctx, tx.to, codes)) === false) warnings.push({ severity: 'danger', code: 'no-code', message: `${outAddr(ctx, tx.to)} has no contract code: the call does nothing${tx.value > 0n ? ' and the value is just sent to it' : ''}` })
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const decodedOut = { type: decoded.type, ...(decoded.protocol ? { protocol: decoded.protocol } : {}), ...(decoded.method ? { method: decoded.method } : {}), summary: summarize(decoded, balanceChanges, outAddr(ctx, tx.from), approvalChanges), ...(decoded.args ? { args: decoded.args } : {}), ...(decoded.calls ? { calls: decoded.calls } : {}) }
|
|
518
|
+
return {
|
|
519
|
+
success: raw.success,
|
|
520
|
+
...(raw.error ? { error: raw.error } : {}),
|
|
521
|
+
...(!raw.success && raw.returnData !== '0x' ? { revertData: raw.returnData } : {}),
|
|
522
|
+
gasUsed: raw.gasUsed.toString(),
|
|
523
|
+
balanceChanges,
|
|
524
|
+
approvalChanges,
|
|
525
|
+
decoded: decodedOut,
|
|
526
|
+
warnings,
|
|
527
|
+
transfers: effective.map(({ fromL, toL, value, ...t }) => ({ ...t, asset: outAddr(ctx, t.asset), from: outAddr(ctx, fromL), to: outAddr(ctx, toL) })),
|
|
528
|
+
logs: raw.logs,
|
|
529
|
+
mode,
|
|
530
|
+
...(raw.success && raw.returnData !== '0x' ? { returnData: raw.returnData } : {}),
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** eth_call 模式:按 calldata 估一下主币和 ERC20 转出(没有日志,只是估计) */
|
|
535
|
+
function estimateMoves(tx: NormalizedTx, decoded: DecodedCall): Move[] {
|
|
536
|
+
const moves: Move[] = []
|
|
537
|
+
const from = tx.from.toLowerCase()
|
|
538
|
+
const push = (asset: string, to: string, value: bigint, standard: Move['standard']) => moves.push({ asset, from, to, amount: value.toString(), standard, fromL: from, toL: to.toLowerCase(), value })
|
|
539
|
+
if (tx.value > 0n && tx.to) push('native', tx.to, tx.value, 'native')
|
|
540
|
+
if (decoded.method === 'transfer' && tx.to && decoded.args) push(tx.to.toLowerCase(), String(decoded.args.to), BigInt(String(decoded.args.amount)), 'erc20')
|
|
541
|
+
return moves
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function gasPrice(provider: Provider): Promise<bigint | undefined> {
|
|
545
|
+
try {
|
|
546
|
+
const run = await nodes()
|
|
547
|
+
return BigInt((await run(provider.rpc, 'gasPrice', async (node) => (node as unknown as Sender).send('eth_gasPrice', []))) as string)
|
|
548
|
+
} catch {
|
|
549
|
+
return undefined
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function enrich(ctx: Ctx, results: SimulationResult[], from: string, options: SimulateOptions): Promise<void> {
|
|
554
|
+
const all = results.flatMap((r) => r.balanceChanges)
|
|
555
|
+
if (options.usd && all.length) {
|
|
556
|
+
try {
|
|
557
|
+
const { tokenPrices } = await import('./price.js')
|
|
558
|
+
const assets = [...new Set(all.filter((c) => c.standard === 'native' || c.standard === 'erc20').map((c) => (c.standard === 'native' ? NATIVE_TOKEN : c.asset)))]
|
|
559
|
+
const prices = await tokenPrices(ctx.provider, assets, options.usd === true ? {} : { sources: options.usd })
|
|
560
|
+
for (const c of all) {
|
|
561
|
+
const p = prices.find((x) => x.token.toLowerCase() === (c.standard === 'native' ? NATIVE_TOKEN : c.asset).toLowerCase())
|
|
562
|
+
c.usd = c.standard === 'erc721' || c.standard === 'erc1155' ? null : p?.price == null ? null : Number(c.formatted) * p.price
|
|
563
|
+
}
|
|
564
|
+
} catch {
|
|
565
|
+
for (const c of all) c.usd = null
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (options.risk && !ctx.tron) {
|
|
569
|
+
const incoming = [...new Set(all.filter((c) => c.standard === 'erc20' && !c.delta.startsWith('-') && c.address.toLowerCase() === from.toLowerCase()).map((c) => c.asset))]
|
|
570
|
+
if (incoming.length) {
|
|
571
|
+
try {
|
|
572
|
+
const { tokenRisks } = await import('./risk.js')
|
|
573
|
+
const risks = await tokenRisks(ctx.provider, incoming)
|
|
574
|
+
for (const r of risks) {
|
|
575
|
+
const bad = r.flags.filter((f) => f === 'honeypot' || f === 'cannot-buy' || f.startsWith('high-'))
|
|
576
|
+
if (bad.length) results[0]?.warnings.push({ severity: 'danger', code: 'token-risk', address: r.token, message: `${r.token}: ${bad.join(', ')}` })
|
|
577
|
+
else if (r.flags.includes('has-tax')) results[0]?.warnings.push({ severity: 'warn', code: 'token-risk', address: r.token, message: `${r.token}: has transfer tax (buy ${r.buyTax}, sell ${r.sellTax})` })
|
|
578
|
+
}
|
|
579
|
+
} catch {
|
|
580
|
+
// 风险检查失败不影响模拟结果
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function large(ctx: Ctx, from: string, results: SimulationResult[]): Promise<void> {
|
|
587
|
+
const native = results.flatMap((r) => r.balanceChanges).filter((c) => c.standard === 'native' && c.address.toLowerCase() === from.toLowerCase() && c.delta.startsWith('-'))
|
|
588
|
+
if (!native.length) return
|
|
589
|
+
try {
|
|
590
|
+
const out = native.reduce((s, c) => s - BigInt(c.delta), 0n)
|
|
591
|
+
const [balance] = await ctx.provider.balances(from, [NATIVE_TOKEN], { decimals: false })
|
|
592
|
+
const have = BigInt(balance?.balance ?? '0')
|
|
593
|
+
if (have > 0n && out * 10n >= have * 9n) results[0]?.warnings.push({ severity: 'warn', code: 'large-value', message: `Sends ${Math.round(Number((out * 1000n) / have) / 10)}% of the native balance` })
|
|
594
|
+
} catch {
|
|
595
|
+
// 余额查不到就不提示
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function run(provider: Provider, txs: SimulateTx[], options: SimulateOptions): Promise<{ results: SimulationResult[]; ctx: Ctx; from: string }> {
|
|
600
|
+
if (!txs.length) throw new Error('No transactions to simulate')
|
|
601
|
+
if (txs.length > 50) throw new RangeError('Too many transactions to simulate at once (max 50)')
|
|
602
|
+
const chainId = await provider.getChainId()
|
|
603
|
+
const tron = isTronChain(chainId)
|
|
604
|
+
const normalized = txs.map((t) => normalize(t, options.from, tron))
|
|
605
|
+
const from = normalized[0]?.from as string
|
|
606
|
+
const subjects = new Set([from, ...(options.watch ?? []).map((a) => (tron ? toEvmAddress(a) : a)), ...normalized.map((t) => t.from)].map((a) => a.toLowerCase()))
|
|
607
|
+
const ctx: Ctx = { provider, chainId, tron, subjects, meta: new Map() }
|
|
608
|
+
const codes = new Map<string, Promise<boolean | null>>()
|
|
609
|
+
|
|
610
|
+
let raws: RawCall[] | null = null
|
|
611
|
+
let mode: SimulationResult['mode'] = 'simulateV1'
|
|
612
|
+
if (tron) {
|
|
613
|
+
raws = await simulateTronCalls(provider, normalized)
|
|
614
|
+
mode = 'tron'
|
|
615
|
+
} else {
|
|
616
|
+
if (!options.forceCall) {
|
|
617
|
+
try {
|
|
618
|
+
raws = await simulateV1(provider, normalized, options)
|
|
619
|
+
} catch (err) {
|
|
620
|
+
const text = cleanError(err)
|
|
621
|
+
// 交易本身有问题(余额不足等):换节点结果一样,直接作为失败结果;其他(不支持、节点故障)退回 eth_call
|
|
622
|
+
if (TX_ERROR_RE.test(text) && !UNSUPPORTED_RE.test(text)) raws = normalized.map(() => ({ success: false, gasUsed: 0n, returnData: '0x', error: text, logs: [] }))
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (!raws) {
|
|
626
|
+
raws = await callEach(provider, normalized, options)
|
|
627
|
+
mode = 'eth_call'
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const results: SimulationResult[] = []
|
|
631
|
+
for (const [i, raw] of raws.entries()) {
|
|
632
|
+
const t = normalized[i] as NormalizedTx
|
|
633
|
+
// eth_call 没有日志:按 calldata 估;Tron 日志里没有 TRX 的变化,只有 call_value
|
|
634
|
+
const moves = mode === 'eth_call' ? estimateMoves(t, decodeCalldata(t.data, { to: t.to, chainId, value: t.value })) : mode === 'tron' ? estimateMoves({ ...t, data: '0x' }, { type: 'unknown', summary: '' }) : []
|
|
635
|
+
const result = await buildResult(ctx, t, raw, mode, codes, moves)
|
|
636
|
+
if (mode === 'eth_call' && i > 0) result.warnings.push({ severity: 'info', code: 'no-state', message: 'Without eth_simulateV1 each transaction is simulated against the current state, not after the previous ones' })
|
|
637
|
+
if (mode === 'tron' && i > 0) result.warnings.push({ severity: 'info', code: 'no-state', message: 'Tron simulates each call against the current state, not after the previous ones' })
|
|
638
|
+
results.push(result)
|
|
639
|
+
}
|
|
640
|
+
await enrich(ctx, results, from, options)
|
|
641
|
+
await large(ctx, from, results)
|
|
642
|
+
if (options.fee !== false && !tron && results.some((r) => r.gasUsed !== '0')) {
|
|
643
|
+
const price = await gasPrice(provider)
|
|
644
|
+
if (price !== undefined) for (const r of results) r.fee = (price * BigInt(r.gasUsed)).toString()
|
|
645
|
+
}
|
|
646
|
+
return { results, ctx, from }
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* 模拟一笔交易(不签名、不发送)。可以直接传 `buildSwap` / `buildRevokes` / 流动性构建里的交易。
|
|
651
|
+
*
|
|
652
|
+
* ```ts
|
|
653
|
+
* const plan = await buildSwap(provider, user, { tokenIn, tokenOut, amountInFormatted: '1' })
|
|
654
|
+
* const sim = await simulate(provider, plan.transactions[1], { from: user }) // 兑换这一笔(授权在前,没发出去时用 simulateBatch)
|
|
655
|
+
* if (!sim.success) throw new Error(sim.error)
|
|
656
|
+
* sim.balanceChanges // [{ asset: 'native', delta: '-1000…', formatted: '-1', … }, { asset: USDC, formatted: '2300.5', … }]
|
|
657
|
+
* sim.warnings // [{ severity: 'warn', code: 'unknown-spender', … }]
|
|
658
|
+
* ```
|
|
659
|
+
*/
|
|
660
|
+
export async function simulate(provider: Provider, tx: SimulateTx, options: SimulateOptions = {}): Promise<SimulationResult> {
|
|
661
|
+
const { results } = await run(provider, [tx], options)
|
|
662
|
+
return results[0] as SimulationResult
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* 按顺序模拟一串依赖前一笔的交易(如先 approve 再兑换:`plan.transactions`)。eth_simulateV1 在同一个区块里依次执行,状态延续;
|
|
667
|
+
* 不支持的节点退回单笔 eth_call,后面的交易看不到前面的结果(结果里有 `no-state` 提示;Tron 同理)。
|
|
668
|
+
*
|
|
669
|
+
* ```ts
|
|
670
|
+
* const sim = await simulateBatch(provider, plan.transactions, { from: user, usd: true })
|
|
671
|
+
* sim.success; sim.balanceChanges // 汇总:整个流程你付出 / 收到什么
|
|
672
|
+
* ```
|
|
673
|
+
*/
|
|
674
|
+
export async function simulateBatch(provider: Provider, txs: readonly SimulateTx[] | { transactions: readonly SimulateTx[] }, options: SimulateOptions = {}): Promise<BatchSimulationResult> {
|
|
675
|
+
const list = 'transactions' in txs ? txs.transactions : txs
|
|
676
|
+
const { results, ctx } = await run(provider, [...list], options)
|
|
677
|
+
const failedIndex = results.findIndex((r) => !r.success)
|
|
678
|
+
const sum = new Map<string, BalanceChange & { raw: bigint }>()
|
|
679
|
+
for (const r of results) {
|
|
680
|
+
for (const c of r.balanceChanges) {
|
|
681
|
+
const key = `${c.address}|${c.asset}|${c.tokenId ?? ''}`
|
|
682
|
+
const entry = sum.get(key) ?? { ...c, usd: undefined, raw: 0n }
|
|
683
|
+
entry.raw += BigInt(c.delta)
|
|
684
|
+
if (c.usd != null) entry.usd = (entry.usd ?? 0) + c.usd
|
|
685
|
+
sum.set(key, entry)
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
const latest = new Map<string, ApprovalChange>()
|
|
689
|
+
for (const r of results) for (const a of r.approvalChanges) latest.set(`${a.type}|${a.owner}|${a.token}|${a.spender}`, a)
|
|
690
|
+
const seen = new Set<string>()
|
|
691
|
+
const warnings = results.flatMap((r) => r.warnings).filter((w) => !seen.has(`${w.code}|${w.message}`) && seen.add(`${w.code}|${w.message}`))
|
|
692
|
+
const gas = results.reduce((s, r) => s + BigInt(r.gasUsed), 0n)
|
|
693
|
+
const fees = results.map((r) => r.fee)
|
|
694
|
+
return {
|
|
695
|
+
success: failedIndex < 0,
|
|
696
|
+
results,
|
|
697
|
+
...(failedIndex < 0 ? {} : { failedIndex }),
|
|
698
|
+
gasUsed: gas.toString(),
|
|
699
|
+
...(fees.every((f) => f !== undefined) ? { fee: fees.reduce((s, f) => s + BigInt(f as string), 0n).toString() } : {}),
|
|
700
|
+
balanceChanges: [...sum.values()].filter((e) => e.raw !== 0n).map(({ raw, ...e }) => ({ ...e, delta: raw.toString(), formatted: formatUnits(raw, e.decimals) })),
|
|
701
|
+
approvalChanges: [...latest.values()],
|
|
702
|
+
warnings,
|
|
703
|
+
mode: ctx.tron ? 'tron' : (results[0] as SimulationResult).mode,
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* 模拟通过才发送的一行守卫:`simulate` 失败或有 danger 级别的提示时抛错,否则返回模拟结果,由调用方继续发送。
|
|
709
|
+
*
|
|
710
|
+
* ```ts
|
|
711
|
+
* await assertSimulation(simulate(provider, tx, { from }))
|
|
712
|
+
* await batchCall(provider, key, [{ to: tx.to, data: tx.data, value: tx.value }])
|
|
713
|
+
* ```
|
|
714
|
+
*/
|
|
715
|
+
export async function assertSimulation<T extends Pick<SimulationResult, 'success' | 'error' | 'warnings'> | Pick<BatchSimulationResult, 'success' | 'warnings'> & { results: Array<Pick<SimulationResult, 'error'>> }>(
|
|
716
|
+
simulation: Promise<T> | T,
|
|
717
|
+
options: { allowDanger?: boolean } = {},
|
|
718
|
+
): Promise<T> {
|
|
719
|
+
const sim = await simulation
|
|
720
|
+
if (!sim.success) {
|
|
721
|
+
const error = 'error' in sim ? sim.error : (sim as { results: Array<{ error?: string }> }).results.find((r) => r.error)?.error
|
|
722
|
+
throw new Error(`Simulation failed: ${error ?? 'execution reverted'}`)
|
|
723
|
+
}
|
|
724
|
+
const danger = sim.warnings.filter((w) => w.severity === 'danger')
|
|
725
|
+
if (danger.length && !options.allowDanger) throw new Error(`Simulation warning: ${danger.map((w) => w.message).join('; ')}`)
|
|
726
|
+
return sim
|
|
727
|
+
}
|