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.
Files changed (57) hide show
  1. package/README.md +51 -4
  2. package/dist/anvil.d.ts +34 -0
  3. package/dist/anvil.d.ts.map +1 -0
  4. package/dist/anvil.js +254 -0
  5. package/dist/anvil.js.map +1 -0
  6. package/dist/client.d.ts +3 -0
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +13 -2
  9. package/dist/client.js.map +1 -1
  10. package/dist/index.js +25 -5
  11. package/dist/index.js.map +1 -1
  12. package/dist/preview.d.ts +55 -0
  13. package/dist/preview.d.ts.map +1 -0
  14. package/dist/preview.js +233 -0
  15. package/dist/preview.js.map +1 -0
  16. package/dist/tools/advanced.d.ts +67 -9
  17. package/dist/tools/advanced.d.ts.map +1 -1
  18. package/dist/tools/advanced.js +206 -67
  19. package/dist/tools/advanced.js.map +1 -1
  20. package/dist/tools/balance.d.ts +5 -5
  21. package/dist/tools/contract-info.d.ts +9 -9
  22. package/dist/tools/contract.d.ts +8 -8
  23. package/dist/tools/contract.d.ts.map +1 -1
  24. package/dist/tools/contract.js +3 -0
  25. package/dist/tools/contract.js.map +1 -1
  26. package/dist/tools/ens.d.ts +15 -15
  27. package/dist/tools/gas.d.ts +18 -18
  28. package/dist/tools/logs.d.ts +3 -3
  29. package/dist/tools/transactions.d.ts +9 -9
  30. package/dist/tools/transactions.d.ts.map +1 -1
  31. package/dist/tools/transactions.js +92 -113
  32. package/dist/tools/transactions.js.map +1 -1
  33. package/dist/utils.d.ts +13 -0
  34. package/dist/utils.d.ts.map +1 -1
  35. package/dist/utils.js +76 -0
  36. package/dist/utils.js.map +1 -1
  37. package/dist/wallet-client.d.ts +83 -0
  38. package/dist/wallet-client.d.ts.map +1 -0
  39. package/dist/wallet-client.js +357 -0
  40. package/dist/wallet-client.js.map +1 -0
  41. package/package.json +9 -8
  42. package/src/anvil.ts +323 -0
  43. package/src/client.ts +16 -2
  44. package/src/index.ts +26 -5
  45. package/src/preview.ts +320 -0
  46. package/src/tools/advanced.ts +239 -70
  47. package/src/tools/contract.ts +3 -0
  48. package/src/tools/transactions.ts +98 -125
  49. package/src/utils.ts +95 -0
  50. package/src/wallet-client.ts +380 -0
  51. package/dist/wallet-server.d.ts +0 -35
  52. package/dist/wallet-server.d.ts.map +0 -1
  53. package/dist/wallet-server.js +0 -232
  54. package/dist/wallet-server.js.map +0 -1
  55. package/public/wallet-app.js +0 -677
  56. package/public/wallet.html +0 -723
  57. 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
+ }
@@ -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
- // Perform the requested trace type
179
- switch (args.traceType) {
180
- case 'trace':
181
- try {
182
- // Use debug_traceTransaction for call trace
183
- traceResult = await client.request({
184
- method: 'debug_traceTransaction',
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
- case 'vmTrace':
193
- try {
194
- traceResult = await client.request({
195
- method: 'debug_traceTransaction',
196
- params: [args.transactionHash, { tracer: 'prestateTracer' }]
197
- })
198
- } catch (e) {
199
- traceResult = { error: (e as Error).message }
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
- break
224
+ }
225
+ }
202
226
 
203
- case 'stateDiff':
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
- traceResult = await client.request({
206
- method: 'debug_traceTransaction',
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
- default:
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
- type: 'CALL',
217
- from: transaction.from,
218
- to: transaction.to,
219
- value: transaction.value?.toString() || '0',
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
- const result = {
229
- success: true,
230
- chain: args.chain,
231
- transactionHash: args.transactionHash,
232
- traceType: args.traceType,
233
- transaction: {
234
- blockNumber: transaction.blockNumber?.toString(),
235
- from: transaction.from,
236
- to: transaction.to,
237
- value: transaction.value?.toString() || '0',
238
- gas: transaction.gas?.toString() || '0',
239
- gasPrice: transaction.gasPrice?.toString() || '0',
240
- nonce: transaction.nonce?.toString() || '0',
241
- input: transaction.input
242
- },
243
- receipt: {
244
- status: receipt.status,
245
- gasUsed: receipt.gasUsed?.toString() || '0',
246
- effectiveGasPrice: receipt.effectiveGasPrice?.toString() || '0',
247
- logs: receipt.logs.map(log => ({
248
- address: log.address,
249
- topics: log.topics,
250
- data: log.data
251
- }))
252
- },
253
- trace: traceResult
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
  }
@@ -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