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.
- package/README.md +63 -5
- 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/chain-meta.d.ts +27 -0
- package/dist/chain-meta.d.ts.map +1 -0
- package/dist/chain-meta.js +92 -0
- package/dist/chain-meta.js.map +1 -0
- package/dist/clear-signing.d.ts +62 -0
- package/dist/clear-signing.d.ts.map +1 -0
- package/dist/clear-signing.js +139 -0
- package/dist/clear-signing.js.map +1 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +11 -0
- package/dist/client.js.map +1 -1
- package/dist/erc7730-index.json.gz +0 -0
- package/dist/index.js +25 -5
- package/dist/index.js.map +1 -1
- package/dist/preview.d.ts +58 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +182 -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/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 +11 -11
- 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 +11 -9
- package/src/anvil.ts +323 -0
- package/src/chain-meta.ts +102 -0
- package/src/clear-signing.ts +206 -0
- package/src/client.ts +14 -0
- package/src/erc7730-index.json.gz +0 -0
- package/src/index.ts +26 -5
- package/src/preview.ts +265 -0
- package/src/tools/advanced.ts +239 -70
- 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/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 {
|
|
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
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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
|
}
|