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
@@ -52,12 +52,18 @@ export default {
52
52
  }
53
53
 
54
54
  if (request.state === 'failed') {
55
+ // Only an explicit rejection proves nothing was signed. A timeout or a dropped
56
+ // connection can land after the wallet signed and broadcast.
57
+ const rejected = request.stage === 'approval' && /rejected|denied/i.test(request.error ?? '')
55
58
  return formatResponse({
56
59
  success: false,
57
- status: 'rejected_or_failed',
60
+ status: rejected ? 'rejected' : 'outcome_unknown',
58
61
  what: request.summary,
59
62
  error: request.error,
60
- message: 'Nothing was signed. The wallet rejected it, or it expired before anybody approved.'
63
+ ...(request.txHash && { transactionHash: request.txHash }),
64
+ message: rejected
65
+ ? 'The wallet rejected it. Nothing was signed.'
66
+ : 'It did not complete here, but it may still have been signed or broadcast. Do not retry until the wallet or a block explorer shows it did not go through.'
61
67
  })
62
68
  }
63
69
 
@@ -1,12 +1,13 @@
1
1
  import { randomBytes } from 'node:crypto'
2
2
  import { type AbiFunction, type Address, encodeFunctionData, isAddress, parseAbiItem, parseUnits, toHex } from 'viem'
3
3
  import { z } from 'zod'
4
+ import { tokenMeta } from '../../chain-meta.js'
4
5
  import { getClientManager, SUPPORTED_CHAINS } from '../../client.js'
5
6
  import { log } from '../../log.js'
6
7
  import { buildTxPreview, type RawTx, type TxPreview } from '../../preview.js'
7
8
  import { type SigningProgress, type SigningRequest, signingCall } from '../../signing-requests.js'
8
9
  import type { ChainName } from '../../types.js'
9
- import { createTool, formatResponse } from '../../utils.js'
10
+ import { convertArgumentsToTypes, createTool, formatResponse } from '../../utils.js'
10
11
  import { getWalletClient } from '../../wallet-client.js'
11
12
  import { getPhoneSigner } from '../../walletconnect.js'
12
13
  import { failure } from './errors.js'
@@ -74,6 +75,33 @@ async function requirePhoneSession(identity: string, account?: string) {
74
75
  return { phone, session, from }
75
76
  }
76
77
 
78
+ /** The phone's wallet reaches chains through its own RPC, which cannot see this machine's node. */
79
+ function requireReachable(chain: ChainName, signWith: SignWith) {
80
+ if (chain === 'localhost' && signWith === 'phone') {
81
+ throw new Error('A phone wallet cannot sign on localhost: it has no route to this machine\'s node. Use signWith "browser".')
82
+ }
83
+ }
84
+
85
+ /**
86
+ * The token's own decimals. A caller-supplied figure is only a cross-check: trusting a wrong
87
+ * one moves orders of magnitude more or less than was asked for.
88
+ */
89
+ async function tokenDecimals(chain: ChainName, token: Address, claimed?: number): Promise<number> {
90
+ const onChain = (await tokenMeta(chain, token).catch(() => undefined))?.decimals
91
+ if (onChain === undefined) {
92
+ if (claimed === undefined) {
93
+ throw new Error(
94
+ `Could not read decimals() from ${token} on ${chain}. Check it is an ERC20 token on this chain, or pass "decimals" explicitly.`
95
+ )
96
+ }
97
+ return claimed
98
+ }
99
+ if (claimed !== undefined && claimed !== onChain) {
100
+ throw new Error(`"decimals" was ${claimed}, but ${token} reports ${onChain} on ${chain}. Nothing was sent.`)
101
+ }
102
+ return onChain
103
+ }
104
+
77
105
  /** Long enough for a congested chain, short enough that a stuck one is reported rather than hung on. */
78
106
  const RECEIPT_TIMEOUT = 180_000
79
107
 
