@w3lib/evm-batch-call 0.6.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.
Files changed (248) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +367 -3
  3. package/contracts/TokenProbe.sol +103 -0
  4. package/dist/aggregate.d.ts.map +1 -1
  5. package/dist/aggregate.js +114 -3
  6. package/dist/aggregate.js.map +1 -1
  7. package/dist/approvals.d.ts +169 -0
  8. package/dist/approvals.d.ts.map +1 -0
  9. package/dist/approvals.js +310 -0
  10. package/dist/approvals.js.map +1 -0
  11. package/dist/chains.d.ts.map +1 -1
  12. package/dist/chains.js +4 -0
  13. package/dist/chains.js.map +1 -1
  14. package/dist/dividends.d.ts +135 -0
  15. package/dist/dividends.d.ts.map +1 -0
  16. package/dist/dividends.js +253 -0
  17. package/dist/dividends.js.map +1 -0
  18. package/dist/fees.d.ts +97 -0
  19. package/dist/fees.d.ts.map +1 -0
  20. package/dist/fees.js +115 -0
  21. package/dist/fees.js.map +1 -0
  22. package/dist/history.d.ts +154 -0
  23. package/dist/history.d.ts.map +1 -0
  24. package/dist/history.js +247 -0
  25. package/dist/history.js.map +1 -0
  26. package/dist/historySources.d.ts +32 -0
  27. package/dist/historySources.d.ts.map +1 -0
  28. package/dist/historySources.js +319 -0
  29. package/dist/historySources.js.map +1 -0
  30. package/dist/http.d.ts +10 -0
  31. package/dist/http.d.ts.map +1 -0
  32. package/dist/http.js +30 -0
  33. package/dist/http.js.map +1 -0
  34. package/dist/liquidity/v3.d.ts +2 -0
  35. package/dist/liquidity/v3.d.ts.map +1 -1
  36. package/dist/liquidity/v3.js +1 -1
  37. package/dist/liquidity/v3.js.map +1 -1
  38. package/dist/liquidity/v4.d.ts +21 -0
  39. package/dist/liquidity/v4.d.ts.map +1 -1
  40. package/dist/liquidity/v4.js +4 -3
  41. package/dist/liquidity/v4.js.map +1 -1
  42. package/dist/lp.d.ts +10 -1
  43. package/dist/lp.d.ts.map +1 -1
  44. package/dist/lp.js +19 -2
  45. package/dist/lp.js.map +1 -1
  46. package/dist/lpV4.d.ts +4 -0
  47. package/dist/lpV4.d.ts.map +1 -1
  48. package/dist/lpV4.js +27 -3
  49. package/dist/lpV4.js.map +1 -1
  50. package/dist/multicall.d.ts.map +1 -1
  51. package/dist/multicall.js +3 -0
  52. package/dist/multicall.js.map +1 -1
  53. package/dist/names.d.ts +55 -0
  54. package/dist/names.d.ts.map +1 -0
  55. package/dist/names.js +93 -0
  56. package/dist/names.js.map +1 -0
  57. package/dist/owner.d.ts +4 -26
  58. package/dist/owner.d.ts.map +1 -1
  59. package/dist/owner.js +7 -112
  60. package/dist/owner.js.map +1 -1
  61. package/dist/permit.d.ts +137 -0
  62. package/dist/permit.d.ts.map +1 -0
  63. package/dist/permit.js +355 -0
  64. package/dist/permit.js.map +1 -0
  65. package/dist/portfolio.d.ts +56 -0
  66. package/dist/portfolio.d.ts.map +1 -0
  67. package/dist/portfolio.js +54 -0
  68. package/dist/portfolio.js.map +1 -0
  69. package/dist/price.d.ts +95 -0
  70. package/dist/price.d.ts.map +1 -0
  71. package/dist/price.js +329 -0
  72. package/dist/price.js.map +1 -0
  73. package/dist/rewards/farms.d.ts +26 -0
  74. package/dist/rewards/farms.d.ts.map +1 -0
  75. package/dist/rewards/farms.js +210 -0
  76. package/dist/rewards/farms.js.map +1 -0
  77. package/dist/rewards/index.d.ts +42 -0
  78. package/dist/rewards/index.d.ts.map +1 -0
  79. package/dist/rewards/index.js +382 -0
  80. package/dist/rewards/index.js.map +1 -0
  81. package/dist/rewards/types.d.ts +170 -0
  82. package/dist/rewards/types.d.ts.map +1 -0
  83. package/dist/rewards/types.js +2 -0
  84. package/dist/rewards/types.js.map +1 -0
  85. package/dist/risk.d.ts +42 -0
  86. package/dist/risk.d.ts.map +1 -0
  87. package/dist/risk.js +134 -0
  88. package/dist/risk.js.map +1 -0
  89. package/dist/rpcNodes.d.ts.map +1 -1
  90. package/dist/rpcNodes.js +18 -0
  91. package/dist/rpcNodes.js.map +1 -1
  92. package/dist/simulate.d.ts +186 -0
  93. package/dist/simulate.d.ts.map +1 -0
  94. package/dist/simulate.js +559 -0
  95. package/dist/simulate.js.map +1 -0
  96. package/dist/simulateDecode.d.ts +33 -0
  97. package/dist/simulateDecode.d.ts.map +1 -0
  98. package/dist/simulateDecode.js +321 -0
  99. package/dist/simulateDecode.js.map +1 -0
  100. package/dist/smartAccount/account.d.ts +31 -0
  101. package/dist/smartAccount/account.d.ts.map +1 -0
  102. package/dist/smartAccount/account.js +33 -0
  103. package/dist/smartAccount/account.js.map +1 -0
  104. package/dist/smartAccount/clients.d.ts +77 -0
  105. package/dist/smartAccount/clients.d.ts.map +1 -0
  106. package/dist/smartAccount/clients.js +122 -0
  107. package/dist/smartAccount/clients.js.map +1 -0
  108. package/dist/smartAccount/eip5792.d.ts +77 -0
  109. package/dist/smartAccount/eip5792.d.ts.map +1 -0
  110. package/dist/smartAccount/eip5792.js +152 -0
  111. package/dist/smartAccount/eip5792.js.map +1 -0
  112. package/dist/smartAccount/send.d.ts +40 -0
  113. package/dist/smartAccount/send.d.ts.map +1 -0
  114. package/dist/smartAccount/send.js +271 -0
  115. package/dist/smartAccount/send.js.map +1 -0
  116. package/dist/smartAccount/userOp.d.ts +91 -0
  117. package/dist/smartAccount/userOp.d.ts.map +1 -0
  118. package/dist/smartAccount/userOp.js +124 -0
  119. package/dist/smartAccount/userOp.js.map +1 -0
  120. package/dist/subpaths/approvals.d.ts +26 -0
  121. package/dist/subpaths/approvals.d.ts.map +1 -0
  122. package/dist/subpaths/approvals.js +27 -0
  123. package/dist/subpaths/approvals.js.map +1 -0
  124. package/dist/subpaths/dividends.d.ts +28 -0
  125. package/dist/subpaths/dividends.d.ts.map +1 -0
  126. package/dist/subpaths/dividends.js +29 -0
  127. package/dist/subpaths/dividends.js.map +1 -0
  128. package/dist/subpaths/fees.d.ts +23 -0
  129. package/dist/subpaths/fees.d.ts.map +1 -0
  130. package/dist/subpaths/fees.js +25 -0
  131. package/dist/subpaths/fees.js.map +1 -0
  132. package/dist/subpaths/history.d.ts +22 -0
  133. package/dist/subpaths/history.d.ts.map +1 -0
  134. package/dist/subpaths/history.js +23 -0
  135. package/dist/subpaths/history.js.map +1 -0
  136. package/dist/subpaths/names.d.ts +35 -0
  137. package/dist/subpaths/names.d.ts.map +1 -0
  138. package/dist/subpaths/names.js +39 -0
  139. package/dist/subpaths/names.js.map +1 -0
  140. package/dist/subpaths/permit.d.ts +14 -0
  141. package/dist/subpaths/permit.d.ts.map +1 -0
  142. package/dist/subpaths/permit.js +14 -0
  143. package/dist/subpaths/permit.js.map +1 -0
  144. package/dist/subpaths/price.d.ts +38 -0
  145. package/dist/subpaths/price.d.ts.map +1 -0
  146. package/dist/subpaths/price.js +40 -0
  147. package/dist/subpaths/price.js.map +1 -0
  148. package/dist/subpaths/rewards.d.ts +27 -0
  149. package/dist/subpaths/rewards.d.ts.map +1 -0
  150. package/dist/subpaths/rewards.js +25 -0
  151. package/dist/subpaths/rewards.js.map +1 -0
  152. package/dist/subpaths/risk.d.ts +19 -0
  153. package/dist/subpaths/risk.d.ts.map +1 -0
  154. package/dist/subpaths/risk.js +20 -0
  155. package/dist/subpaths/risk.js.map +1 -0
  156. package/dist/subpaths/simulate.d.ts +25 -0
  157. package/dist/subpaths/simulate.d.ts.map +1 -0
  158. package/dist/subpaths/simulate.js +28 -0
  159. package/dist/subpaths/simulate.js.map +1 -0
  160. package/dist/subpaths/smart-account.d.ts +24 -0
  161. package/dist/subpaths/smart-account.d.ts.map +1 -0
  162. package/dist/subpaths/smart-account.js +24 -0
  163. package/dist/subpaths/smart-account.js.map +1 -0
  164. package/dist/subpaths/swap.d.ts +19 -0
  165. package/dist/subpaths/swap.d.ts.map +1 -0
  166. package/dist/subpaths/swap.js +20 -0
  167. package/dist/subpaths/swap.js.map +1 -0
  168. package/dist/swap.d.ts +168 -0
  169. package/dist/swap.d.ts.map +1 -0
  170. package/dist/swap.js +387 -0
  171. package/dist/swap.js.map +1 -0
  172. package/dist/tokenProbe.d.ts +3 -0
  173. package/dist/tokenProbe.d.ts.map +1 -0
  174. package/dist/tokenProbe.js +4 -0
  175. package/dist/tokenProbe.js.map +1 -0
  176. package/dist/write/batch.d.ts +34 -2
  177. package/dist/write/batch.d.ts.map +1 -1
  178. package/dist/write/batch.js +22 -3
  179. package/dist/write/batch.js.map +1 -1
  180. package/dist/write/collect.d.ts +9 -0
  181. package/dist/write/collect.d.ts.map +1 -1
  182. package/dist/write/collect.js +41 -1
  183. package/dist/write/collect.js.map +1 -1
  184. package/dist/write/send.d.ts +4 -0
  185. package/dist/write/send.d.ts.map +1 -1
  186. package/dist/write/send.js.map +1 -1
  187. package/dist/write/signer.d.ts +5 -1
  188. package/dist/write/signer.d.ts.map +1 -1
  189. package/dist/write/signer.js.map +1 -1
  190. package/dist/write/transfer.d.ts +5 -3
  191. package/dist/write/transfer.d.ts.map +1 -1
  192. package/dist/write/transfer.js +12 -6
  193. package/dist/write/transfer.js.map +1 -1
  194. package/dist/write/tron.d.ts +25 -0
  195. package/dist/write/tron.d.ts.map +1 -1
  196. package/dist/write/tron.js +26 -0
  197. package/dist/write/tron.js.map +1 -1
  198. package/package.json +73 -16
  199. package/src/aggregate.ts +110 -3
  200. package/src/approvals.ts +462 -0
  201. package/src/chains.ts +4 -0
  202. package/src/dividends.ts +360 -0
  203. package/src/fees.ts +201 -0
  204. package/src/history.ts +367 -0
  205. package/src/historySources.ts +360 -0
  206. package/src/http.ts +34 -0
  207. package/src/liquidity/v3.ts +1 -1
  208. package/src/liquidity/v4.ts +4 -3
  209. package/src/lp.ts +21 -2
  210. package/src/lpV4.ts +32 -2
  211. package/src/multicall.ts +3 -0
  212. package/src/names.ts +141 -0
  213. package/src/owner.ts +7 -138
  214. package/src/permit.ts +457 -0
  215. package/src/portfolio.ts +98 -0
  216. package/src/price.ts +415 -0
  217. package/src/rewards/farms.ts +229 -0
  218. package/src/rewards/index.ts +431 -0
  219. package/src/rewards/types.ts +179 -0
  220. package/src/risk.ts +166 -0
  221. package/src/rpcNodes.ts +18 -0
  222. package/src/simulate.ts +727 -0
  223. package/src/simulateDecode.ts +352 -0
  224. package/src/smartAccount/account.ts +63 -0
  225. package/src/smartAccount/clients.ts +180 -0
  226. package/src/smartAccount/eip5792.ts +187 -0
  227. package/src/smartAccount/send.ts +341 -0
  228. package/src/smartAccount/userOp.ts +180 -0
  229. package/src/subpaths/approvals.ts +48 -0
  230. package/src/subpaths/dividends.ts +47 -0
  231. package/src/subpaths/fees.ts +43 -0
  232. package/src/subpaths/history.ts +41 -0
  233. package/src/subpaths/names.ts +43 -0
  234. package/src/subpaths/permit.ts +39 -0
  235. package/src/subpaths/price.ts +60 -0
  236. package/src/subpaths/rewards.ts +46 -0
  237. package/src/subpaths/risk.ts +23 -0
  238. package/src/subpaths/simulate.ts +51 -0
  239. package/src/subpaths/smart-account.ts +40 -0
  240. package/src/subpaths/swap.ts +36 -0
  241. package/src/swap.ts +534 -0
  242. package/src/tokenProbe.ts +5 -0
  243. package/src/write/batch.ts +44 -5
  244. package/src/write/collect.ts +60 -1
  245. package/src/write/send.ts +4 -0
  246. package/src/write/signer.ts +5 -1
  247. package/src/write/transfer.ts +17 -10
  248. package/src/write/tron.ts +43 -0
@@ -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
+ }