web3-tools-mcp 1.4.0 → 1.6.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 (54) hide show
  1. package/README.md +106 -7
  2. package/dist/chain-meta.d.ts +27 -0
  3. package/dist/chain-meta.d.ts.map +1 -0
  4. package/dist/chain-meta.js +92 -0
  5. package/dist/chain-meta.js.map +1 -0
  6. package/dist/clear-signing.d.ts +62 -0
  7. package/dist/clear-signing.d.ts.map +1 -0
  8. package/dist/clear-signing.js +139 -0
  9. package/dist/clear-signing.js.map +1 -0
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +4 -2
  12. package/dist/client.js.map +1 -1
  13. package/dist/erc7730-index.json.gz +0 -0
  14. package/dist/http-server.d.ts +12 -0
  15. package/dist/http-server.d.ts.map +1 -0
  16. package/dist/http-server.js +74 -0
  17. package/dist/http-server.js.map +1 -0
  18. package/dist/index.js +37 -13
  19. package/dist/index.js.map +1 -1
  20. package/dist/kv-storage.d.ts +23 -0
  21. package/dist/kv-storage.d.ts.map +1 -0
  22. package/dist/kv-storage.js +69 -0
  23. package/dist/kv-storage.js.map +1 -0
  24. package/dist/preview.d.ts +3 -0
  25. package/dist/preview.d.ts.map +1 -1
  26. package/dist/preview.js +46 -87
  27. package/dist/preview.js.map +1 -1
  28. package/dist/tools/transactions.d.ts +16 -2
  29. package/dist/tools/transactions.d.ts.map +1 -1
  30. package/dist/tools/transactions.js +128 -18
  31. package/dist/tools/transactions.js.map +1 -1
  32. package/dist/types.d.ts +1 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/utils.d.ts +2 -7
  35. package/dist/utils.d.ts.map +1 -1
  36. package/dist/utils.js +1 -0
  37. package/dist/utils.js.map +1 -1
  38. package/dist/walletconnect.d.ts +31 -0
  39. package/dist/walletconnect.d.ts.map +1 -0
  40. package/dist/walletconnect.js +128 -0
  41. package/dist/walletconnect.js.map +1 -0
  42. package/package.json +9 -4
  43. package/src/chain-meta.ts +102 -0
  44. package/src/clear-signing.ts +206 -0
  45. package/src/client.ts +4 -2
  46. package/src/erc7730-index.json.gz +0 -0
  47. package/src/http-server.ts +94 -0
  48. package/src/index.ts +38 -13
  49. package/src/kv-storage.ts +83 -0
  50. package/src/preview.ts +49 -94
  51. package/src/tools/transactions.ts +146 -20
  52. package/src/types.ts +1 -0
  53. package/src/utils.ts +4 -17
  54. package/src/walletconnect.ts +157 -0
package/src/preview.ts CHANGED
@@ -9,8 +9,9 @@ import {
9
9
  toFunctionSelector
10
10
  } from 'viem'
11
11
  import { simulateBlocks } from 'viem/actions'
12
- import { whatsabi } from '@shazow/whatsabi'
13
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'
14
15
  import type { ChainName } from './types.js'
15
16
 