@@ -140,6 +168,7 @@ async function requestSignature(
140
168
  progress: SigningProgress
141
169
  ) {
142
170
  log('info', 'Transaction', `${chain} → ${tx.to}, signing with ${signWith}`)
171
+ requireReachable(chain, signWith)
143
172
  if (signWith === 'phone') return signOnPhone(chain, tx, identity, account, progress)
144
173
 
145
174
  const wallet = getWalletClient(identity)
@@ -212,6 +241,8 @@ function previewSummary(preview: TxPreview) {
212
241
  return {
213
242
  action: preview.decoded?.intent ?? preview.decoded?.functionName,
214
243
  protocol: preview.decoded?.protocol ?? preview.toLabel,
244
+ // Labels come from the contracts themselves, so the address they belong to goes too.
245
+ to: preview.to,
215
246
  details: preview.decoded?.fields.map((f) => `${f.name}: ${f.value}${f.warning ? ` (${f.warning})` : ''}`),
216
247
  simulation: preview.simulation
217
248
  }
@@ -273,7 +304,11 @@ export default {
273
304
  tokenAddress: z.string().describe('ERC20 token contract address'),
274
305
  to: z.string().describe('Recipient address'),
275
306
  amount: z.string().describe('Amount in token units (e.g., "100" for 100 USDC)'),
276
- decimals: z.number().optional().default(18).describe('Token decimals (default: 18)'),
307
+ decimals: z
308
+ .number()
309
+ .int()
310
+ .optional()
311
+ .describe('Token decimals. Read from the token when omitted; when given, must match what the token reports.'),
277
312
  signWith: SignWithSchema,
278
313
  account: AccountSchema
279
314
  }),
@@ -281,10 +316,11 @@ export default {
281
316
  try {
282
317
  const tokenAddress = requireAddress('token address', args.tokenAddress)
283
318
  const to = requireAddress('recipient address', args.to)
319
+ const decimals = await tokenDecimals(args.chain as ChainName, tokenAddress, args.decimals)
284
320
 
285
321
  const data = encodeFunctionData({
286
322
  abi: [parseAbiItem('function transfer(address to, uint256 amount)')],
287
- args: [to, parseUnits(args.amount, args.decimals ?? 18)]
323
+ args: [to, parseUnits(args.amount, decimals)]
288
324
  })
289
325
 
290
326
  const outcome = await signingCall(
@@ -327,9 +363,19 @@ export default {
327
363
  contractAddress: z.string().describe('Contract address'),
328
364
  functionAbi: z.string().describe('Function ABI definition (e.g., "function transfer(address to, uint256 amount)")'),
329
365
  args: z
330
- .array(z.union([z.string(), z.number(), z.boolean()]))
366
+ .array(
367
+ z.union([
368
+ z.string(),
369
+ // JSON numbers past 2^53 arrive already rounded, so signing one would send a different value.
370
+ z.number().refine(Number.isSafeInteger, {
371
+ message:
372
+ 'Numbers must be integers within ±2^53-1. Pass large or exact values as strings, e.g. "1000000000000000000".'
373
+ }),
374
+ z.boolean()
375
+ ])
376
+ )
331
377
  .optional()
332
- .describe('Function arguments in order matching the ABI signature'),
378
+ .describe('Function arguments in order matching the ABI signature. Pass uint/int values as strings.'),
333
379
  value: z.string().optional().describe('Optional native value to send with transaction (in ETH units, e.g., "0.1")'),
334
380
  signWith: SignWithSchema,
335
381
  account: AccountSchema
@@ -342,7 +388,7 @@ export default {
342
388
  const data = encodeFunctionData({
343
389
  abi: [abiItem],
344
390
  functionName: abiItem.name,
345
- args: (args.args || []) as readonly unknown[]
391
+ args: convertArgumentsToTypes(args.args || [], abiItem.inputs)
346
392
  })
347
393
  const value = args.value ? `0x${parseUnits(args.value, 18).toString(16)}` : '0x0'
348
394
 
@@ -389,8 +435,11 @@ export default {
389
435
  try {
390
436
  const sign = async (progress: SigningProgress) => {
391
437
  if (args.signWith === 'phone') {
392
- const { phone, from } = await requirePhoneSession(identity, args.account)
393
- return phone.request('mainnet', 'personal_sign', [toHex(args.message), from], {
438
+ const { phone, session, from } = await requirePhoneSession(identity, args.account)
439
+ // personal_sign is chain-agnostic, but the request still has to name a chain the session holds.
440
+ const chain = SUPPORTED_CHAINS.find((c) => session.chains.includes(getClientManager().getChainId(c)))
441
+ if (!chain) throw new Error(`The paired wallet approved no chain this server knows: ${session.chains.join(', ')}.`)
442
+ return phone.request(chain, 'personal_sign', [toHex(args.message), from], {
394
443
  identity,
395
444
  account: args.account,
396
445
  onWaiting: progress.waiting
@@ -403,7 +452,9 @@ export default {
403
452
  id: generateRequestId(),
404
453
  type: 'sign_message',
405
454
  chain: 'any',
406
- data: { message: args.message }
455
+ // Hex on both signers: a wallet reads a raw "0x…" string as bytes, so the same text
456
+ // would otherwise sign differently depending on where it was approved.
457
+ data: { message: toHex(args.message) }
407
458
  })
408
459
  }
409
460
 
package/src/types.ts CHANGED
@@ -11,7 +11,6 @@ export interface AbiError {
11
11
  export interface Config {
12
12
  etherscanApiKey?: string
13
13
  alchemyApiKey?: string
14
- infuraApiKey?: string
15
14
  customRpcUrls?: Record<string, string>
16
15
  hypersyncApiKey?: string
17
16
  walletConnectProjectId?: string
package/src/utils.ts CHANGED
@@ -136,11 +136,13 @@ function findErrorInTrace(trace: unknown): { errorPath: SummarizedCall[]; errorM
136
136
  }
137
137
 
138
138
  /**
139
- * Coerce a JSON value into what viem expects for an ABI type. Tuples, arrays and anything
139
+ * Coerce a JSON value into what viem expects for an ABI type. A list is coerced element by
140
+ * element — an array argument, or an OR filter on an event argument. Tuples and anything
140
141
  * else pass through: viem validates them, and guessing here would only mangle them.
141
142
  */
142
143
  function coerce(value: unknown, type: string): unknown {
143
144
  if (value === null || value === undefined) return null
145
+ if (Array.isArray(value)) return value.map((item) => coerce(item, type))
144
146
 
145
147
  if (type === 'address' || type === 'string' || type.startsWith('bytes')) {
146
148
  return String(value)
@@ -195,6 +197,50 @@ export function convertEventArgsToTypes(
195
197
  )
196
198
  }
197
199
 
200
+ /** One tool call is one quota unit, so an unbounded batch would be a way around the quota. */
201
+ export const MAX_BATCH = 25
202
+
203
+ /**
204
+ * Why an RPC call failed, without the request URL — viem puts it in `message`, and with a
205
+ * provider key configured that URL carries the key.
206
+ */
207
+ export function rpcReason(error: unknown): string {
208
+ const { details, shortMessage } = error as { details?: string; shortMessage?: string }
209
+ return details || shortMessage || (error instanceof Error ? error.message : String(error))
210
+ }
211
+
212
+ /** Every configured key blanked out of `text`: RPC URLs carry them, and errors quote the URL. */
213
+ export function redactSecrets(text: string, config: Config): string {
214
+ return [config.alchemyApiKey, config.etherscanApiKey, config.hypersyncApiKey].reduce<string>(
215
+ (out, secret) => (secret ? out.replaceAll(secret, '[redacted]') : out),
216
+ text
217
+ )
218
+ }
219
+
220
+ /** A Map whose entries expire after `ttlMs`, dropping the oldest once it holds `maxSize`. */
221
+ export class TtlCache<V> {
222
+ private entries = new Map<string, { value: V; expires: number }>()
223
+
224
+ constructor(
225
+ private maxSize: number,
226
+ private ttlMs: number
227
+ ) {}
228
+
229
+ get(key: string): V | undefined {
230
+ const entry = this.entries.get(key)
231
+ if (!entry) return undefined
232
+ if (entry.expires > Date.now()) return entry.value
233
+ this.entries.delete(key)
234
+ return undefined
235
+ }
236
+
237
+ set(key: string, value: V): void {
238
+ this.entries.delete(key)
239
+ this.entries.set(key, { value, expires: Date.now() + this.ttlMs })
240
+ if (this.entries.size > this.maxSize) this.entries.delete(this.entries.keys().next().value as string)
241
+ }
242
+ }
243
+
198
244
  // Format response as tool result
199
245
  export function formatResponse(data: unknown): ToolResult {
200
246
  const serializable = convertBigIntToString(data)
@@ -231,7 +277,6 @@ export function parseCommandLineArgs(): Config & { showHelp?: boolean } {
231
277
  // Parse environment variables and command line arguments
232
278
  config.etherscanApiKey = process.env.ETHERSCAN_API_KEY || getArgValue('--etherscan-api-key')
233
279
  config.alchemyApiKey = process.env.ALCHEMY_API_KEY || getArgValue('--alchemy-api-key')
234
- config.infuraApiKey = process.env.INFURA_API_KEY || getArgValue('--infura-api-key')
235
280
  config.hypersyncApiKey = process.env.HYPERSYNC_API_KEY || getArgValue('--hypersync-api-key')
236
281
  config.walletConnectProjectId = process.env.WALLETCONNECT_PROJECT_ID || getArgValue('--walletconnect-project-id')
237
282
 
@@ -3,12 +3,26 @@ import { randomBytes } from 'node:crypto'
3
3
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
4
4
  import { homedir } from 'node:os'
5
5
  import { join } from 'node:path'
6
- import { LOCAL_PORT_ATTEMPTS, roomToken, type TransactionRequest, type TransactionResponse, WalletRelay } from 'web3-wallet-relay'
6
+ import {
7
+ LOCAL_PORT_ATTEMPTS,
8
+ roomToken,
9
+ SIGN_PATH,
10
+ type TransactionRequest,
11
+ type TransactionResponse,
12
+ WalletRelay
13
+ } from 'web3-wallet-relay'
7
14
  import { WebSocket } from 'ws'
8
15
  import { HOSTED } from './hosted.js'
9
16
 
10
17
  const REQUEST_TIMEOUT = 300_000
11
18
 
19
+ /**
20
+ * Past this point the page may have signed and broadcast without the answer reaching us, so
21
+ * a failure is not a rejection: retrying blindly could send the same thing twice.
22
+ */
23
+ const OUTCOME_UNKNOWN =
24
+ 'The outcome is unknown — it may still have been signed or broadcast. Check the wallet or a block explorer before retrying.'
25
+
12
26
  /**
13
27
  * How long a client with nothing in flight keeps its socket. A hosted server makes one per
14
28
  * person who ever signed, and without this each would hold a relay connection for the life
@@ -19,7 +33,7 @@ const IDLE_TIMEOUT = 10 * 60_000
19
33
  /** Always the signer token: this URL goes to a browser, which may not act as a requester. */
20
34
  function pairingUrlFor(port: number, secret: string, identity: string): string {
21
35
  const base = HOSTED ? (process.env.MCP_PUBLIC_URL ?? `http://localhost:${port}`) : `http://127.0.0.1:${port}`
22
- return `${base.replace(/\/$/, '')}/#t=${roomToken(secret, identity, 'signer')}`
36
+ return `${base.replace(/\/$/, '')}${SIGN_PATH}#t=${roomToken(secret, identity, 'signer')}`
23
37
  }
24
38
  const SIGNER_WAIT_TIMEOUT = 30_000
25
39
  const CONNECT_ATTEMPTS = 8
@@ -94,6 +108,7 @@ export class WalletClient {
94
108
  /** Instance fields so tests can shorten the waits instead of sitting out the real ones. */
95
109
  private signerWaitTimeout = SIGNER_WAIT_TIMEOUT
96
110
  private idleTimeout = IDLE_TIMEOUT
111
+ private requestTimeout = REQUEST_TIMEOUT
97
112
 
98
113
  private readonly remoteUrl = process.env.WALLET_SERVER_URL
99
114
  private readonly remoteToken = process.env.WALLET_TOKEN
@@ -234,6 +249,10 @@ export class WalletClient {
234
249
  this.signerAddress = undefined
235
250
  this.pageUrl = undefined
236
251
  this.pageAgent = undefined
252
+ for (const [id, pending] of this.pending) {
253
+ this.pending.delete(id)
254
+ pending.reject(new Error(`Lost the connection to the wallet relay before the wallet answered. ${OUTCOME_UNKNOWN}`))
255
+ }
237
256
  })
238
257
  })
239
258
  }
@@ -325,14 +344,16 @@ export class WalletClient {
325
344
  return new Promise((resolve, reject) => {
326
345
  this.pending.set(request.id, { resolve, reject })
327
346
  this.rearmIdle()
328
- ws.send(JSON.stringify(request))
347
+ ws.send(JSON.stringify({ ...request, expiresAt: Date.now() + this.requestTimeout }))
329
348
 
330
349
  setTimeout(() => {
331
350
  if (this.pending.delete(request.id)) {
332
351
  this.rearmIdle()
333
- reject(new Error('Transaction request timed out'))
352
+ // So the page stops offering it; its own expiresAt covers a cancel that never lands.
353
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'cancel', id: request.id }))
354
+ reject(new Error(`Wallet request timed out. ${OUTCOME_UNKNOWN}`))
334
355
  }
335
- }, REQUEST_TIMEOUT)
356
+ }, this.requestTimeout)
336
357
  })
337
358
  }
338
359