web3-tools-mcp 1.3.4 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +63 -5
  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/chain-meta.d.ts +27 -0
  7. package/dist/chain-meta.d.ts.map +1 -0
  8. package/dist/chain-meta.js +92 -0
  9. package/dist/chain-meta.js.map +1 -0
  10. package/dist/clear-signing.d.ts +62 -0
  11. package/dist/clear-signing.d.ts.map +1 -0
  12. package/dist/clear-signing.js +139 -0
  13. package/dist/clear-signing.js.map +1 -0
  14. package/dist/client.d.ts +3 -0
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +11 -0
  17. package/dist/client.js.map +1 -1
  18. package/dist/erc7730-index.json.gz +0 -0
  19. package/dist/index.js +25 -5
  20. package/dist/index.js.map +1 -1
  21. package/dist/preview.d.ts +58 -0
  22. package/dist/preview.d.ts.map +1 -0
  23. package/dist/preview.js +182 -0
  24. package/dist/preview.js.map +1 -0
  25. package/dist/tools/advanced.d.ts +67 -9
  26. package/dist/tools/advanced.d.ts.map +1 -1
  27. package/dist/tools/advanced.js +206 -67
  28. package/dist/tools/advanced.js.map +1 -1
  29. package/dist/tools/balance.d.ts +5 -5
  30. package/dist/tools/contract-info.d.ts +9 -9
  31. package/dist/tools/contract.d.ts +8 -8
  32. package/dist/tools/ens.d.ts +15 -15
  33. package/dist/tools/gas.d.ts +18 -18
  34. package/dist/tools/logs.d.ts +3 -3
  35. package/dist/tools/transactions.d.ts +11 -11
  36. package/dist/tools/transactions.d.ts.map +1 -1
  37. package/dist/tools/transactions.js +92 -113
  38. package/dist/tools/transactions.js.map +1 -1
  39. package/dist/utils.d.ts +13 -0
  40. package/dist/utils.d.ts.map +1 -1
  41. package/dist/utils.js +76 -0
  42. package/dist/utils.js.map +1 -1
  43. package/dist/wallet-client.d.ts +83 -0
  44. package/dist/wallet-client.d.ts.map +1 -0
  45. package/dist/wallet-client.js +357 -0
  46. package/dist/wallet-client.js.map +1 -0
  47. package/package.json +11 -9
  48. package/src/anvil.ts +323 -0
  49. package/src/chain-meta.ts +102 -0
  50. package/src/clear-signing.ts +206 -0
  51. package/src/client.ts +14 -0
  52. package/src/erc7730-index.json.gz +0 -0
  53. package/src/index.ts +26 -5
  54. package/src/preview.ts +265 -0
  55. package/src/tools/advanced.ts +239 -70
  56. package/src/tools/transactions.ts +98 -125
  57. package/src/utils.ts +95 -0
  58. package/src/wallet-client.ts +380 -0
  59. package/dist/wallet-server.d.ts +0 -35
  60. package/dist/wallet-server.d.ts.map +0 -1
  61. package/dist/wallet-server.js +0 -232
  62. package/dist/wallet-server.js.map +0 -1
  63. package/public/wallet-app.js +0 -677
  64. package/public/wallet.html +0 -723
  65. package/src/wallet-server.ts +0 -283
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
6
  import { initializeClientManager } from "./client.js";
7
7
  import { registerAllTools } from "./tools/index.js";
8
8
  import { parseCommandLineArgs } from "./utils.js";
9
- import { startWalletServer } from "./wallet-server.js";
9
+ import { getWalletClient } from "./wallet-client.js";
10
10
 
11
11
  // Parse configuration
12
12
  const config = parseCommandLineArgs();
@@ -34,6 +34,8 @@ ENVIRONMENT VARIABLES:
34
34
  ALCHEMY_API_KEY Alternative to --alchemy-api-key
35
35
  INFURA_API_KEY Alternative to --infura-api-key
36
36
  HYPERSYNC_API_KEY Alternative to --hypersync-api-key
37
+ WALLET_SERVER_URL URL of a hosted wallet relay (omit to run one locally)
38
+ WALLET_TOKEN Shared pairing token for the wallet relay
37
39
 
38
40
  SUPPORTED CHAINS:
39
41
  mainnet, arbitrum, avalanche, base, bnb, gnosis, sonic, optimism, polygon, zksync, linea, unichain, localhost
