web3-tools-mcp 3.0.0 → 3.2.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 (113) hide show
  1. package/CHANGELOG.md +115 -2
  2. package/README.md +96 -60
  3. package/dist/chain-meta.d.ts.map +1 -1
  4. package/dist/chain-meta.js +19 -8
  5. package/dist/chain-meta.js.map +1 -1
  6. package/dist/chains.d.ts +609 -65
  7. package/dist/chains.d.ts.map +1 -1
  8. package/dist/chains.js +89 -28
  9. package/dist/chains.js.map +1 -1
  10. package/dist/clear-signing.d.ts.map +1 -1
  11. package/dist/clear-signing.js +17 -6
  12. package/dist/clear-signing.js.map +1 -1
  13. package/dist/client.d.ts.map +1 -1
  14. package/dist/client.js +2 -4
  15. package/dist/client.js.map +1 -1
  16. package/dist/http-server.d.ts +1 -1
  17. package/dist/http-server.d.ts.map +1 -1
  18. package/dist/http-server.js +12 -5
  19. package/dist/http-server.js.map +1 -1
  20. package/dist/index.js +5 -6
  21. package/dist/index.js.map +1 -1
  22. package/dist/kv-storage.d.ts +2 -1
  23. package/dist/kv-storage.d.ts.map +1 -1
  24. package/dist/kv-storage.js +5 -2
  25. package/dist/kv-storage.js.map +1 -1
  26. package/dist/oauth.d.ts +18 -4
  27. package/dist/oauth.d.ts.map +1 -1
  28. package/dist/oauth.js +62 -26
  29. package/dist/oauth.js.map +1 -1
  30. package/dist/preview.d.ts.map +1 -1
  31. package/dist/preview.js +7 -5
  32. package/dist/preview.js.map +1 -1
  33. package/dist/tools/index.d.ts.map +1 -1
  34. package/dist/tools/index.js +16 -1
  35. package/dist/tools/index.js.map +1 -1
  36. package/dist/tools/read/advanced.d.ts +25 -20
  37. package/dist/tools/read/advanced.d.ts.map +1 -1
  38. package/dist/tools/read/advanced.js +45 -33
  39. package/dist/tools/read/advanced.js.map +1 -1
  40. package/dist/tools/read/balance.d.ts +6 -6
  41. package/dist/tools/read/balance.d.ts.map +1 -1
  42. package/dist/tools/read/balance.js +17 -32
  43. package/dist/tools/read/balance.js.map +1 -1
  44. package/dist/tools/read/contract-info.d.ts +14 -12
  45. package/dist/tools/read/contract-info.d.ts.map +1 -1
  46. package/dist/tools/read/contract-info.js +53 -122
  47. package/dist/tools/read/contract-info.js.map +1 -1
  48. package/dist/tools/read/contract.d.ts +10 -10
  49. package/dist/tools/read/contract.d.ts.map +1 -1
  50. package/dist/tools/read/contract.js +2 -1
  51. package/dist/tools/read/contract.js.map +1 -1
  52. package/dist/tools/read/defi.d.ts +86 -0
  53. package/dist/tools/read/defi.d.ts.map +1 -0
  54. package/dist/tools/read/defi.js +210 -0
  55. package/dist/tools/read/defi.js.map +1 -0
  56. package/dist/tools/read/ens.d.ts +20 -40
  57. package/dist/tools/read/ens.d.ts.map +1 -1
  58. package/dist/tools/read/ens.js +25 -33
  59. package/dist/tools/read/ens.js.map +1 -1
  60. package/dist/tools/read/gas.d.ts +16 -16
  61. package/dist/tools/read/gas.d.ts.map +1 -1
  62. package/dist/tools/read/gas.js +13 -48
  63. package/dist/tools/read/gas.js.map +1 -1
  64. package/dist/tools/read/logs.d.ts +65 -4
  65. package/dist/tools/read/logs.d.ts.map +1 -1
  66. package/dist/tools/read/logs.js +96 -115
  67. package/dist/tools/read/logs.js.map +1 -1
  68. package/dist/tools/read/signatures.d.ts.map +1 -1
  69. package/dist/tools/read/signatures.js +4 -3
  70. package/dist/tools/read/signatures.js.map +1 -1
  71. package/dist/tools/read/signing.d.ts.map +1 -1
  72. package/dist/tools/read/signing.js +8 -2
  73. package/dist/tools/read/signing.js.map +1 -1
  74. package/dist/tools/sign/transactions.d.ts +16 -16
  75. package/dist/tools/sign/transactions.d.ts.map +1 -1
  76. package/dist/tools/sign/transactions.js +54 -9
  77. package/dist/tools/sign/transactions.js.map +1 -1
  78. package/dist/tools/sign/wallet.d.ts +4 -4
  79. package/dist/types.d.ts +0 -1
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/utils.d.ts +18 -0
  82. package/dist/utils.d.ts.map +1 -1
  83. package/dist/utils.js +43 -2
  84. package/dist/utils.js.map +1 -1
  85. package/dist/wallet-client.d.ts +1 -0
  86. package/dist/wallet-client.d.ts.map +1 -1
  87. package/dist/wallet-client.js +18 -5
  88. package/dist/wallet-client.js.map +1 -1
  89. package/package.json +4 -7
  90. package/src/chain-meta.ts +16 -7
  91. package/src/chains.ts +101 -31
  92. package/src/clear-signing.ts +15 -9
  93. package/src/client.ts +2 -3
  94. package/src/http-server.ts +11 -6
  95. package/src/index.ts +5 -6
  96. package/src/kv-storage.ts +6 -3
  97. package/src/oauth.ts +93 -34
  98. package/src/preview.ts +7 -5
  99. package/src/tools/index.ts +14 -1
  100. package/src/tools/read/advanced.ts +63 -34
  101. package/src/tools/read/balance.ts +18 -33
  102. package/src/tools/read/contract-info.ts +64 -144
  103. package/src/tools/read/contract.ts +2 -1
  104. package/src/tools/read/defi.ts +287 -0
  105. package/src/tools/read/ens.ts +26 -33
  106. package/src/tools/read/gas.ts +13 -50
  107. package/src/tools/read/logs.ts +131 -143
  108. package/src/tools/read/signatures.ts +4 -4
  109. package/src/tools/read/signing.ts +8 -2
  110. package/src/tools/sign/transactions.ts +60 -9
  111. package/src/types.ts +0 -1
  112. package/src/utils.ts +47 -2
  113. package/src/wallet-client.ts +26 -5
