web3-tools-mcp 1.3.3 → 1.4.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/README.md +51 -4
- package/dist/anvil.d.ts +34 -0
- package/dist/anvil.d.ts.map +1 -0
- package/dist/anvil.js +254 -0
- package/dist/anvil.js.map +1 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +13 -2
- package/dist/client.js.map +1 -1
- package/dist/index.js +25 -5
- package/dist/index.js.map +1 -1
- package/dist/preview.d.ts +55 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +233 -0
- package/dist/preview.js.map +1 -0
- package/dist/tools/advanced.d.ts +67 -9
- package/dist/tools/advanced.d.ts.map +1 -1
- package/dist/tools/advanced.js +206 -67
- package/dist/tools/advanced.js.map +1 -1
- package/dist/tools/balance.d.ts +5 -5
- package/dist/tools/contract-info.d.ts +9 -9
- package/dist/tools/contract.d.ts +8 -8
- package/dist/tools/contract.d.ts.map +1 -1
- package/dist/tools/contract.js +3 -0
- package/dist/tools/contract.js.map +1 -1
- package/dist/tools/ens.d.ts +15 -15
- package/dist/tools/gas.d.ts +18 -18
- package/dist/tools/logs.d.ts +3 -3
- package/dist/tools/transactions.d.ts +9 -9
- package/dist/tools/transactions.d.ts.map +1 -1
- package/dist/tools/transactions.js +92 -113
- package/dist/tools/transactions.js.map +1 -1
- package/dist/utils.d.ts +13 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +76 -0
- package/dist/utils.js.map +1 -1
- package/dist/wallet-client.d.ts +83 -0
- package/dist/wallet-client.d.ts.map +1 -0
- package/dist/wallet-client.js +357 -0
- package/dist/wallet-client.js.map +1 -0
- package/package.json +9 -8
- package/src/anvil.ts +323 -0
- package/src/client.ts +16 -2
- package/src/index.ts +26 -5
- package/src/preview.ts +320 -0
- package/src/tools/advanced.ts +239 -70
- package/src/tools/contract.ts +3 -0
- package/src/tools/transactions.ts +98 -125
- package/src/utils.ts +95 -0
- package/src/wallet-client.ts +380 -0
- package/dist/wallet-server.d.ts +0 -35
- package/dist/wallet-server.d.ts.map +0 -1
- package/dist/wallet-server.js +0 -232
- package/dist/wallet-server.js.map +0 -1
- package/public/wallet-app.js +0 -677
- package/public/wallet.html +0 -723
- package/src/wallet-server.ts +0 -283
package/src/preview.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Abi,
|
|
3
|
+
type AbiFunction,
|
|
4
|
+
type Address,
|
|
5
|
+
decodeFunctionData,
|
|
6
|
+
formatEther,
|
|
7
|
+
formatUnits,
|
|
8
|
+
parseAbiItem,
|
|
9
|
+
toFunctionSelector
|
|
10
|
+
} from 'viem'
|
|
11
|
+
import { simulateBlocks } from 'viem/actions'
|
|
12
|
+
import { whatsabi } from '@shazow/whatsabi'
|
|
13
|
+
import { getClientManager } from './client.js'
|
|
14
|
+
import type { ChainName } from './types.js'
|
|
15
|
+
|
|
16
|
+
export interface PreviewField {
|
|
17
|
+
name: string
|
|
18
|
+
type: string
|
|
19
|
+
value: string
|
|
20
|
+
warning?: string
|
|
21
|
+
/** Set when the value is an address, so the UI can link and label it. */
|
|
22
|
+
address?: string
|
|
23
|
+
label?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AssetChange {
|
|
27
|
+
token: string
|
|
28
|
+
symbol?: string
|
|
29
|
+
decimals?: number
|
|
30
|
+
from: string
|
|
31
|
+
to: string
|
|
32
|
+
amount: string
|
|
33
|
+
humanAmount?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TxPreview {
|
|
37
|
+
chain: string
|
|
38
|
+
to: string
|
|
39
|
+
/** Token symbol or verified contract name for `to`, when we can resolve one. */
|
|
40
|
+
toLabel?: string
|
|
41
|
+
/** Block explorer base URL, for linking addresses and tokens. */
|
|
42
|
+
explorer?: string
|
|
43
|
+
value?: string
|
|
44
|
+
valueFormatted?: string
|
|
45
|
+
decoded?: {
|
|
46
|
+
functionName: string
|
|
47
|
+
signature: string
|
|
48
|
+
fields: PreviewField[]
|
|
49
|
+
/** 'verified' = ABI from Sourcify/Etherscan, 'guessed' = selector lookup on bytecode */
|
|
50
|
+
source: 'verified' | 'guessed'
|
|
51
|
+
proxy?: string
|
|
52
|
+
}
|
|
53
|
+
simulation?: {
|
|
54
|
+
success: boolean
|
|
55
|
+
gasEstimate?: string
|
|
56
|
+
error?: string
|
|
57
|
+
assetChanges: AssetChange[]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RawTx {
|
|
62
|
+
to: string
|
|
63
|
+
data?: string
|
|
64
|
+
value?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ERC20_META_ABI = [
|
|
68
|
+
parseAbiItem('function decimals() view returns (uint8)'),
|
|
69
|
+
parseAbiItem('function symbol() view returns (string)')
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
|
|
73
|
+
const MAX_UINT256 = (1n << 256n) - 1n
|
|
74
|
+
const UNLIMITED_THRESHOLD = MAX_UINT256 / 2n
|
|
75
|
+
|
|
76
|
+
// Selectors whose amount argument is denominated in the token at tx.to.
|
|
77
|
+
const ERC20_AMOUNT_ARGS: Record<string, { arg: string; approval?: boolean }> = {
|
|
78
|
+
[toFunctionSelector('function approve(address,uint256)')]: { arg: 'amount', approval: true },
|
|
79
|
+
[toFunctionSelector('function transfer(address,uint256)')]: { arg: 'amount' },
|
|
80
|
+
[toFunctionSelector('function transferFrom(address,address,uint256)')]: { arg: 'amount' }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const abiCache = new Map<string, { abi: Abi; source: 'verified' | 'guessed'; proxy?: string; name?: string }>()
|
|
84
|
+
const labelCache = new Map<string, string | undefined>()
|
|
85
|
+
const metaCache = new Map<string, { symbol?: string; decimals?: number }>()
|
|
86
|
+
|
|
87
|
+
async function loadAbi(chain: ChainName, address: string) {
|
|
88
|
+
const key = `${chain}:${address.toLowerCase()}`
|
|
89
|
+
const cached = abiCache.get(key)
|
|
90
|
+
if (cached) return cached
|
|
91
|
+
|
|
92
|
+
const clientManager = getClientManager()
|
|
93
|
+
const client = clientManager.getClient(chain)
|
|
94
|
+
const etherscanApiKey = clientManager.getConfig().etherscanApiKey
|
|
95
|
+
|
|
96
|
+
const loaders: whatsabi.loaders.ABILoader[] = [new whatsabi.loaders.SourcifyABILoader({ chainId: clientManager.getChainId(chain) })]
|
|
97
|
+
if (etherscanApiKey) {
|
|
98
|
+
loaders.push(new whatsabi.loaders.EtherscanV2ABILoader({ apiKey: etherscanApiKey, chainId: clientManager.getChainId(chain) }))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result = await whatsabi.autoload(address as Address, {
|
|
102
|
+
provider: client,
|
|
103
|
+
abiLoader: new whatsabi.loaders.MultiABILoader(loaders),
|
|
104
|
+
signatureLookup: new whatsabi.loaders.OpenChainSignatureLookup(),
|
|
105
|
+
followProxies: true
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// Bytecode-guessed ABIs carry no argument names; verified ones do.
|
|
109
|
+
const source = result.abi.some((item) => item.type === 'function' && item.inputs?.some((i) => i.name)) ? 'verified' : 'guessed'
|
|
110
|
+
const loaded = {
|
|
111
|
+
abi: result.abi as Abi,
|
|
112
|
+
source: source as 'verified' | 'guessed',
|
|
113
|
+
proxy: result.address !== address ? result.address : undefined,
|
|
114
|
+
name: result.contractResult?.name ?? undefined
|
|
115
|
+
}
|
|
116
|
+
abiCache.set(key, loaded)
|
|
117
|
+
return loaded
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function tokenMeta(chain: ChainName, token: string) {
|
|
121
|
+
const key = `${chain}:${token.toLowerCase()}`
|
|
122
|
+
const cached = metaCache.get(key)
|
|
123
|
+
if (cached) return cached
|
|
124
|
+
|
|
125
|
+
const client = getClientManager().getClient(chain)
|
|
126
|
+
const [decimals, symbol] = await client.multicall({
|
|
127
|
+
contracts: [
|
|
128
|
+
{ address: token as Address, abi: ERC20_META_ABI, functionName: 'decimals' },
|
|
129
|
+
{ address: token as Address, abi: ERC20_META_ABI, functionName: 'symbol' }
|
|
130
|
+
],
|
|
131
|
+
...(chain === 'localhost' && { deployless: true })
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const meta = {
|
|
135
|
+
decimals: decimals.status === 'success' ? Number(decimals.result) : undefined,
|
|
136
|
+
symbol: symbol.status === 'success' ? (symbol.result as string) : undefined
|
|
137
|
+
}
|
|
138
|
+
metaCache.set(key, meta)
|
|
139
|
+
return meta
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Human label for an address: token symbol first (more recognisable than the contract
|
|
144
|
+
* name — "USDC" beats "FiatTokenProxy"), then the verified contract name. EOAs get none,
|
|
145
|
+
* and the bytecode check keeps us from asking explorers about plain wallets.
|
|
146
|
+
*/
|
|
147
|
+
async function addressLabel(chain: ChainName, address: string): Promise<string | undefined> {
|
|
148
|
+
const key = `${chain}:${address.toLowerCase()}`
|
|
149
|
+
if (labelCache.has(key)) return labelCache.get(key)
|
|
150
|
+
|
|
151
|
+
let label: string | undefined
|
|
152
|
+
try {
|
|
153
|
+
const code = await getClientManager().getClient(chain).getBytecode({ address: address as Address })
|
|
154
|
+
if (code && code !== '0x') {
|
|
155
|
+
label = (await tokenMeta(chain, address).catch(() => ({ symbol: undefined }))).symbol
|
|
156
|
+
if (!label) label = (await loadAbi(chain, address)).name
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// Unknown address — show it bare rather than failing the preview.
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
labelCache.set(key, label)
|
|
163
|
+
return label
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function stringify(value: unknown): string {
|
|
167
|
+
if (typeof value === 'bigint') return value.toString()
|
|
168
|
+
if (Array.isArray(value)) return `[${value.map(stringify).join(', ')}]`
|
|
169
|
+
if (value && typeof value === 'object') {
|
|
170
|
+
return `{${Object.entries(value)
|
|
171
|
+
.map(([k, v]) => `${k}: ${stringify(v)}`)
|
|
172
|
+
.join(', ')}}`
|
|
173
|
+
}
|
|
174
|
+
return String(value)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Decode calldata into labelled fields (clear signing). Token amounts on the standard
|
|
179
|
+
* ERC20 selectors are formatted with on-chain decimals and unlimited approvals flagged.
|
|
180
|
+
*/
|
|
181
|
+
async function decodeCalldata(chain: ChainName, tx: RawTx): Promise<TxPreview['decoded']> {
|
|
182
|
+
if (!tx.data || tx.data === '0x') return undefined
|
|
183
|
+
|
|
184
|
+
const { abi, source, proxy } = await loadAbi(chain, tx.to)
|
|
185
|
+
const { functionName, args } = decodeFunctionData({ abi, data: tx.data as `0x${string}` })
|
|
186
|
+
|
|
187
|
+
const abiItem = abi.find((item): item is AbiFunction => item.type === 'function' && item.name === functionName)
|
|
188
|
+
const inputs = abiItem?.inputs ?? []
|
|
189
|
+
const erc20 = ERC20_AMOUNT_ARGS[tx.data.slice(0, 10)]
|
|
190
|
+
const meta = erc20 ? await tokenMeta(chain, tx.to).catch(() => ({ symbol: undefined, decimals: undefined })) : undefined
|
|
191
|
+
|
|
192
|
+
const fields: PreviewField[] = (args ?? []).map((value, i) => {
|
|
193
|
+
const input = inputs[i]
|
|
194
|
+
const name = input?.name || `arg${i}`
|
|
195
|
+
const field: PreviewField = { name, type: input?.type ?? 'unknown', value: stringify(value) }
|
|
196
|
+
|
|
197
|
+
if (input?.type === 'address' && typeof value === 'string') field.address = value
|
|
198
|
+
|
|
199
|
+
// Amount argument is positionally last on all three ERC20 selectors.
|
|
200
|
+
if (erc20 && i === (args as readonly unknown[]).length - 1 && typeof value === 'bigint') {
|
|
201
|
+
if (meta?.decimals !== undefined) {
|
|
202
|
+
field.value = `${formatUnits(value, meta.decimals)}${meta.symbol ? ` ${meta.symbol}` : ''}`
|
|
203
|
+
}
|
|
204
|
+
if (erc20.approval && value > UNLIMITED_THRESHOLD) {
|
|
205
|
+
field.value = `Unlimited${meta?.symbol ? ` ${meta.symbol}` : ''}`
|
|
206
|
+
field.warning = 'Unlimited spending approval'
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return field
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// Label every address argument at once rather than serially per field.
|
|
213
|
+
await Promise.all(
|
|
214
|
+
fields
|
|
215
|
+
.filter((field) => field.address)
|
|
216
|
+
.map(async (field) => {
|
|
217
|
+
field.label = await addressLabel(chain, field.address as string)
|
|
218
|
+
})
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const signature = abiItem
|
|
222
|
+
? `${functionName}(${inputs.map((i) => `${i.type}${i.name ? ` ${i.name}` : ''}`).join(', ')})`
|
|
223
|
+
: functionName
|
|
224
|
+
|
|
225
|
+
return { functionName, signature, fields, source, proxy }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function topicToAddress(topic: string): string {
|
|
229
|
+
return `0x${topic.slice(-40)}`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function enrichTransfers(chain: ChainName, logs: readonly { address: string; topics: readonly string[]; data: string }[]) {
|
|
233
|
+
const transfers = logs.filter((log) => log.topics[0]?.toLowerCase() === TRANSFER_TOPIC && log.topics.length >= 3)
|
|
234
|
+
|
|
235
|
+
return Promise.all(
|
|
236
|
+
transfers.map(async (log): Promise<AssetChange> => {
|
|
237
|
+
const amount = log.data && log.data !== '0x' ? BigInt(log.data).toString() : '0'
|
|
238
|
+
const meta = await tokenMeta(chain, log.address).catch(() => ({ symbol: undefined, decimals: undefined }))
|
|
239
|
+
return {
|
|
240
|
+
token: log.address,
|
|
241
|
+
symbol: meta.symbol,
|
|
242
|
+
decimals: meta.decimals,
|
|
243
|
+
from: topicToAddress(log.topics[1]!),
|
|
244
|
+
to: topicToAddress(log.topics[2]!),
|
|
245
|
+
amount,
|
|
246
|
+
humanAmount: meta.decimals !== undefined ? formatUnits(BigInt(amount), meta.decimals) : undefined
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Simulate a transaction without broadcasting.
|
|
254
|
+
*
|
|
255
|
+
* Prefers eth_simulateV1 (viem `simulateBlocks`) for the ERC20 transfer logs it returns,
|
|
256
|
+
* falling back to eth_call + estimateGas on RPCs that don't implement it.
|
|
257
|
+
*/
|
|
258
|
+
async function simulate(chain: ChainName, tx: RawTx, from: Address): Promise<TxPreview['simulation']> {
|
|
259
|
+
const client = getClientManager().getClient(chain)
|
|
260
|
+
const call = {
|
|
261
|
+
account: from,
|
|
262
|
+
to: tx.to as Address,
|
|
263
|
+
...(tx.data && { data: tx.data as `0x${string}` }),
|
|
264
|
+
...(tx.value && BigInt(tx.value) > 0n && { value: BigInt(tx.value) })
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const blocks = await simulateBlocks(client, { blocks: [{ calls: [call] }], traceTransfers: true, validation: false })
|
|
269
|
+
const result = blocks?.[0]?.calls?.[0]
|
|
270
|
+
if (result) {
|
|
271
|
+
const error = result.error as { shortMessage?: string; message?: string } | undefined
|
|
272
|
+
return {
|
|
273
|
+
success: result.status === 'success',
|
|
274
|
+
gasEstimate: result.gasUsed?.toString(),
|
|
275
|
+
error: result.status === 'success' ? undefined : (error?.shortMessage ?? error?.message ?? 'execution reverted'),
|
|
276
|
+
assetChanges: await enrichTransfers(chain, (result.logs ?? []) as never)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
// RPC lacks eth_simulateV1 — fall through
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
await client.call(call)
|
|
285
|
+
const gas = await client.estimateGas(call)
|
|
286
|
+
return { success: true, gasEstimate: gas.toString(), assetChanges: [] }
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return {
|
|
289
|
+
success: false,
|
|
290
|
+
error: error instanceof Error ? error.message.slice(0, 500) : String(error),
|
|
291
|
+
assetChanges: []
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Build the human-readable preview shown in the wallet before signing: decoded calldata
|
|
298
|
+
* plus a simulation of the outcome. Never throws — a preview that cannot be built is
|
|
299
|
+
* reported as missing rather than blocking the transaction.
|
|
300
|
+
*/
|
|
301
|
+
export async function buildTxPreview(chain: ChainName, tx: RawTx, from?: string): Promise<TxPreview> {
|
|
302
|
+
const value = tx.value ? BigInt(tx.value).toString() : undefined
|
|
303
|
+
|
|
304
|
+
const [decoded, simulation, toLabel] = await Promise.all([
|
|
305
|
+
decodeCalldata(chain, tx).catch(() => undefined),
|
|
306
|
+
from ? simulate(chain, tx, from as Address).catch(() => undefined) : Promise.resolve(undefined),
|
|
307
|
+
addressLabel(chain, tx.to).catch(() => undefined)
|
|
308
|
+
])
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
chain,
|
|
312
|
+
to: tx.to,
|
|
313
|
+
toLabel,
|
|
314
|
+
explorer: `https://${getClientManager().getEtherscanDomain(chain)}`,
|
|
315
|
+
value,
|
|
316
|
+
valueFormatted: value && value !== '0' ? formatEther(BigInt(value)) : undefined,
|
|
317
|
+
decoded,
|
|
318
|
+
simulation
|
|
319
|
+
}
|
|
320
|
+
}
|
package/src/tools/advanced.ts
CHANGED
|
@@ -2,7 +2,12 @@ import { type Address, decodeAbiParameters, isAddress, parseAbiParameters } from
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import type { ChainName } from '../types.js'
|
|
4
4
|
import { getClientManager, SUPPORTED_CHAINS } from '../client.js'
|
|
5
|
-
import { createTool, formatResponse } from '../utils.js'
|
|
5
|
+
import { createTool, formatResponse, summarizeTrace } from '../utils.js'
|
|
6
|
+
import {
|
|
7
|
+
isAnvilInstalled,
|
|
8
|
+
traceTransactionWithAnvil,
|
|
9
|
+
simulateCallWithTrace
|
|
10
|
+
} from '../anvil.js'
|
|
6
11
|
|
|
7
12
|
export default {
|
|
8
13
|
get_storage_at: createTool(
|
|
@@ -162,7 +167,17 @@ export default {
|
|
|
162
167
|
.describe(
|
|
163
168
|
'Trace type: "trace" (call tree, recommended), "vmTrace" (VM execution), "stateDiff" (state changes)'
|
|
164
169
|
)
|
|
165
|
-
.default('trace')
|
|
170
|
+
.default('trace'),
|
|
171
|
+
useAnvil: z
|
|
172
|
+
.boolean()
|
|
173
|
+
.optional()
|
|
174
|
+
.describe('Force using Anvil for tracing (requires Foundry installed). Auto-used as fallback when RPC tracing fails.')
|
|
175
|
+
.default(false),
|
|
176
|
+
summarize: z
|
|
177
|
+
.boolean()
|
|
178
|
+
.optional()
|
|
179
|
+
.describe('Return a compact summary instead of full trace. Truncates hex data and flattens nested calls.')
|
|
180
|
+
.default(false)
|
|
166
181
|
}),
|
|
167
182
|
async (args) => {
|
|
168
183
|
const clientManager = getClientManager()
|
|
@@ -174,89 +189,243 @@ export default {
|
|
|
174
189
|
const receipt = await client.getTransactionReceipt({ hash: args.transactionHash as `0x${string}` })
|
|
175
190
|
|
|
176
191
|
let traceResult: unknown = null
|
|
192
|
+
let usedAnvil = false
|
|
177
193
|
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
params: [args.transactionHash, { tracer: 'callTracer' }]
|
|
186
|
-
})
|
|
187
|
-
} catch (e) {
|
|
188
|
-
traceResult = { error: (e as Error).message }
|
|
189
|
-
}
|
|
190
|
-
break
|
|
194
|
+
// Map trace type to tracer name
|
|
195
|
+
const tracerMap: Record<string, 'callTracer' | 'prestateTracer' | 'stateDiffTracer'> = {
|
|
196
|
+
trace: 'callTracer',
|
|
197
|
+
vmTrace: 'prestateTracer',
|
|
198
|
+
stateDiff: 'stateDiffTracer'
|
|
199
|
+
}
|
|
200
|
+
const tracer = tracerMap[args.traceType ?? 'trace'] ?? 'callTracer'
|
|
191
201
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
202
|
+
// Try RPC tracing first (unless forceAnvil is true)
|
|
203
|
+
if (!args.useAnvil) {
|
|
204
|
+
try {
|
|
205
|
+
traceResult = await client.request({
|
|
206
|
+
method: 'debug_traceTransaction',
|
|
207
|
+
params: [args.transactionHash, { tracer }]
|
|
208
|
+
})
|
|
209
|
+
} catch (rpcError) {
|
|
210
|
+
// RPC tracing failed, will try Anvil fallback
|
|
211
|
+
const errorMessage = (rpcError as Error).message
|
|
212
|
+
if (
|
|
213
|
+
errorMessage.includes('not supported') ||
|
|
214
|
+
errorMessage.includes('not available') ||
|
|
215
|
+
errorMessage.includes('method not found') ||
|
|
216
|
+
errorMessage.includes('does not exist')
|
|
217
|
+
) {
|
|
218
|
+
// This is an expected error for public RPCs, try Anvil
|
|
219
|
+
traceResult = null
|
|
220
|
+
} else {
|
|
221
|
+
// Other error, store it but still try Anvil
|
|
222
|
+
traceResult = { rpcError: errorMessage }
|
|
200
223
|
}
|
|
201
|
-
|
|
224
|
+
}
|
|
225
|
+
}
|
|
202
226
|
|
|
203
|
-
|
|
227
|
+
// Fallback to Anvil if RPC tracing failed or was skipped
|
|
228
|
+
if (traceResult === null || (traceResult && typeof traceResult === 'object' && 'rpcError' in traceResult) || args.useAnvil) {
|
|
229
|
+
const anvilAvailable = await isAnvilInstalled()
|
|
230
|
+
if (anvilAvailable) {
|
|
231
|
+
usedAnvil = true // Mark as used before attempting (even if it fails)
|
|
204
232
|
try {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
params: [args.transactionHash, { tracer: 'stateDiffTracer' }]
|
|
208
|
-
})
|
|
209
|
-
} catch (e) {
|
|
210
|
-
traceResult = { error: (e as Error).message }
|
|
211
|
-
}
|
|
212
|
-
break
|
|
233
|
+
const forkUrl = clientManager.getRpcUrl(args.chain as ChainName)
|
|
234
|
+
const blockNumber = transaction.blockNumber ?? 0n
|
|
213
235
|
|
|
214
|
-
|
|
236
|
+
traceResult = await traceTransactionWithAnvil(
|
|
237
|
+
forkUrl,
|
|
238
|
+
args.transactionHash,
|
|
239
|
+
blockNumber,
|
|
240
|
+
tracer,
|
|
241
|
+
args.chain
|
|
242
|
+
)
|
|
243
|
+
} catch (anvilError) {
|
|
244
|
+
// Anvil tracing also failed
|
|
245
|
+
traceResult = {
|
|
246
|
+
error: `Anvil tracing failed: ${(anvilError as Error).message}`,
|
|
247
|
+
rpcError: traceResult && typeof traceResult === 'object' && 'rpcError' in traceResult
|
|
248
|
+
? (traceResult as { rpcError: string }).rpcError
|
|
249
|
+
: 'RPC does not support debug_traceTransaction'
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} else if (!traceResult || (typeof traceResult === 'object' && 'rpcError' in traceResult)) {
|
|
253
|
+
// Anvil not available and RPC failed
|
|
215
254
|
traceResult = {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
gas: transaction.gas?.toString() || '0',
|
|
221
|
-
gasUsed: receipt.gasUsed?.toString() || '0',
|
|
222
|
-
input: transaction.input,
|
|
223
|
-
output: '0x',
|
|
224
|
-
error: receipt.status === 'success' ? null : 'Transaction failed'
|
|
255
|
+
error: 'Tracing not available. Install Foundry (anvil) for local tracing: https://book.getfoundry.sh/getting-started/installation',
|
|
256
|
+
rpcError: traceResult && typeof traceResult === 'object' && 'rpcError' in traceResult
|
|
257
|
+
? (traceResult as { rpcError: string }).rpcError
|
|
258
|
+
: 'RPC does not support debug_traceTransaction'
|
|
225
259
|
}
|
|
260
|
+
}
|
|
226
261
|
}
|
|
227
262
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
263
|
+
// Apply summarization if requested - focused on finding reverts
|
|
264
|
+
// Skip summarization only if traceResult is a plain error object (not a call trace with an error field)
|
|
265
|
+
const isPlainError = traceResult && typeof traceResult === 'object' &&
|
|
266
|
+
'error' in traceResult && !('type' in traceResult) && !('calls' in traceResult)
|
|
267
|
+
const traceSummary = args.summarize && traceResult && typeof traceResult === 'object' && !isPlainError
|
|
268
|
+
? summarizeTrace(traceResult)
|
|
269
|
+
: null
|
|
270
|
+
|
|
271
|
+
const result = args.summarize && traceSummary
|
|
272
|
+
? {
|
|
273
|
+
chain: args.chain,
|
|
274
|
+
transactionHash: args.transactionHash,
|
|
275
|
+
status: receipt.status,
|
|
276
|
+
gasUsed: receipt.gasUsed?.toString(),
|
|
277
|
+
...traceSummary // { hasError, errorPath, summary }
|
|
278
|
+
}
|
|
279
|
+
: {
|
|
280
|
+
success: true,
|
|
281
|
+
chain: args.chain,
|
|
282
|
+
transactionHash: args.transactionHash,
|
|
283
|
+
traceType: args.traceType,
|
|
284
|
+
usedAnvil,
|
|
285
|
+
transaction: {
|
|
286
|
+
blockNumber: transaction.blockNumber?.toString(),
|
|
287
|
+
from: transaction.from,
|
|
288
|
+
to: transaction.to,
|
|
289
|
+
value: transaction.value?.toString() || '0',
|
|
290
|
+
gas: transaction.gas?.toString() || '0',
|
|
291
|
+
gasPrice: transaction.gasPrice?.toString() || '0',
|
|
292
|
+
nonce: transaction.nonce?.toString() || '0',
|
|
293
|
+
input: transaction.input
|
|
294
|
+
},
|
|
295
|
+
receipt: {
|
|
296
|
+
status: receipt.status,
|
|
297
|
+
gasUsed: receipt.gasUsed?.toString() || '0',
|
|
298
|
+
effectiveGasPrice: receipt.effectiveGasPrice?.toString() || '0',
|
|
299
|
+
logs: receipt.logs.map(log => ({
|
|
300
|
+
address: log.address,
|
|
301
|
+
topics: log.topics,
|
|
302
|
+
data: log.data
|
|
303
|
+
}))
|
|
304
|
+
},
|
|
305
|
+
trace: traceResult
|
|
306
|
+
}
|
|
255
307
|
|
|
256
308
|
return formatResponse(result)
|
|
257
309
|
} catch (error) {
|
|
258
310
|
throw new Error(`Transaction trace failed: ${error}`)
|
|
259
311
|
}
|
|
260
312
|
}
|
|
313
|
+
),
|
|
314
|
+
|
|
315
|
+
debug_call: createTool(
|
|
316
|
+
'Debug Contract Call',
|
|
317
|
+
'Simulate a contract call with full trace output. Requires Foundry (anvil) installed. Useful for debugging reverts and understanding call execution.',
|
|
318
|
+
z.object({
|
|
319
|
+
chain: z.enum(SUPPORTED_CHAINS).describe('The blockchain network to fork'),
|
|
320
|
+
to: z.string().describe('Contract address to call'),
|
|
321
|
+
data: z.string().optional().describe('Calldata (hex encoded). Either provide this or functionAbi + args.'),
|
|
322
|
+
functionAbi: z
|
|
323
|
+
.string()
|
|
324
|
+
.optional()
|
|
325
|
+
.describe('Function ABI signature (e.g., "function transfer(address to, uint256 amount)"). Use with args parameter.'),
|
|
326
|
+
args: z
|
|
327
|
+
.array(z.union([z.string(), z.number(), z.boolean()]))
|
|
328
|
+
.optional()
|
|
329
|
+
.describe('Function arguments (when using functionAbi)'),
|
|
330
|
+
from: z
|
|
331
|
+
.string()
|
|
332
|
+
.optional()
|
|
333
|
+
.describe('Sender address (defaults to zero address)'),
|
|
334
|
+
value: z.string().optional().describe('ETH value to send (in wei)'),
|
|
335
|
+
blockNumber: z.string().optional().describe('Block number to fork from (defaults to latest)'),
|
|
336
|
+
traceType: z
|
|
337
|
+
.enum(['callTracer', 'prestateTracer'])
|
|
338
|
+
.optional()
|
|
339
|
+
.default('callTracer')
|
|
340
|
+
.describe('Trace type: callTracer (call tree) or prestateTracer (state before execution)'),
|
|
341
|
+
summarize: z
|
|
342
|
+
.boolean()
|
|
343
|
+
.optional()
|
|
344
|
+
.describe('Return a compact summary instead of full trace. Truncates hex data and flattens nested calls.')
|
|
345
|
+
.default(false)
|
|
346
|
+
}),
|
|
347
|
+
async (args) => {
|
|
348
|
+
// Check if Anvil is installed
|
|
349
|
+
const anvilAvailable = await isAnvilInstalled()
|
|
350
|
+
if (!anvilAvailable) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
'Anvil is not installed. Please install Foundry: https://book.getfoundry.sh/getting-started/installation'
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const clientManager = getClientManager()
|
|
357
|
+
const forkUrl = clientManager.getRpcUrl(args.chain as ChainName)
|
|
358
|
+
|
|
359
|
+
// Encode calldata if functionAbi is provided
|
|
360
|
+
let calldata = args.data
|
|
361
|
+
if (args.functionAbi && !calldata) {
|
|
362
|
+
try {
|
|
363
|
+
const { encodeFunctionData, parseAbiItem } = await import('viem')
|
|
364
|
+
const abiItem = parseAbiItem(args.functionAbi)
|
|
365
|
+
if (abiItem.type !== 'function') {
|
|
366
|
+
throw new Error('ABI must be a function signature')
|
|
367
|
+
}
|
|
368
|
+
calldata = encodeFunctionData({
|
|
369
|
+
abi: [abiItem],
|
|
370
|
+
functionName: abiItem.name,
|
|
371
|
+
args: (args.args ?? []) as readonly unknown[]
|
|
372
|
+
})
|
|
373
|
+
} catch (encodeError) {
|
|
374
|
+
throw new Error(`Failed to encode function call: ${(encodeError as Error).message}`)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
const blockNumber = args.blockNumber ? BigInt(args.blockNumber) : undefined
|
|
380
|
+
const value = args.value ? BigInt(args.value) : undefined
|
|
381
|
+
|
|
382
|
+
const traceResult = await simulateCallWithTrace(
|
|
383
|
+
forkUrl,
|
|
384
|
+
{
|
|
385
|
+
to: args.to,
|
|
386
|
+
data: calldata,
|
|
387
|
+
from: args.from,
|
|
388
|
+
value
|
|
389
|
+
},
|
|
390
|
+
blockNumber,
|
|
391
|
+
args.traceType
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
// Apply summarization if requested - focused on finding reverts
|
|
395
|
+
// Skip summarization only if trace is a plain error object (not a call trace with an error field)
|
|
396
|
+
const isPlainTraceError = traceResult.trace && typeof traceResult.trace === 'object' &&
|
|
397
|
+
'error' in traceResult.trace && !('type' in traceResult.trace) && !('calls' in traceResult.trace)
|
|
398
|
+
const traceSummary = args.summarize && traceResult.trace && typeof traceResult.trace === 'object' && !isPlainTraceError
|
|
399
|
+
? summarizeTrace(traceResult.trace)
|
|
400
|
+
: null
|
|
401
|
+
|
|
402
|
+
const result = args.summarize && traceSummary
|
|
403
|
+
? {
|
|
404
|
+
chain: args.chain,
|
|
405
|
+
to: args.to,
|
|
406
|
+
success: traceResult.success,
|
|
407
|
+
gasUsed: traceResult.gasUsed.toString(),
|
|
408
|
+
revertReason: traceResult.revertReason,
|
|
409
|
+
...traceSummary // { hasError, errorPath, summary }
|
|
410
|
+
}
|
|
411
|
+
: {
|
|
412
|
+
success: traceResult.success,
|
|
413
|
+
chain: args.chain,
|
|
414
|
+
to: args.to,
|
|
415
|
+
from: args.from ?? '0x0000000000000000000000000000000000000000',
|
|
416
|
+
data: calldata,
|
|
417
|
+
value: value?.toString() ?? '0',
|
|
418
|
+
blockNumber: blockNumber?.toString() ?? 'latest',
|
|
419
|
+
result: traceResult.result,
|
|
420
|
+
gasUsed: traceResult.gasUsed.toString(),
|
|
421
|
+
revertReason: traceResult.revertReason,
|
|
422
|
+
trace: traceResult.trace
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return formatResponse(result)
|
|
426
|
+
} catch (error) {
|
|
427
|
+
throw new Error(`Debug call failed: ${(error as Error).message}`)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
261
430
|
)
|
|
262
431
|
}
|
package/src/tools/contract.ts
CHANGED
|
@@ -94,9 +94,12 @@ export default {
|
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
// Execute multicall
|
|
97
|
+
// Use deployless mode for localhost/anvil since Multicall3 may not be deployed
|
|
98
|
+
const useDeployless = chain === "localhost";
|
|
97
99
|
const multicallResults = await client.multicall({
|
|
98
100
|
contracts: multicallContracts,
|
|
99
101
|
blockNumber: blockTag === "latest" ? undefined : blockTag,
|
|
102
|
+
...(useDeployless && { deployless: true }),
|
|
100
103
|
});
|
|
101
104
|
|
|
102
105
|
// Process results
|