@@ -84,18 +86,37 @@ const server = new McpServer({
84
86
  // Register all tools
85
87
  registerAllTools(server);
86
88
 
87
- // Start wallet server in background
88
- startWalletServer().catch((error) => {
89
- console.error("[MCP] Wallet server failed to start:", error.message);
89
+ // Connect to the wallet relay (hosted when WALLET_SERVER_URL is set, embedded otherwise)
90
+ const wallet = getWalletClient();
91
+ const walletReady = wallet.connect().catch((error) => {
92
+ console.error("[MCP] Wallet relay unavailable:", error.message);
90
93
  console.error("[MCP] Transaction signing features will not be available");
91
94
  });
92
95
 
96
+ // The wallet relay's listening socket keeps the event loop alive, so this process would
97
+ // outlive the client that spawned it and go on holding its port. Leave when the client does.
98
+ let shuttingDown = false;
99
+ async function shutdown(reason: string) {
100
+ if (shuttingDown) return;
101
+ shuttingDown = true;
102
+ console.error(`[MCP] Shutting down (${reason})`);
103
+ await wallet.stop().catch(() => {});
104
+ process.exit(0);
105
+ }
106
+
107
+ process.stdin.on("end", () => shutdown("client disconnected"));
108
+ process.stdin.on("close", () => shutdown("client disconnected"));
109
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
110
+ process.on("SIGINT", () => shutdown("SIGINT"));
111
+
93
112
  // Start server
94
113
  async function main() {
95
114
  const transport = new StdioServerTransport();
96
115
  await server.connect(transport);
97
116
  console.error("Web3 Tools MCP Server running on stdio");
98
- console.error("Wallet interface available at http://localhost:3456");
117
+ // The relay may still be picking a free port, and its URL carries the pairing token.
118
+ await walletReady;
119
+ console.error(`Wallet interface available at ${wallet.getUrl()}`);
99
120
  }
100
121
 
101
122
  main().catch((error) => {
package/src/preview.ts ADDED
@@ -0,0 +1,265 @@
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 { getClientManager } from './client.js'
13
+ import { addressLabel, loadAbi, tokenMeta, UNLIMITED_THRESHOLD } from './chain-meta.js'
14
+ import { lookupContract, protocolLabel, resolveClearSigning } from './clear-signing.js'
15
+ import type { ChainName } from './types.js'
16
+
17
+ export interface PreviewField {
18
+ name: string
19
+ type: string
20
+ value: string
21
+ warning?: string
22
+ /** Set when the value is an address, so the UI can link and label it. */
23
+ address?: string
24
+ label?: string
25
+ }
26
+
27
+ export interface AssetChange {
28
+ token: string
29
+ symbol?: string
30
+ decimals?: number
31
+ from: string
32
+ to: string
33
+ amount: string
34
+ humanAmount?: string
35
+ }
36
+
37
+ export interface TxPreview {
38
+ chain: string
39
+ to: string
40
+ /** Token symbol or verified contract name for `to`, when we can resolve one. */
41
+ toLabel?: string
42
+ /** Block explorer base URL, for linking addresses and tokens. */
43
+ explorer?: string
44
+ value?: string
45
+ valueFormatted?: string
46
+ decoded?: {
47
+ functionName: string
48
+ signature: string
49
+ fields: PreviewField[]
50
+ /** From the ERC-7730 registry: what this call means, and whose contract it is. */
51
+ intent?: string
52
+ protocol?: string
53
+ /** 'verified' = ABI from Sourcify/Etherscan, 'guessed' = selector lookup on bytecode */
54
+ source: 'verified' | 'guessed'
55
+ proxy?: string
56
+ }
57
+ simulation?: {
58
+ success: boolean
59
+ gasEstimate?: string
60
+ error?: string
61
+ assetChanges: AssetChange[]
62
+ }
63
+ }
64
+
65
+ export interface RawTx {
66
+ to: string
67
+ data?: string
68
+ value?: string
69
+ }
70
+
71
+ const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
72
+
73
+ // Selectors whose amount argument is denominated in the token at tx.to.
74
+ const ERC20_AMOUNT_ARGS: Record<string, { arg: string; approval?: boolean }> = {
75
+ [toFunctionSelector('function approve(address,uint256)')]: { arg: 'amount', approval: true },
76
+ [toFunctionSelector('function transfer(address,uint256)')]: { arg: 'amount' },
77
+ [toFunctionSelector('function transferFrom(address,address,uint256)')]: { arg: 'amount' }
78
+ }
79
+
80
+ function stringify(value: unknown): string {
81
+ if (typeof value === 'bigint') return value.toString()
82
+ if (Array.isArray(value)) return `[${value.map(stringify).join(', ')}]`
83
+ if (value && typeof value === 'object') {
84
+ return `{${Object.entries(value)
85
+ .map(([k, v]) => `${k}: ${stringify(v)}`)
86
+ .join(', ')}}`
87
+ }
88
+ return String(value)
89
+ }
90
+
91
+ /**
92
+ * Decode calldata into labelled fields (clear signing). Token amounts on the standard
93
+ * ERC20 selectors are formatted with on-chain decimals and unlimited approvals flagged.
94
+ */
95
+ async function decodeCalldata(chain: ChainName, tx: RawTx): Promise<TxPreview['decoded']> {
96
+ if (!tx.data || tx.data === '0x') return undefined
97
+
98
+ const { abi, source, proxy } = await loadAbi(chain, tx.to)
99
+ const { functionName, args } = decodeFunctionData({ abi, data: tx.data as `0x${string}` })
100
+
101
+ const abiItem = abi.find((item): item is AbiFunction => item.type === 'function' && item.name === functionName)
102
+ const inputs = abiItem?.inputs ?? []
103
+ const erc20 = ERC20_AMOUNT_ARGS[tx.data.slice(0, 10)]
104
+ const meta = erc20 ? await tokenMeta(chain, tx.to).catch(() => ({ symbol: undefined, decimals: undefined })) : undefined
105
+
106
+ const fields: PreviewField[] = (args ?? []).map((value, i) => {
107
+ const input = inputs[i]
108
+ const name = input?.name || `arg${i}`
109
+ const field: PreviewField = { name, type: input?.type ?? 'unknown', value: stringify(value) }
110
+
111
+ if (input?.type === 'address' && typeof value === 'string') field.address = value
112
+
113
+ // Amount argument is positionally last on all three ERC20 selectors.
114
+ if (erc20 && i === (args as readonly unknown[]).length - 1 && typeof value === 'bigint') {
115
+ if (meta?.decimals !== undefined) {
116
+ field.value = `${formatUnits(value, meta.decimals)}${meta.symbol ? ` ${meta.symbol}` : ''}`
117
+ }
118
+ if (erc20.approval && value > UNLIMITED_THRESHOLD) {
119
+ field.value = `Unlimited${meta?.symbol ? ` ${meta.symbol}` : ''}`
120
+ field.warning = 'Unlimited spending approval'
121
+ }
122
+ }
123
+ return field
124
+ })
125
+
126
+ // Label every address argument at once rather than serially per field.
127
+ await Promise.all(
128
+ fields
129
+ .filter((field) => field.address)
130
+ .map(async (field) => {
131
+ field.label = await addressLabel(chain, field.address as string)
132
+ })
133
+ )
134
+
135
+ const signature = abiItem
136
+ ? `${functionName}(${inputs.map((i) => `${i.type}${i.name ? ` ${i.name}` : ''}`).join(', ')})`
137
+ : functionName
138
+
139
+ // Map positional args to their parameter names so registry field paths resolve.
140
+ const named: Record<string, unknown> = {}
141
+ inputs.forEach((input, i) => {
142
+ if (input.name) named[input.name] = (args ?? [])[i]
143
+ })
144
+
145
+ // A registry descriptor says what the call means and how the protocol wants each field
146
+ // labelled, which beats raw ABI argument names. Fall back to those when it has none.
147
+ const clearSigning = await resolveClearSigning(chain, tx, named).catch(() => null)
148
+ if (clearSigning) {
149
+ return {
150
+ functionName,
151
+ signature,
152
+ source,
153
+ proxy,
154
+ intent: clearSigning.intent,
155
+ protocol: clearSigning.protocol,
156
+ fields: clearSigning.fields.map((field) => ({
157
+ name: field.label,
158
+ type: field.format,
159
+ value: field.value,
160
+ ...(field.address && { address: field.address }),
161
+ ...(field.name && { label: field.name }),
162
+ ...(field.value.startsWith('Unlimited') && { warning: 'Unlimited spending approval' })
163
+ }))
164
+ }
165
+ }
166
+
167
+ return { functionName, signature, fields, source, proxy }
168
+ }
169
+
170
+ function topicToAddress(topic: string): string {
171
+ return `0x${topic.slice(-40)}`
172
+ }
173
+
174
+ async function enrichTransfers(chain: ChainName, logs: readonly { address: string; topics: readonly string[]; data: string }[]) {
175
+ const transfers = logs.filter((log) => log.topics[0]?.toLowerCase() === TRANSFER_TOPIC && log.topics.length >= 3)
176
+
177
+ return Promise.all(
178
+ transfers.map(async (log): Promise<AssetChange> => {
179
+ const amount = log.data && log.data !== '0x' ? BigInt(log.data).toString() : '0'
180
+ const meta = await tokenMeta(chain, log.address).catch(() => ({ symbol: undefined, decimals: undefined }))
181
+ return {
182
+ token: log.address,
183
+ symbol: meta.symbol,
184
+ decimals: meta.decimals,
185
+ from: topicToAddress(log.topics[1]!),
186
+ to: topicToAddress(log.topics[2]!),
187
+ amount,
188
+ humanAmount: meta.decimals !== undefined ? formatUnits(BigInt(amount), meta.decimals) : undefined
189
+ }
190
+ })
191
+ )
192
+ }
193
+
194
+ /**
195
+ * Simulate a transaction without broadcasting.
196
+ *
197
+ * Prefers eth_simulateV1 (viem `simulateBlocks`) for the ERC20 transfer logs it returns,
198
+ * falling back to eth_call + estimateGas on RPCs that don't implement it.
199
+ */
200
+ async function simulate(chain: ChainName, tx: RawTx, from: Address): Promise<TxPreview['simulation']> {
201
+ const client = getClientManager().getClient(chain)
202
+ const call = {
203
+ account: from,
204
+ to: tx.to as Address,
205
+ ...(tx.data && { data: tx.data as `0x${string}` }),
206
+ ...(tx.value && BigInt(tx.value) > 0n && { value: BigInt(tx.value) })
207
+ }
208
+
209
+ try {
210
+ const blocks = await simulateBlocks(client, { blocks: [{ calls: [call] }], traceTransfers: true, validation: false })
211
+ const result = blocks?.[0]?.calls?.[0]
212
+ if (result) {
213
+ const error = result.error as { shortMessage?: string; message?: string } | undefined
214
+ return {
215
+ success: result.status === 'success',
216
+ gasEstimate: result.gasUsed?.toString(),
217
+ error: result.status === 'success' ? undefined : (error?.shortMessage ?? error?.message ?? 'execution reverted'),
218
+ assetChanges: await enrichTransfers(chain, (result.logs ?? []) as never)
219
+ }
220
+ }
221
+ } catch {
222
+ // RPC lacks eth_simulateV1 — fall through
223
+ }
224
+
225
+ try {
226
+ await client.call(call)
227
+ const gas = await client.estimateGas(call)
228
+ return { success: true, gasEstimate: gas.toString(), assetChanges: [] }
229
+ } catch (error) {
230
+ return {
231
+ success: false,
232
+ error: error instanceof Error ? error.message.slice(0, 500) : String(error),
233
+ assetChanges: []
234
+ }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Build the human-readable preview shown in the wallet before signing: decoded calldata
240
+ * plus a simulation of the outcome. Never throws — a preview that cannot be built is
241
+ * reported as missing rather than blocking the transaction.
242
+ */
243
+ export async function buildTxPreview(chain: ChainName, tx: RawTx, from?: string): Promise<TxPreview> {
244
+ const value = tx.value ? BigInt(tx.value).toString() : undefined
245
+
246
+ const [decoded, simulation, toLabel] = await Promise.all([
247
+ decodeCalldata(chain, tx).catch(() => undefined),
248
+ from ? simulate(chain, tx, from as Address).catch(() => undefined) : Promise.resolve(undefined),
249
+ // The registry's protocol name beats anything on-chain: "Aave", not a proxy's class name.
250
+ Promise.resolve(lookupContract(chain, tx.to))
251
+ .then((entry) => (entry ? protocolLabel(entry.protocol) : addressLabel(chain, tx.to)))
252
+ .catch(() => undefined)
253
+ ])
254
+
255
+ return {
256
+ chain,
257
+ to: tx.to,
258
+ toLabel,
259
+ explorer: `https://${getClientManager().getEtherscanDomain(chain)}`,
260
+ value,
261
+ valueFormatted: value && value !== '0' ? formatEther(BigInt(value)) : undefined,
262
+ decoded,
263
+ simulation
264
+ }
265
+ }
@@ -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
  }