16
17
  export interface PreviewField {
@@ -46,6 +47,9 @@ export interface TxPreview {
46
47
  functionName: string
47
48
  signature: string
48
49
  fields: PreviewField[]
50
+ /** From the ERC-7730 registry: what this call means, and whose contract it is. */
51
+ intent?: string
52
+ protocol?: string
49
53
  /** 'verified' = ABI from Sourcify/Etherscan, 'guessed' = selector lookup on bytecode */
50
54
  source: 'verified' | 'guessed'
51
55
  proxy?: string
@@ -64,14 +68,7 @@ export interface RawTx {
64
68
  value?: string
65
69
  }
66
70
 
67
- const ERC20_META_ABI = [
68
- parseAbiItem('function decimals() view returns (uint8)'),
69
- parseAbiItem('function symbol() view returns (string)')
70
- ]
71
-
72
71
  const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
73
- const MAX_UINT256 = (1n << 256n) - 1n
74
- const UNLIMITED_THRESHOLD = MAX_UINT256 / 2n
75
72
 
76
73
  // Selectors whose amount argument is denominated in the token at tx.to.
77
74
  const ERC20_AMOUNT_ARGS: Record<string, { arg: string; approval?: boolean }> = {
@@ -80,89 +77,6 @@ const ERC20_AMOUNT_ARGS: Record<string, { arg: string; approval?: boolean }> = {
80
77
  [toFunctionSelector('function transferFrom(address,address,uint256)')]: { arg: 'amount' }
81
78
  }
82
79
 
83
- const abiCache = new Map<string, { abi: Abi; source: 'verified' | 'guessed'; proxy?: string; name?: string }>()
84
- const labelCache = new Map<string, string | undefined>()
85
- const metaCache = new Map<string, { symbol?: string; decimals?: number }>()
86
-
87
- async function loadAbi(chain: ChainName, address: string) {
88
- const key = `${chain}:${address.toLowerCase()}`
89
- const cached = abiCache.get(key)
90
- if (cached) return cached
91
-
92
- const clientManager = getClientManager()
93
- const client = clientManager.getClient(chain)
94
- const etherscanApiKey = clientManager.getConfig().etherscanApiKey
95
-
96
- const loaders: whatsabi.loaders.ABILoader[] = [new whatsabi.loaders.SourcifyABILoader({ chainId: clientManager.getChainId(chain) })]
97
- if (etherscanApiKey) {
98
- loaders.push(new whatsabi.loaders.EtherscanV2ABILoader({ apiKey: etherscanApiKey, chainId: clientManager.getChainId(chain) }))
99
- }
100
-
101
- const result = await whatsabi.autoload(address as Address, {
102
- provider: client,
103
- abiLoader: new whatsabi.loaders.MultiABILoader(loaders),
104
- signatureLookup: new whatsabi.loaders.OpenChainSignatureLookup(),
105
- followProxies: true
106
- })
107
-
108
- // Bytecode-guessed ABIs carry no argument names; verified ones do.
109
- const source = result.abi.some((item) => item.type === 'function' && item.inputs?.some((i) => i.name)) ? 'verified' : 'guessed'
110
- const loaded = {
111
- abi: result.abi as Abi,
112
- source: source as 'verified' | 'guessed',
113
- proxy: result.address !== address ? result.address : undefined,
114
- name: result.contractResult?.name ?? undefined
115
- }
116
- abiCache.set(key, loaded)
117
- return loaded
118
- }
119
-
120
- async function tokenMeta(chain: ChainName, token: string) {
121
- const key = `${chain}:${token.toLowerCase()}`
122
- const cached = metaCache.get(key)
123
- if (cached) return cached
124
-
125
- const client = getClientManager().getClient(chain)
126
- const [decimals, symbol] = await client.multicall({
127
- contracts: [
128
- { address: token as Address, abi: ERC20_META_ABI, functionName: 'decimals' },
129
- { address: token as Address, abi: ERC20_META_ABI, functionName: 'symbol' }
130
- ],
131
- ...(chain === 'localhost' && { deployless: true })
132
- })
133
-
134
- const meta = {
135
- decimals: decimals.status === 'success' ? Number(decimals.result) : undefined,
136
- symbol: symbol.status === 'success' ? (symbol.result as string) : undefined
137
- }
138
- metaCache.set(key, meta)
139
- return meta
140
- }
141
-
142
- /**
143
- * Human label for an address: token symbol first (more recognisable than the contract
144
- * name — "USDC" beats "FiatTokenProxy"), then the verified contract name. EOAs get none,
145
- * and the bytecode check keeps us from asking explorers about plain wallets.
146
- */
147
- async function addressLabel(chain: ChainName, address: string): Promise<string | undefined> {
148
- const key = `${chain}:${address.toLowerCase()}`
149
- if (labelCache.has(key)) return labelCache.get(key)
150
-
151
- let label: string | undefined
152
- try {
153
- const code = await getClientManager().getClient(chain).getBytecode({ address: address as Address })
154
- if (code && code !== '0x') {
155
- label = (await tokenMeta(chain, address).catch(() => ({ symbol: undefined }))).symbol
156
- if (!label) label = (await loadAbi(chain, address)).name
157
- }
158
- } catch {
159
- // Unknown address — show it bare rather than failing the preview.
160
- }
161
-
162
- labelCache.set(key, label)
163
- return label
164
- }
165
-
166
80
  function stringify(value: unknown): string {
167
81
  if (typeof value === 'bigint') return value.toString()
168
82
  if (Array.isArray(value)) return `[${value.map(stringify).join(', ')}]`
@@ -222,6 +136,34 @@ async function decodeCalldata(chain: ChainName, tx: RawTx): Promise<TxPreview['d
222
136
  ? `${functionName}(${inputs.map((i) => `${i.type}${i.name ? ` ${i.name}` : ''}`).join(', ')})`
223
137
  : functionName
224
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
+
225
167
  return { functionName, signature, fields, source, proxy }
226
168
  }
227
169
 
@@ -302,9 +244,22 @@ export async function buildTxPreview(chain: ChainName, tx: RawTx, from?: string)
302
244
  const value = tx.value ? BigInt(tx.value).toString() : undefined
303
245
 
304
246
  const [decoded, simulation, toLabel] = await Promise.all([
305
- decodeCalldata(chain, tx).catch(() => undefined),
306
- from ? simulate(chain, tx, from as Address).catch(() => undefined) : Promise.resolve(undefined),
307
- addressLabel(chain, tx.to).catch(() => undefined)
247
+ // A preview that cannot be built must not block the transaction, but swallowing the
248
+ // reason makes "no details shown" impossible to diagnose.
249
+ decodeCalldata(chain, tx).catch((error) => {
250
+ console.error('[Preview] Could not decode calldata:', error instanceof Error ? error.message : error)
251
+ return undefined
252
+ }),
253
+ from
254
+ ? simulate(chain, tx, from as Address).catch((error) => {
255
+ console.error('[Preview] Could not simulate:', error instanceof Error ? error.message : error)
256
+ return undefined
257
+ })
258
+ : Promise.resolve(undefined),
259
+ // The registry's protocol name beats anything on-chain: "Aave", not a proxy's class name.
260
+ Promise.resolve(lookupContract(chain, tx.to))
261
+ .then((entry) => (entry ? protocolLabel(entry.protocol) : addressLabel(chain, tx.to)))
262
+ .catch(() => undefined)
308
263
  ])
309
264
 
310
265
  return {
@@ -1,11 +1,18 @@
1
1
  import { z } from 'zod'
2
2
  import { getWalletClient } from '../wallet-client.js'
3
3
  import { getClientManager, SUPPORTED_CHAINS } from '../client.js'
4
- import { encodeFunctionData, isAddress, parseAbiItem, parseUnits, type AbiFunction, type Address } from 'viem'
4
+ import { encodeFunctionData, isAddress, parseAbiItem, parseUnits, toHex, type AbiFunction, type Address } from 'viem'
5
5
  import { randomBytes } from 'node:crypto'
6
6
  import type { ChainName } from '../types.js'
7
- import { buildTxPreview, type RawTx } from '../preview.js'
7
+ import { buildTxPreview, type RawTx, type TxPreview } from '../preview.js'
8
+ import { getPhoneSigner } from '../walletconnect.js'
8
9
  import { createTool, formatResponse } from '../utils.js'
10
+ import qrcode from 'qrcode-terminal'
11
+
12
+ /** Render a pairing URI as an ASCII QR, so it can be scanned straight from the chat. */
13
+ function qrFor(uri: string): Promise<string> {
14
+ return new Promise((resolve) => qrcode.generate(uri, { small: true }, resolve))
15
+ }
9
16
 
10
17
  function generateRequestId(): string {
11
18
  return randomBytes(16).toString('hex')
@@ -21,10 +28,28 @@ function explorerTxUrl(chain: ChainName, txHash: unknown): string {
21
28
  }
22
29
 
23
30
  /**
24
- * Send a transaction for signing, with a decoded + simulated preview attached so the
25
- * wallet page can show what it actually does instead of raw calldata.
31
+ * Send a transaction for signing.
32
+ *
33
+ * A paired phone wins over the browser page: it was set up deliberately and is the only
34
+ * option when there is no browser to open. Either way the transaction is decoded and
35
+ * simulated first — the phone wallet shows its own summary, so the preview is returned to
36
+ * the caller as well, where it can be read before the prompt is approved.
26
37
  */
27
38
  async function requestSignature(chain: ChainName, tx: RawTx & { data?: string }) {
39
+ const phone = getPhoneSigner()
40
+
41
+ if (await phone?.isPaired()) {
42
+ const session = await phone!.session()
43
+ const from = session?.accounts[0]
44
+ const preview = await buildTxPreview(chain, tx, from)
45
+
46
+ const txHash = await phone!.request(chain, 'eth_sendTransaction', [
47
+ { from, to: tx.to, value: tx.value ?? '0x0', ...(tx.data && { data: tx.data }) }
48
+ ])
49
+
50
+ return { txHash, preview, signedWith: 'phone' as const }
51
+ }
52
+
28
53
  const wallet = getWalletClient()
29
54
  await wallet.waitForSigner()
30
55
 
@@ -38,14 +63,42 @@ async function requestSignature(chain: ChainName, tx: RawTx & { data?: string })
38
63
  data: { to: tx.to, value: tx.value ?? '0x0', ...(tx.data && { data: tx.data }) },
39
64
  preview
40
65
  }),
41
- preview
66
+ preview,
67
+ signedWith: 'browser' as const
68
+ }
69
+ }
70
+
71
+ /** What the caller should see about a transaction before it is approved. */
72
+ function previewSummary(preview: TxPreview) {
73
+ return {
74
+ action: preview.decoded?.intent ?? preview.decoded?.functionName,
75
+ protocol: preview.decoded?.protocol ?? preview.toLabel,
76
+ details: preview.decoded?.fields.map((f) => `${f.name}: ${f.value}${f.warning ? ` (${f.warning})` : ''}`),
77
+ simulation: preview.simulation
42
78
  }
43
79
  }
44
80
 
81
+ /**
82
+ * WalletConnect rejects with a plain `{ code, message }` rather than an Error, which
83
+ * String() renders as "[object Object]" — the reason a wallet gave for refusing is the
84
+ * whole point of the message, so dig it out.
85
+ */
86
+ function describeError(error: unknown): string {
87
+ if (error instanceof Error) return error.message
88
+ if (error && typeof error === 'object') {
89
+ const { message, code, reason } = error as { message?: string; code?: number; reason?: string }
90
+ const text = message ?? reason
91
+ if (text) return code === undefined ? text : `${text} (code ${code})`
92
+ return JSON.stringify(error)
93
+ }
94
+ return String(error)
95
+ }
96
+
45
97
  function failure(error: unknown, message: string) {
98
+ console.error('[Transaction]', message, '—', describeError(error))
46
99
  return formatResponse({
47
100
  success: false,
48
- error: error instanceof Error ? error.message : String(error),
101
+ error: describeError(error),
49
102
  message
50
103
  })
51
104
  }
@@ -65,7 +118,7 @@ export default {
65
118
  const to = requireAddress('recipient address', args.to)
66
119
  const value = `0x${parseUnits(args.amount, 18).toString(16)}`
67
120
 
68
- const { txHash, preview } = await requestSignature(args.chain as ChainName, { to, value, data: args.data })
121
+ const { txHash, preview, signedWith } = await requestSignature(args.chain as ChainName, { to, value, data: args.data })
69
122
 
70
123
  return formatResponse({
71
124
  success: true,
@@ -73,7 +126,8 @@ export default {
73
126
  transactionHash: txHash,
74
127
  to,
75
128
  amount: args.amount,
76
- simulation: preview.simulation,
129
+ signedWith,
130
+ preview: previewSummary(preview),
77
131
  explorerUrl: explorerTxUrl(args.chain as ChainName, txHash)
78
132
  })
79
133
  } catch (error) {
@@ -102,7 +156,7 @@ export default {
102
156
  args: [to, parseUnits(args.amount, args.decimals ?? 18)]
103
157
  })
104
158
 
105
- const { txHash, preview } = await requestSignature(args.chain as ChainName, { to: tokenAddress, data, value: '0x0' })
159
+ const { txHash, preview, signedWith } = await requestSignature(args.chain as ChainName, { to: tokenAddress, data, value: '0x0' })
106
160
 
107
161
  return formatResponse({
108
162
  success: true,
@@ -111,7 +165,8 @@ export default {
111
165
  tokenAddress,
112
166
  to,
113
167
  amount: args.amount,
114
- simulation: preview.simulation,
168
+ signedWith,
169
+ preview: previewSummary(preview),
115
170
  explorerUrl: explorerTxUrl(args.chain as ChainName, txHash)
116
171
  })
117
172
  } catch (error) {
@@ -145,7 +200,7 @@ export default {
145
200
  })
146
201
  const value = args.value ? `0x${parseUnits(args.value, 18).toString(16)}` : '0x0'
147
202
 
148
- const { txHash, preview } = await requestSignature(args.chain as ChainName, { to: contractAddress, data, value })
203
+ const { txHash, preview, signedWith } = await requestSignature(args.chain as ChainName, { to: contractAddress, data, value })
149
204
 
150
205
  return formatResponse({
151
206
  success: true,
@@ -153,7 +208,8 @@ export default {
153
208
  transactionHash: txHash,
154
209
  contractAddress,
155
210
  functionName: abiItem.name,
156
- simulation: preview.simulation,
211
+ signedWith,
212
+ preview: previewSummary(preview),
157
213
  explorerUrl: explorerTxUrl(args.chain as ChainName, txHash)
158
214
  })
159
215
  } catch (error) {
@@ -164,23 +220,30 @@ export default {
164
220
 
165
221
  sign_message: createTool(
166
222
  'Sign Message',
167
- 'Sign a message with the connected wallet. Opens browser wallet for approval.',
223
+ 'Sign a message with the connected wallet — a paired phone if there is one, otherwise the browser wallet.',
168
224
  z.object({
169
225
  message: z.string().describe('Message to sign')
170
226
  }),
171
227
  async (args) => {
172
228
  try {
173
- const signature = await getWalletClient().request({
174
- id: generateRequestId(),
175
- type: 'sign_message',
176
- chain: 'any',
177
- data: { message: args.message }
178
- })
229
+ // Same precedence as transactions: a hosted server has no browser to fall back on.
230
+ const phone = getPhoneSigner()
231
+ const session = await phone?.session()
232
+
233
+ const signature = session
234
+ ? await phone!.request('mainnet', 'personal_sign', [toHex(args.message), session.accounts[0]])
235
+ : await getWalletClient().request({
236
+ id: generateRequestId(),
237
+ type: 'sign_message',
238
+ chain: 'any',
239
+ data: { message: args.message }
240
+ })
179
241
 
180
242
  return formatResponse({
181
243
  success: true,
182
244
  message: args.message,
183
245
  signature,
246
+ signedWith: session ? 'phone' : 'browser',
184
247
  signatureType: 'personal_sign'
185
248
  })
186
249
  } catch (error) {
@@ -189,13 +252,74 @@ export default {
189
252
  }
190
253
  ),
191
254
 
255
+ pair_phone_wallet: createTool(
256
+ 'Pair Phone Wallet',
257
+ 'Pair a phone wallet over WalletConnect, so transactions are signed on the phone instead of in a browser. Returns a QR code to scan. Requires a WalletConnect project id.',
258
+ z.object({
259
+ chains: z
260
+ .array(z.enum(SUPPORTED_CHAINS))
261
+ .optional()
262
+ .describe('Chains to request access to (defaults to every supported network)')
263
+ }),
264
+ async (args) => {
265
+ const phone = getPhoneSigner()
266
+ if (!phone) {
267
+ return formatResponse({
268
+ success: false,
269
+ message:
270
+ 'No WalletConnect project id configured. Get a free one at https://dashboard.reown.com and pass --walletconnect-project-id or set WALLETCONNECT_PROJECT_ID.'
271
+ })
272
+ }
273
+
274
+ try {
275
+ const existing = await phone.session()
276
+ if (existing) {
277
+ return formatResponse({
278
+ alreadyPaired: true,
279
+ address: existing.accounts[0],
280
+ wallet: existing.peer,
281
+ message: 'A phone wallet is already paired. Transactions will be sent to it.'
282
+ })
283
+ }
284
+
285
+ const chains = (args.chains ?? SUPPORTED_CHAINS.filter((c) => c !== 'localhost')) as ChainName[]
286
+ const uri = await phone.pair(chains)
287
+
288
+ return formatResponse({
289
+ qr: await qrFor(uri),
290
+ uri,
291
+ message:
292
+ 'Scan this QR with your phone wallet (MetaMask, Rabby, Trust…), or open the uri on the phone. ' +
293
+ 'Approve the session, then run wallet_status to confirm.'
294
+ })
295
+ } catch (error) {
296
+ return failure(error, 'Could not start WalletConnect pairing')
297
+ }
298
+ }
299
+ ),
300
+
192
301
  wallet_status: createTool(
193
302
  'Wallet Status',
194
303
  'Check if a wallet is connected to the browser interface',
195
304
  z.object({}),
196
305
  async () => {
197
- const wallet = getWalletClient()
306
+ // A paired phone is the signer of record, so look there first. Connecting to the
307
+ // browser relay before checking would start one on a hosted server, which has no
308
+ // browser to open it in.
309
+ const phone = getPhoneSigner()
310
+ const phoneSession = await phone?.session().catch(() => undefined)
311
+ if (phoneSession) {
312
+ return formatResponse({
313
+ connected: true,
314
+ signer: 'phone',
315
+ address: phoneSession.accounts[0],
316
+ wallet: phoneSession.peer,
317
+ chains: phoneSession.chains,
318
+ message: 'A phone wallet is paired over WalletConnect. Transactions are sent there for signing.'
319
+ })
320
+ }
198
321
 
322
+ const wallet = getWalletClient()
199
323
  try {
200
324
  await wallet.connect()
201
325
  } catch (error) {
@@ -206,6 +330,8 @@ export default {
206
330
 
207
331
  return formatResponse({
208
332
  connected: wallet.isConnected(),
333
+ signer: 'browser',
334
+ phonePairing: phone ? 'Not paired — run pair_phone_wallet to sign from a phone.' : undefined,
209
335
  address: wallet.getAddress(),
210
336
  walletUrl: wallet.getUrl(),
211
337
  openTab: wallet.getPageUrl(),
package/src/types.ts CHANGED
@@ -14,6 +14,7 @@ export interface Config {
14
14
  infuraApiKey?: string
15
15
  customRpcUrls?: Record<string, string>
16
16
  hypersyncApiKey?: string
17
+ walletConnectProjectId?: string
17
18
  }
18
19
 
19
20
  // Chain names
package/src/utils.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AbiParameter } from 'viem'
2
- import type { ToolResult } from './types.js'
2
+ import type { Config, ToolResult } from './types.js'
3
3
  import { z } from 'zod'
4
4
 
5
5
  // Create tool helper
@@ -264,22 +264,8 @@ export function formatResponse(data: unknown): ToolResult {
264
264
  }
265
265
 
266
266
  // Parse command line arguments
267
- export function parseCommandLineArgs(): {
268
- etherscanApiKey?: string
269
- alchemyApiKey?: string
270
- infuraApiKey?: string
271
- customRpcUrls?: Record<string, string>
272
- hypersyncApiKey?: string
273
- showHelp?: boolean
274
- } {
275
- const config: {
276
- etherscanApiKey?: string
277
- alchemyApiKey?: string
278
- infuraApiKey?: string
279
- customRpcUrls?: Record<string, string>
280
- hypersyncApiKey?: string
281
- showHelp?: boolean
282
- } = {}
267
+ export function parseCommandLineArgs(): Config & { showHelp?: boolean } {
268
+ const config: Config & { showHelp?: boolean } = {}
283
269
 
284
270
  function getArgValue(argName: string): string | undefined {
285
271
  const args = process.argv
@@ -302,6 +288,7 @@ export function parseCommandLineArgs(): {
302
288
  config.alchemyApiKey = process.env.ALCHEMY_API_KEY || getArgValue('--alchemy-api-key')
303
289
  config.infuraApiKey = process.env.INFURA_API_KEY || getArgValue('--infura-api-key')
304
290
  config.hypersyncApiKey = process.env.HYPERSYNC_API_KEY || getArgValue('--hypersync-api-key')
291
+ config.walletConnectProjectId = process.env.WALLETCONNECT_PROJECT_ID || getArgValue('--walletconnect-project-id')
305
292
 
306
293
  // Parse custom RPC URLs
307
294
  const customRpcs = getArgValue('--custom-rpc')
@@ -0,0 +1,157 @@
1
+ import { mkdirSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { getClientManager } from './client.js'
5
+ import { getKeyValueStorage } from './kv-storage.js'
6
+ import type { ChainName } from './types.js'
7
+
8
+ /**
9
+ * Signing from a phone, with no page to load and nothing to host: the MCP server acts as
10
+ * the dApp, the wallet app is the signer, and WalletConnect's relay carries the (end-to-end
11
+ * encrypted) messages between them. Pair once by scanning a QR; the session is stored on
12
+ * disk, so it survives restarts.
13
+ */
14
+
15
+ type SignClientType = Awaited<ReturnType<typeof importSignClient>>['prototype']
16
+
17
+ async function importSignClient() {
18
+ // Imported lazily: several MB of SDK that is pointless to load without a project ID.
19
+ const { SignClient } = await import('@walletconnect/sign-client')
20
+ return SignClient
21
+ }
22
+
23
+ interface Session {
24
+ topic: string
25
+ accounts: string[]
26
+ chains: number[]
27
+ peer?: string
28
+ }
29
+
30
+ const METHODS = ['eth_sendTransaction', 'personal_sign', 'eth_signTypedData_v4']
31
+ const EVENTS = ['chainChanged', 'accountsChanged']
32
+ const APPROVAL_TIMEOUT = 300_000
33
+
34
+ function storePath(): string {
35
+ const dir = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'web3-tools-mcp')
36
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
37
+ return join(dir, 'walletconnect.db')
38
+ }
39
+
40
+ export class PhoneSigner {
41
+ private client: SignClientType | undefined
42
+ private starting: Promise<void> | undefined
43
+ private pairing: { uri: string; expiresAt: number } | undefined
44
+
45
+ constructor(private projectId: string) {}
46
+
47
+ private async start(): Promise<void> {
48
+ if (this.client) return
49
+ if (this.starting) return this.starting
50
+
51
+ this.starting = (async () => {
52
+ const SignClient = await importSignClient()
53
+ // Redis when hosted (a free-tier filesystem is ephemeral, and losing the store means
54
+ // re-pairing by QR after every sleep); a file next to the other config locally.
55
+ const storage = getKeyValueStorage()
56
+ this.client = (await SignClient.init({
57
+ projectId: this.projectId,
58
+ ...(storage ? { storage } : { storageOptions: { database: storePath() } }),
59
+ metadata: {
60
+ name: 'web3-tools-mcp',
61
+ description: 'Blockchain tools for AI agents',
62
+ url: 'https://github.com/hajnalben/web3-tools-mcp',
63
+ icons: []
64
+ }
65
+ })) as SignClientType
66
+ })().finally(() => {
67
+ this.starting = undefined
68
+ })
69
+
70
+ return this.starting
71
+ }
72
+
73
+ /** The most recent still-valid session, if the phone is already paired. */
74
+ async session(): Promise<Session | undefined> {
75
+ await this.start()
76
+ const sessions = this.client?.session.getAll() ?? []
77
+ const live = sessions.filter((s) => s.expiry * 1000 > Date.now())
78
+ const session = live[live.length - 1]
79
+ if (!session) return undefined
80
+
81
+ const accounts = session.namespaces.eip155?.accounts ?? []
82
+ return {
83
+ topic: session.topic,
84
+ // "eip155:8453:0xabc…" → address / chain id
85
+ accounts: [...new Set(accounts.map((a) => a.split(':')[2] as string))],
86
+ chains: [...new Set(accounts.map((a) => Number(a.split(':')[1])))],
87
+ peer: session.peer?.metadata?.name
88
+ }
89
+ }
90
+
91
+ async isPaired(): Promise<boolean> {
92
+ return Boolean(await this.session())
93
+ }
94
+
95
+ /**
96
+ * Start a pairing and return its URI immediately, resolving the wallet's approval in the
97
+ * background — a tool result only reaches the user once it returns, so blocking here
98
+ * would hide the QR they are supposed to scan.
99
+ */
100
+ async pair(chains: ChainName[]): Promise<string> {
101
+ await this.start()
102
+ if (!this.client) throw new Error('WalletConnect failed to start')
103
+
104
+ const clientManager = getClientManager()
105
+ const eip155 = chains.map((chain) => `eip155:${clientManager.getChainId(chain)}`)
106
+
107
+ const { uri, approval } = await this.client.connect({
108
+ // Optional rather than required: wallets reject a session outright when they cannot
109
+ // serve every required chain, and few support all thirteen.
110
+ optionalNamespaces: { eip155: { chains: eip155, methods: METHODS, events: EVENTS } }
111
+ })
112
+
113
+ if (!uri) throw new Error('WalletConnect did not return a pairing URI')
114
+ this.pairing = { uri, expiresAt: Date.now() + APPROVAL_TIMEOUT }
115
+
116
+ approval()
117
+ .then((session) => console.error(`[WalletConnect] Paired with ${session.peer?.metadata?.name ?? 'a wallet'}`))
118
+ .catch((error) => console.error('[WalletConnect] Pairing was not completed:', error.message))
119
+
120
+ return uri
121
+ }
122
+
123
+ /** Send a request to the paired wallet. Throws if the chain is outside the session. */
124
+ async request(chain: ChainName, method: string, params: unknown[]): Promise<unknown> {
125
+ const session = await this.session()
126
+ if (!session || !this.client) throw new Error('No phone wallet paired')
127
+
128
+ const chainId = getClientManager().getChainId(chain)
129
+ if (!session.chains.includes(chainId)) {
130
+ throw new Error(
131
+ `The paired wallet did not approve ${chain} (chain ${chainId}). It approved: ${session.chains.join(', ')}. Re-pair to add it.`
132
+ )
133
+ }
134
+
135
+ return this.client.request({
136
+ topic: session.topic,
137
+ chainId: `eip155:${chainId}`,
138
+ request: { method, params }
139
+ })
140
+ }
141
+
142
+ async unpair(): Promise<void> {
143
+ const session = await this.session()
144
+ if (!session || !this.client) return
145
+ await this.client.disconnect({ topic: session.topic, reason: { code: 6000, message: 'User disconnected' } })
146
+ }
147
+ }
148
+
149
+ let signer: PhoneSigner | null = null
150
+
151
+ /** Configured only when a WalletConnect project id is present. */
152
+ export function getPhoneSigner(): PhoneSigner | null {
153
+ const projectId = getClientManager().getConfig().walletConnectProjectId
154
+ if (!projectId) return null
155
+ if (!signer) signer = new PhoneSigner(projectId)
156
+ return signer
157
+ }