package/src/oauth.ts CHANGED
@@ -3,7 +3,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
3
  import { homedir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'
6
- import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'
6
+ import { InvalidGrantError, InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'
7
7
  import type { AuthorizationParams, OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider.js'
8
8
  import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'
9
9
  import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'
@@ -30,6 +30,7 @@ interface StoredCode {
30
30
  resource?: string
31
31
  scopes: string[]
32
32
  address?: string
33
+ credential?: string
33
34
  expiresAt: number
34
35
  }
35
36
 
@@ -37,9 +38,17 @@ interface StoredToken {
37
38
  clientId: string
38
39
  scopes: string[]
39
40
  address?: string
41
+ credential?: string
40
42
  expiresAt: number
41
43
  }
42
44
 
45
+ /** Codes expire in milliseconds, tokens in seconds — the unit OAuth reports them in. */
46
+ function expired(key: string, value: unknown): boolean {
47
+ const expiresAt = (value as { expiresAt?: number } | undefined)?.expiresAt
48
+ if (expiresAt === undefined) return false
49
+ return (key.startsWith('code:') ? expiresAt : expiresAt * 1000) < Date.now()
50
+ }
51
+
43
52
  /** Redis where configured, a JSON file otherwise — issued tokens must outlive a restart. */
44
53
  class Store {
45
54
  private redis: KeyValueStorage | undefined
@@ -69,10 +78,12 @@ class Store {
69
78
  return this.read()[key] as T | undefined
70
79
  }
71
80
 
72
- async set<T>(key: string, value: T): Promise<void> {
73
- if (this.redis) return this.redis.setItem(`oauth:${key}`, value)
81
+ /** `ttl` in seconds lets Redis drop the entry itself; the file is pruned on every write. */
82
+ async set<T>(key: string, value: T, ttl?: number): Promise<void> {
83
+ if (this.redis) return this.redis.setItem(`oauth:${key}`, value, ttl)
74
84
  const data = this.read()
75
85
  data[key] = value
86
+ for (const [name, stored] of Object.entries(data)) if (expired(name, stored)) delete data[name]
76
87
  writeFileSync(this.file, JSON.stringify(data), { mode: 0o600 })
77
88
  }
78
89
 
@@ -104,8 +115,12 @@ function constantTimeEquals(a: string, b: string): boolean {
104
115
  * the deployment rather than anybody in particular.
105
116
  */
106
117
  export interface Login {
107
- /** The page served at GET /authorize. `hidden` must be placed inside its form. */
108
- page(hidden: string, error?: string): string | Promise<string>
118
+ /**
119
+ * The page served at GET /authorize. `hidden` must be placed inside its form. `request`
120
+ * names who is asking and where the code will be sent, so a person can tell a phishing
121
+ * client from their own; like `error`, its values arrive HTML-escaped.
122
+ */
123
+ page(hidden: string, error?: string, request?: { client: string; redirectHost: string }): string | Promise<string>
109
124
  /** Reads the posted form. An `error` re-renders the page; an `address` names the person. */
110
125
  identify(form: Record<string, string>): Promise<{ address?: string; error?: string }>
111
126
  /**
@@ -113,6 +128,11 @@ export interface Login {
113
128
  * page, so a credential can never be echoed back into the HTML.
114
129
  */
115
130
  owns?: string[]
131
+ /**
132
+ * A shared secret this login checks against, if it has one. Everything issued is bound to
133
+ * it, so rotating the secret revokes every code and token handed out under the old one.
134
+ */
135
+ credential?: string
116
136
  }
117
137
 
118
138
  const PAGE_STYLE = `
@@ -131,13 +151,15 @@ const PAGE_STYLE = `
131
151
  export function tokenLogin(accessToken: string): Login {
132
152
  return {
133
153
  owns: ['token'],
134
- page: (hidden, error) => `<!doctype html>
154
+ credential: accessToken,
155
+ page: (hidden, error, request) => `<!doctype html>
135
156
  <meta charset="utf-8">
136
157
  <meta name="viewport" content="width=device-width, initial-scale=1">
137
158
  <title>Connect to web3-tools-mcp</title>
138
159
  <style>${PAGE_STYLE}
139
160
  </style>
140
161
  <h1>Connect to web3-tools-mcp</h1>
162
+ ${request ? `<p><strong>${request.client}</strong> is asking for access, and will be sent back to <strong>${request.redirectHost}</strong>.</p>` : ''}
141
163
  <p>Paste this server's access token to authorise the client.</p>
142
164
  ${error ? `<p class="error">${error}</p>` : ''}
143
165
  <form method="post">
@@ -155,6 +177,8 @@ ${error ? `<p class="error">${error}</p>` : ''}
155
177
 
156
178
  export class OAuthProvider implements OAuthServerProvider {
157
179
  private store = new Store()
180
+ // A hash, so the secret itself never lands in the store.
181
+ private credential: string | undefined
158
182
 
159
183
  /**
160
184
  * `staticToken` is accepted verbatim in an Authorization header, for clients that can send
@@ -163,7 +187,9 @@ export class OAuthProvider implements OAuthServerProvider {
163
187
  constructor(
164
188
  private login: Login,
165
189
  private staticToken?: string
166
- ) {}
190
+ ) {
191
+ this.credential = login.credential && createHash('sha256').update(login.credential).digest('hex').slice(0, 16)
192
+ }
167
193
 
168
194
  get clientsStore(): OAuthRegisteredClientsStore {
169
195
  const store = this.store
@@ -203,11 +229,21 @@ export class OAuthProvider implements OAuthServerProvider {
203
229
  .map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
204
230
  .join('\n ')
205
231
 
232
+ const redirectTo = new URL(params.redirectUri)
233
+ const shown = {
234
+ client: escapeHtml(client.client_name || 'An unnamed client'),
235
+ redirectHost: escapeHtml(redirectTo.host || redirectTo.href)
236
+ }
237
+
238
+ // A login page that can be framed can be clickjacked into authorising somebody else.
239
+ res.setHeader('X-Frame-Options', 'DENY')
240
+ res.setHeader('Content-Security-Policy', "frame-ancestors 'none'")
241
+
206
242
  const render = async (status: number, error?: string) => {
207
243
  res
208
244
  .status(status)
209
245
  .setHeader('content-type', 'text/html; charset=utf-8')
210
- .send(await this.login.page(hidden, error ? escapeHtml(error) : undefined))
246
+ .send(await this.login.page(hidden, error ? escapeHtml(error) : undefined, shown))
211
247
  }
212
248
 
213
249
  if (request.method !== 'POST') return render(200)
@@ -216,32 +252,53 @@ export class OAuthProvider implements OAuthServerProvider {
216
252
  if (identified.error) return render(401, identified.error)
217
253
 
218
254
  const code = randomBytes(24).toString('hex')
219
- await this.store.set<StoredCode>(`code:${code}`, {
220
- clientId: client.client_id,
221
- codeChallenge: params.codeChallenge,
222
- redirectUri: params.redirectUri,
223
- resource: params.resource?.href,
224
- scopes: params.scopes ?? [],
225
- address: identified.address,
226
- expiresAt: Date.now() + CODE_TTL
227
- })
228
-
229
- const redirect = new URL(params.redirectUri)
230
- redirect.searchParams.set('code', code)
231
- if (params.state) redirect.searchParams.set('state', params.state)
232
- res.redirect(redirect.href)
255
+ await this.store.set<StoredCode>(
256
+ `code:${code}`,
257
+ {
258
+ clientId: client.client_id,
259
+ codeChallenge: params.codeChallenge,
260
+ redirectUri: params.redirectUri,
261
+ resource: params.resource?.href,
262
+ scopes: params.scopes ?? [],
263
+ address: identified.address,
264
+ credential: this.credential,
265
+ expiresAt: Date.now() + CODE_TTL
266
+ },
267
+ CODE_TTL / 1000
268
+ )
269
+
270
+ redirectTo.searchParams.set('code', code)
271
+ if (params.state) redirectTo.searchParams.set('state', params.state)
272
+ res.redirect(redirectTo.href)
233
273
  }
234
274
 
235
- async challengeForAuthorizationCode(_client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
275
+ // Refusals here must be InvalidGrantError: the SDK answers anything else with a 500,
276
+ // where a client needs the 400 invalid_grant to know it should start the flow again.
277
+ private async validCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<StoredCode> {
236
278
  const stored = await this.store.get<StoredCode>(`code:${authorizationCode}`)
237
- if (!stored || stored.expiresAt < Date.now()) throw new Error('Authorization code is invalid or expired')
238
- return stored.codeChallenge
279
+ if (!stored || stored.expiresAt < Date.now() || stored.credential !== this.credential) {
280
+ throw new InvalidGrantError('Authorization code is invalid or expired')
281
+ }
282
+ if (stored.clientId !== client.client_id) throw new InvalidGrantError('Authorization code was issued to a different client')
283
+ return stored
239
284
  }
240
285
 
241
- async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<OAuthTokens> {
242
- const stored = await this.store.get<StoredCode>(`code:${authorizationCode}`)
243
- if (!stored || stored.expiresAt < Date.now()) throw new Error('Authorization code is invalid or expired')
244
- if (stored.clientId !== client.client_id) throw new Error('Authorization code was issued to a different client')
286
+ async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
287
+ return (await this.validCode(client, authorizationCode)).codeChallenge
288
+ }
289
+
290
+ async exchangeAuthorizationCode(
291
+ client: OAuthClientInformationFull,
292
+ authorizationCode: string,
293
+ _codeVerifier?: string,
294
+ redirectUri?: string
295
+ ): Promise<OAuthTokens> {
296
+ const stored = await this.validCode(client, authorizationCode)
297
+ // Optional at the token endpoint because the SDK lets a single-URI client omit it at
298
+ // /authorize too; when sent, it must be the one the code was issued for.
299
+ if (redirectUri !== undefined && redirectUri !== stored.redirectUri) {
300
+ throw new InvalidGrantError('redirect_uri does not match the authorization request')
301
+ }
245
302
 
246
303
  // One use only: a replayed code must not mint a second token.
247
304
  await this.store.remove(`code:${authorizationCode}`)
@@ -250,10 +307,11 @@ export class OAuthProvider implements OAuthServerProvider {
250
307
 
251
308
  async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens> {
252
309
  const stored = await this.store.get<StoredToken>(`refresh:${refreshToken}`)
253
- if (!stored) throw new Error('Refresh token is invalid')
254
- if (stored.clientId !== client.client_id) throw new Error('Refresh token was issued to a different client')
310
+ if (!stored || stored.credential !== this.credential) throw new InvalidGrantError('Refresh token is invalid')
311
+ if (stored.clientId !== client.client_id) throw new InvalidGrantError('Refresh token was issued to a different client')
255
312
 
256
313
  await this.store.remove(`refresh:${refreshToken}`)
314
+ if (stored.expiresAt * 1000 < Date.now()) throw new InvalidGrantError('Refresh token has expired')
257
315
  // The refreshed token stays the same person's: a refresh proves possession, not identity.
258
316
  return this.issue(client.client_id, scopes ?? stored.scopes, stored.address)
259
317
  }
@@ -262,9 +320,10 @@ export class OAuthProvider implements OAuthServerProvider {
262
320
  const accessToken = randomBytes(32).toString('hex')
263
321
  const refreshToken = randomBytes(32).toString('hex')
264
322
  const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_TTL
323
+ const stored: StoredToken = { clientId, scopes, address, credential: this.credential, expiresAt }
265
324
 
266
- await this.store.set<StoredToken>(`token:${accessToken}`, { clientId, scopes, address, expiresAt })
267
- await this.store.set<StoredToken>(`refresh:${refreshToken}`, { clientId, scopes, address, expiresAt })
325
+ await this.store.set(`token:${accessToken}`, stored, TOKEN_TTL)
326
+ await this.store.set(`refresh:${refreshToken}`, stored, TOKEN_TTL)
268
327
 
269
328
  return {
270
329
  access_token: accessToken,
@@ -285,7 +344,7 @@ export class OAuthProvider implements OAuthServerProvider {
285
344
  // Must be InvalidTokenError specifically: the SDK turns anything else into a 500, and
286
345
  // a client needs the 401 to know it should authenticate again.
287
346
  const stored = await this.store.get<StoredToken>(`token:${token}`)
288
- if (!stored) throw new InvalidTokenError('Invalid access token')
347
+ if (!stored || stored.credential !== this.credential) throw new InvalidTokenError('Invalid access token')
289
348
  if (stored.expiresAt * 1000 < Date.now()) {
290
349
  await this.store.remove(`token:${token}`)
291
350
  throw new InvalidTokenError('Access token has expired')
package/src/preview.ts CHANGED
@@ -85,15 +85,17 @@ function stringify(value: unknown): string {
85
85
  * Decode calldata into labelled fields (clear signing). Token amounts on the standard
86
86
  * ERC20 selectors are formatted with on-chain decimals and unlimited approvals flagged.
87
87
  */
88
- async function decodeCalldata(chain: ChainName, tx: RawTx): Promise<TxPreview['decoded']> {
88
+ async function decodeCalldata(chain: ChainName, tx: RawTx, from?: string): Promise<TxPreview['decoded']> {
89
89
  if (!tx.data || tx.data === '0x') return undefined
90
90
 
91
91
  const { abi, source, proxy } = await loadAbi(chain, tx.to)
92
92
  const { functionName, args } = decodeFunctionData({ abi, data: tx.data as `0x${string}` })
93
93
 
94
- const abiItem = abi.find((item): item is AbiFunction => item.type === 'function' && item.name === functionName)
94
+ // By selector, not name: an overloaded name would otherwise pick the wrong inputs.
95
+ const selector = tx.data.slice(0, 10).toLowerCase()
96
+ const abiItem = abi.find((item): item is AbiFunction => item.type === 'function' && toFunctionSelector(item) === selector)
95
97
  const inputs = abiItem?.inputs ?? []
96
- const erc20 = ERC20_AMOUNT_ARGS[tx.data.slice(0, 10)]
98
+ const erc20 = ERC20_AMOUNT_ARGS[selector]
97
99
  const meta = erc20 ? await tokenMeta(chain, tx.to).catch(() => ({ symbol: undefined, decimals: undefined })) : undefined
98
100
 
99
101
  const fields: PreviewField[] = (args ?? []).map((value, i) => {
@@ -137,7 +139,7 @@ async function decodeCalldata(chain: ChainName, tx: RawTx): Promise<TxPreview['d
137
139
 
138
140
  // A registry descriptor says what the call means and how the protocol wants each field
139
141
  // labelled, which beats raw ABI argument names. Fall back to those when it has none.
140
- const clearSigning = await resolveClearSigning(chain, tx, named).catch(() => null)
142
+ const clearSigning = await resolveClearSigning(chain, { ...tx, from }, named).catch(() => null)
141
143
  if (clearSigning) {
142
144
  return {
143
145
  functionName,
@@ -256,7 +258,7 @@ export async function buildTxPreview(chain: ChainName, tx: RawTx, from?: string)
256
258
  const [decoded, simulation, toLabel] = await Promise.all([
257
259
  // A preview that cannot be built must not block the transaction, but swallowing the
258
260
  // reason makes "no details shown" impossible to diagnose.
259
- decodeCalldata(chain, tx).catch((error) => {
261
+ decodeCalldata(chain, tx, from).catch((error) => {
260
262
  console.error('[Preview] Could not decode calldata:', error instanceof Error ? error.message : error)
261
263
  return undefined
262
264
  }),
@@ -1,12 +1,15 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
2
  import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'
3
3
  import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js'
4
+ import { getClientManager } from '../client.js'
4
5
  import { identityFrom } from '../context.js'
5
6
  import type { ToolResult } from '../types.js'
7
+ import { redactSecrets } from '../utils.js'
6
8
  import advancedTools from './read/advanced.js'
7
9
  import balanceTools from './read/balance.js'
8
10
  import contractTools from './read/contract.js'
9
11
  import contractInfoTools from './read/contract-info.js'
12
+ import defiTools from './read/defi.js'
10
13
  import ensTools from './read/ens.js'
11
14
  import gasTools from './read/gas.js'
12
15
  import logTools from './read/logs.js'
@@ -34,6 +37,7 @@ const readTools = {
34
37
  ...advancedTools,
35
38
  ...ensTools,
36
39
  ...gasTools,
40
+ ...defiTools,
37
41
  // A signature already asked for and paid for; collecting it is not a second request.
38
42
  ...signingRequestTools
39
43
  } as const
@@ -85,7 +89,16 @@ export function registerAllTools(server: McpServer, middleware?: ToolMiddleware)
85
89
  const identity = identityFrom(extra.authInfo)
86
90
  const run = () => tool.handler(args, identity)
87
91
  const group = toolGroup(name) as ToolGroup
88
- return await (middleware ? middleware({ tool: name, group, identity }, run) : run())
92
+ // Anything a tool returns or throws may quote an RPC URL, and those carry provider keys.
93
+ const config = getClientManager().getConfig()
94
+ try {
95
+ const result = await (middleware ? middleware({ tool: name, group, identity }, run) : run())
96
+ return { ...result, content: result.content.map((item) => ({ ...item, text: redactSecrets(item.text, config) })) }
97
+ } catch (error) {
98
+ if (!(error instanceof Error)) throw new Error(redactSecrets(String(error), config))
99
+ error.message = redactSecrets(error.message, config)
100
+ throw error
101
+ }
89
102
  }
90
103
  )
91
104
  })
@@ -1,9 +1,37 @@
1
- import { type Address, decodeAbiParameters, isAddress, parseAbiParameters } from 'viem'
1
+ import {
2
+ type AbiFunction,
3
+ type Address,
4
+ decodeAbiParameters,
5
+ encodeFunctionData,
6
+ isAddress,
7
+ parseAbiItem,
8
+ parseAbiParameters
9
+ } from 'viem'
2
10
  import { z } from 'zod'
3
11
  import { isDebugNodeAvailable, simulateCallWithTrace, traceTransactionWithAnvil } from '../../anvil.js'
4
12
  import { getClientManager, SUPPORTED_CHAINS } from '../../client.js'
5
13
  import type { ChainName } from '../../types.js'
6
- import { createTool, formatResponse, summarizeTrace } from '../../utils.js'
14
+ import { convertArgumentsToTypes, createTool, formatResponse, rpcReason, summarizeTrace } from '../../utils.js'
15
+
16
+ /** Past these, a trace would crowd everything else out of the agent's context. */
17
+ const MAX_TRACE_CHARS = 50_000
18
+ const MAX_INPUT_CHARS = 10_000
19
+ const MAX_RECEIPT_LOGS = 100
20
+
21
+ const SUMMARIZE_DESCRIPTION =
22
+ 'Return a compact summary instead of the full trace: the path to the revert, or the top-level calls, with selectors only and no calldata.'
23
+
24
+ /** The trace itself if it fits, otherwise its JSON cut short. */
25
+ export function boundTrace(trace: unknown): { trace: unknown; truncated?: true } {
26
+ const json = JSON.stringify(trace) ?? ''
27
+ return json.length > MAX_TRACE_CHARS ? { trace: json.slice(0, MAX_TRACE_CHARS), truncated: true } : { trace }
28
+ }
29
+
30
+ const TRACERS = {
31
+ trace: { tracer: 'callTracer' },
32
+ prestate: { tracer: 'prestateTracer' },
33
+ stateDiff: { tracer: 'prestateTracer', tracerConfig: { diffMode: true } }
34
+ } as const
7
35
 
8
36
  export default {
9
37
  get_storage_at: createTool(
@@ -77,8 +105,8 @@ export default {
77
105
  const parsedTypes = parseAbiParameters([args.abiType])
78
106
  if (parsedTypes.length > 0) {
79
107
  const decoded = decodeAbiParameters(parsedTypes, storageValue) as readonly unknown[]
80
- decodedValue = decoded.length > 0 ? decoded[0] : null
81
- formattedValue = decodedValue ? String(decodedValue) : 'No data'
108
+ decodedValue = decoded[0]
109
+ formattedValue = decodedValue === undefined ? 'No data' : String(decodedValue)
82
110
  } else {
83
111
  decodedValue = null
84
112
  formattedValue = 'Invalid ABI type'
@@ -148,9 +176,11 @@ export default {
148
176
  chain: z.enum(SUPPORTED_CHAINS).describe('The blockchain network to use'),
149
177
  transactionHash: z.string().describe('Transaction hash to trace (0x prefixed)'),
150
178
  traceType: z
151
- .enum(['trace', 'vmTrace', 'stateDiff'])
179
+ .enum(['trace', 'prestate', 'stateDiff'])
152
180
  .optional()
153
- .describe('Trace type: "trace" (call tree, recommended), "vmTrace" (VM execution), "stateDiff" (state changes)')
181
+ .describe(
182
+ 'Trace type: "trace" (call tree, recommended), "prestate" (state of every touched account before the tx), "stateDiff" (state before and after, changed fields only)'
183
+ )
154
184
  .default('trace'),
155
185
  useAnvil: z
156
186
  .boolean()
@@ -159,11 +189,7 @@ export default {
159
189
  'Force tracing through the debug node (ANVIL_RPC_URL). Used automatically as a fallback when the RPC cannot trace.'
160
190
  )
161
191
  .default(false),
162
- summarize: z
163
- .boolean()
164
- .optional()
165
- .describe('Return a compact summary instead of full trace. Truncates hex data and flattens nested calls.')
166
- .default(false)
192
+ summarize: z.boolean().optional().describe(SUMMARIZE_DESCRIPTION).default(false)
167
193
  }),
168
194
  async (args) => {
169
195
  const clientManager = getClientManager()
@@ -176,24 +202,18 @@ export default {
176
202
  let traceResult: unknown = null
177
203
  let usedAnvil = false
178
204
 
179
- // Map trace type to tracer name
180
- const tracerMap: Record<string, 'callTracer' | 'prestateTracer' | 'stateDiffTracer'> = {
181
- trace: 'callTracer',
182
- vmTrace: 'prestateTracer',
183
- stateDiff: 'stateDiffTracer'
184
- }
185
- const tracer = tracerMap[args.traceType ?? 'trace'] ?? 'callTracer'
205
+ const tracerOptions = TRACERS[args.traceType ?? 'trace']
186
206
 
187
207
  // Try RPC tracing first (unless forceAnvil is true)
188
208
  if (!args.useAnvil) {
189
209
  try {
190
210
  traceResult = await client.request({
191
211
  method: 'debug_traceTransaction',
192
- params: [args.transactionHash, { tracer }]
212
+ params: [args.transactionHash, tracerOptions]
193
213
  })
194
214
  } catch (rpcError) {
195
215
  // RPC tracing failed, will try Anvil fallback
196
- const errorMessage = (rpcError as Error).message
216
+ const errorMessage = rpcReason(rpcError)
197
217
  if (
198
218
  errorMessage.includes('not supported') ||
199
219
  errorMessage.includes('not available') ||
@@ -222,11 +242,11 @@ export default {
222
242
  const forkUrl = clientManager.getRpcUrl(args.chain as ChainName)
223
243
  const blockNumber = transaction.blockNumber ?? 0n
224
244
 
225
- traceResult = await traceTransactionWithAnvil(forkUrl, args.transactionHash, blockNumber, tracer)
245
+ traceResult = await traceTransactionWithAnvil(forkUrl, args.transactionHash, blockNumber, tracerOptions.tracer)
226
246
  } catch (anvilError) {
227
247
  // Anvil tracing also failed
228
248
  traceResult = {
229
- error: `Anvil tracing failed: ${(anvilError as Error).message}`,
249
+ error: `Anvil tracing failed: ${rpcReason(anvilError)}`,
230
250
  rpcError:
231
251
  traceResult && typeof traceResult === 'object' && 'rpcError' in traceResult
232
252
  ? (traceResult as { rpcError: string }).rpcError
@@ -257,11 +277,20 @@ export default {
257
277
  const traceSummary =
258
278
  args.summarize && traceResult && typeof traceResult === 'object' && !isPlainError ? summarizeTrace(traceResult) : null
259
279
 
280
+ const replayNote =
281
+ usedAnvil && !isPlainError
282
+ ? 'Replayed on the debug node against the state at the end of the previous block: transactions before this one in the same block were not applied, so the trace can differ from what happened on chain.' +
283
+ (args.traceType === 'stateDiff' ? ' This replay shows the prestate only, not a diff.' : '')
284
+ : undefined
285
+ const bounded = boundTrace(traceResult)
286
+ const input = transaction.input ?? '0x'
287
+
260
288
  const result =
261
289
  args.summarize && traceSummary
262
290
  ? {
263
291
  chain: args.chain,
264
292
  transactionHash: args.transactionHash,
293
+ ...(replayNote && { note: replayNote }),
265
294
  status: receipt.status,
266
295
  gasUsed: receipt.gasUsed?.toString(),
267
296
  ...traceSummary // { hasError, errorPath, summary }
@@ -272,6 +301,10 @@ export default {
272
301
  transactionHash: args.transactionHash,
273
302
  traceType: args.traceType,
274
303
  usedAnvil,
304
+ ...(replayNote && { note: replayNote }),
305
+ ...((bounded.truncated || input.length > MAX_INPUT_CHARS || receipt.logs.length > MAX_RECEIPT_LOGS) && {
306
+ truncated: true
307
+ }),
275
308
  transaction: {
276
309
  blockNumber: transaction.blockNumber?.toString(),
277
310
  from: transaction.from,
@@ -280,19 +313,20 @@ export default {
280
313
  gas: transaction.gas?.toString() || '0',
281
314
  gasPrice: transaction.gasPrice?.toString() || '0',
282
315
  nonce: transaction.nonce?.toString() || '0',
283
- input: transaction.input
316
+ input: input.length > MAX_INPUT_CHARS ? `${input.slice(0, MAX_INPUT_CHARS)}…` : input
284
317
  },
285
318
  receipt: {
286
319
  status: receipt.status,
287
320
  gasUsed: receipt.gasUsed?.toString() || '0',
288
321
  effectiveGasPrice: receipt.effectiveGasPrice?.toString() || '0',
289
- logs: receipt.logs.map((log) => ({
322
+ logCount: receipt.logs.length,
323
+ logs: receipt.logs.slice(0, MAX_RECEIPT_LOGS).map((log) => ({
290
324
  address: log.address,
291
325
  topics: log.topics,
292
326
  data: log.data
293
327
  }))
294
328
  },
295
- trace: traceResult
329
+ trace: bounded.trace
296
330
  }
297
331
 
298
332
  return formatResponse(result)
@@ -322,11 +356,7 @@ export default {
322
356
  .optional()
323
357
  .default('callTracer')
324
358
  .describe('Trace type: callTracer (call tree) or prestateTracer (state before execution)'),
325
- summarize: z
326
- .boolean()
327
- .optional()
328
- .describe('Return a compact summary instead of full trace. Truncates hex data and flattens nested calls.')
329
- .default(false)
359
+ summarize: z.boolean().optional().describe(SUMMARIZE_DESCRIPTION).default(false)
330
360
  }),
331
361
  async (args) => {
332
362
  // Check if Anvil is installed
@@ -342,15 +372,14 @@ export default {
342
372
  let calldata = args.data
343
373
  if (args.functionAbi && !calldata) {
344
374
  try {
345
- const { encodeFunctionData, parseAbiItem } = await import('viem')
346
- const abiItem = parseAbiItem(args.functionAbi)
375
+ const abiItem = parseAbiItem(args.functionAbi) as AbiFunction
347
376
  if (abiItem.type !== 'function') {
348
377
  throw new Error('ABI must be a function signature')
349
378
  }
350
379
  calldata = encodeFunctionData({
351
380
  abi: [abiItem],
352
381
  functionName: abiItem.name,
353
- args: (args.args ?? []) as readonly unknown[]
382
+ args: convertArgumentsToTypes(args.args ?? [], abiItem.inputs)
354
383
  })
355
384
  } catch (encodeError) {
356
385
  throw new Error(`Failed to encode function call: ${(encodeError as Error).message}`)
@@ -406,7 +435,7 @@ export default {
406
435
  result: traceResult.result,
407
436
  gasUsed: traceResult.gasUsed.toString(),
408
437
  revertReason: traceResult.revertReason,
409
- trace: traceResult.trace
438
+ ...boundTrace(traceResult.trace)
410
439
  }
411
440
 
412
441
  return formatResponse(result)
@@ -1,8 +1,9 @@
1
1
  import { type Address, formatEther, formatUnits, isAddress } from 'viem'
2
2
  import { z } from 'zod'
3
+ import { tokenMeta } from '../../chain-meta.js'
3
4
  import { getClientManager, SUPPORTED_CHAINS } from '../../client.js'
4
5
  import type { ChainName } from '../../types.js'
5
- import { createTool, formatResponse } from '../../utils.js'
6
+ import { createTool, formatResponse, MAX_BATCH, rpcReason } from '../../utils.js'
6
7
 
7
8
  const BalanceQuerySchema = z.object({
8
9
  chain: z.enum(SUPPORTED_CHAINS).describe('The blockchain network to use'),
@@ -19,6 +20,7 @@ export default {
19
20
  z.object({
20
21
  queries: z
21
22
  .array(BalanceQuerySchema)
23
+ .max(MAX_BATCH)
22
24
  .describe('Array of balance queries. Omit tokenAddress for native balance, include for ERC20.')
23
25
  }),
24
26
  async (args) => {
@@ -42,7 +44,7 @@ export default {
42
44
  success: boolean
43
45
  balance?: unknown
44
46
  balanceFormatted?: string
45
- decimals?: number
47
+ decimals?: number | null
46
48
  error?: string
47
49
  }> = []
48
50
 
@@ -86,42 +88,24 @@ export default {
86
88
  throw new Error(`Invalid token address format: ${query.tokenAddress}`)
87
89
  }
88
90
 
89
- const balance = await client.readContract({
90
- address: query.tokenAddress as Address,
91
- abi: [
92
- {
93
- name: 'balanceOf',
94
- type: 'function',
95
- stateMutability: 'view',
96
- inputs: [{ name: 'account', type: 'address' }],
97
- outputs: [{ name: '', type: 'uint256' }]
98
- }
99
- ],
100
- functionName: 'balanceOf',
101
- args: [query.address as Address],
102
- blockNumber: blockTag === 'latest' ? undefined : blockTag
103
- })
104
-
105
- // Try to get decimals
106
- let decimals = 18
107
- try {
108
- decimals = (await client.readContract({
91
+ const [balance, { decimals }] = await Promise.all([
92
+ client.readContract({
109
93
  address: query.tokenAddress as Address,
110
94
  abi: [
111
95
  {
112
- name: 'decimals',
96
+ name: 'balanceOf',
113
97
  type: 'function',
114
98
  stateMutability: 'view',
115
- inputs: [],
116
- outputs: [{ name: '', type: 'uint8' }]
99
+ inputs: [{ name: 'account', type: 'address' }],
100
+ outputs: [{ name: '', type: 'uint256' }]
117
101
  }
118
102
  ],
119
- functionName: 'decimals',
103
+ functionName: 'balanceOf',
104
+ args: [query.address as Address],
120
105
  blockNumber: blockTag === 'latest' ? undefined : blockTag
121
- })) as number
122
- } catch {
123
- // Use default decimals if call fails
124
- }
106
+ }),
107
+ tokenMeta(chain as ChainName, query.tokenAddress).catch(() => ({ decimals: undefined }))
108
+ ])
125
109
 
126
110
  return {
127
111
  index: originalIndex,
@@ -132,8 +116,9 @@ export default {
132
116
  blockNumber: query.blockNumber || 'latest',
133
117
  success: true,
134
118
  balance,
135
- balanceFormatted: formatUnits(balance as bigint, decimals),
136
- decimals
119
+ // Unknown decimals leave only the raw balance: guessing 18 could be off by 10^12.
120
+ balanceFormatted: decimals === undefined ? undefined : formatUnits(balance, decimals),
121
+ decimals: decimals ?? null
137
122
  }
138
123
  })
139
124
  )
@@ -153,7 +138,7 @@ export default {
153
138
  chain: query.chain,
154
139
  blockNumber: query.blockNumber || 'latest',
155
140
  success: false,
156
- error: result.reason instanceof Error ? result.reason.message : String(result.reason)
141
+ error: rpcReason(result.reason)
157
142
  }
158
143
  }
159
144
  })