web3-tools-mcp 1.5.0 → 2.0.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/CHANGELOG.md +95 -0
- package/README.md +346 -164
- package/dist/anvil.d.ts +1 -1
- package/dist/anvil.d.ts.map +1 -1
- package/dist/anvil.js +11 -9
- package/dist/anvil.js.map +1 -1
- package/dist/chain-meta.d.ts.map +1 -1
- package/dist/chain-meta.js +7 -3
- package/dist/chain-meta.js.map +1 -1
- package/dist/chains.d.ts +960 -0
- package/dist/chains.d.ts.map +1 -0
- package/dist/chains.js +108 -0
- package/dist/chains.js.map +1 -0
- package/dist/client.d.ts +10 -1717
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +33 -216
- package/dist/client.js.map +1 -1
- package/dist/http-server.d.ts +18 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +89 -0
- package/dist/http-server.js.map +1 -0
- package/dist/index.js +77 -32
- package/dist/index.js.map +1 -1
- package/dist/kv-storage.d.ts +23 -0
- package/dist/kv-storage.d.ts.map +1 -0
- package/dist/kv-storage.js +69 -0
- package/dist/kv-storage.js.map +1 -0
- package/dist/oauth.d.ts +31 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +227 -0
- package/dist/oauth.js.map +1 -0
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +14 -4
- package/dist/preview.js.map +1 -1
- package/dist/tools/advanced.d.ts +4 -4
- package/dist/tools/advanced.d.ts.map +1 -1
- package/dist/tools/advanced.js +228 -247
- package/dist/tools/advanced.js.map +1 -1
- package/dist/tools/balance.d.ts +1 -1
- package/dist/tools/balance.d.ts.map +1 -1
- package/dist/tools/balance.js +97 -103
- package/dist/tools/balance.js.map +1 -1
- package/dist/tools/contract-info.d.ts +3 -3
- package/dist/tools/contract-info.d.ts.map +1 -1
- package/dist/tools/contract-info.js +150 -161
- package/dist/tools/contract-info.js.map +1 -1
- package/dist/tools/contract.d.ts +4 -4
- package/dist/tools/contract.d.ts.map +1 -1
- package/dist/tools/contract.js +91 -110
- package/dist/tools/contract.js.map +1 -1
- package/dist/tools/ens.d.ts +6 -6
- package/dist/tools/ens.d.ts.map +1 -1
- package/dist/tools/ens.js +239 -297
- package/dist/tools/ens.js.map +1 -1
- package/dist/tools/gas.d.ts +4 -4
- package/dist/tools/gas.d.ts.map +1 -1
- package/dist/tools/gas.js +90 -138
- package/dist/tools/gas.js.map +1 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/logs.d.ts +1 -1
- package/dist/tools/logs.d.ts.map +1 -1
- package/dist/tools/logs.js +36 -37
- package/dist/tools/logs.js.map +1 -1
- package/dist/tools/signatures.d.ts.map +1 -1
- package/dist/tools/signatures.js +67 -89
- package/dist/tools/signatures.js.map +1 -1
- package/dist/tools/transactions.d.ts +48 -4
- package/dist/tools/transactions.d.ts.map +1 -1
- package/dist/tools/transactions.js +228 -44
- package/dist/tools/transactions.js.map +1 -1
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +3 -8
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +36 -98
- package/dist/utils.js.map +1 -1
- package/dist/wallet-client.d.ts +11 -1
- package/dist/wallet-client.d.ts.map +1 -1
- package/dist/wallet-client.js +27 -9
- package/dist/wallet-client.js.map +1 -1
- package/dist/walletconnect.d.ts +52 -0
- package/dist/walletconnect.d.ts.map +1 -0
- package/dist/walletconnect.js +221 -0
- package/dist/walletconnect.js.map +1 -0
- package/package.json +21 -9
- package/src/anvil.ts +15 -18
- package/src/chain-meta.ts +8 -4
- package/src/chains.ts +145 -0
- package/src/client.ts +39 -252
- package/src/http-server.ts +117 -0
- package/src/index.ts +98 -49
- package/src/kv-storage.ts +83 -0
- package/src/oauth.ts +270 -0
- package/src/preview.ts +15 -14
- package/src/tools/advanced.ts +195 -213
- package/src/tools/balance.ts +103 -108
- package/src/tools/contract-info.ts +163 -184
- package/src/tools/contract.ts +123 -144
- package/src/tools/ens.ts +290 -345
- package/src/tools/gas.ts +122 -170
- package/src/tools/index.ts +2 -2
- package/src/tools/logs.ts +45 -43
- package/src/tools/signatures.ts +68 -86
- package/src/tools/transactions.ts +283 -50
- package/src/types.ts +2 -15
- package/src/utils.ts +56 -127
- package/src/wallet-client.ts +31 -9
- package/src/walletconnect.ts +269 -0
package/src/utils.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AbiParameter } from 'viem'
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
2
|
+
import type { z } from 'zod'
|
|
3
|
+
import type { Config, ToolResult } from './types.js'
|
|
4
4
|
|
|
5
5
|
// Create tool helper
|
|
6
6
|
export function createTool<T extends z.ZodType>(
|
|
@@ -23,7 +23,7 @@ export function convertBigIntToString(obj: unknown): unknown {
|
|
|
23
23
|
return obj.toString()
|
|
24
24
|
}
|
|
25
25
|
if (Array.isArray(obj)) {
|
|
26
|
-
return obj.map(item => convertBigIntToString(item))
|
|
26
|
+
return obj.map((item) => convertBigIntToString(item))
|
|
27
27
|
}
|
|
28
28
|
if (obj && typeof obj === 'object') {
|
|
29
29
|
const converted: Record<string, unknown> = {}
|
|
@@ -35,7 +35,6 @@ export function convertBigIntToString(obj: unknown): unknown {
|
|
|
35
35
|
return obj
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
38
|
// Summarize a call trace for compact output
|
|
40
39
|
export interface SummarizedCall {
|
|
41
40
|
type: string
|
|
@@ -73,17 +72,20 @@ export function summarizeTrace(trace: unknown): { hasError: boolean; errorPath:
|
|
|
73
72
|
summary = `SUCCESS - ${topCalls.length} top-level calls: ${topCalls.slice(0, 5).join(', ')}${topCalls.length > 5 ? '...' : ''}`
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
return {
|
|
77
|
-
hasError: result !== null,
|
|
78
|
-
errorPath: result?.errorPath ?? null,
|
|
79
|
-
summary
|
|
75
|
+
return {
|
|
76
|
+
hasError: result !== null,
|
|
77
|
+
errorPath: result?.errorPath ?? null,
|
|
78
|
+
summary
|
|
80
79
|
}
|
|
81
80
|
}
|
|
82
81
|
|
|
83
82
|
function findErrorInTrace(trace: unknown): { errorPath: SummarizedCall[]; errorMessage: string } | null {
|
|
84
83
|
if (!trace || typeof trace !== 'object') return null
|
|
85
84
|
|
|
86
|
-
function findError(
|
|
85
|
+
function findError(
|
|
86
|
+
call: Record<string, unknown>,
|
|
87
|
+
path: SummarizedCall[]
|
|
88
|
+
): { errorPath: SummarizedCall[]; errorMessage: string } | null {
|
|
87
89
|
const type = (call.type as string) || 'CALL'
|
|
88
90
|
const error = (call.error as string) || (call.revertReason as string)
|
|
89
91
|
const subcalls = call.calls as Record<string, unknown>[] | undefined
|
|
@@ -130,124 +132,64 @@ function findErrorInTrace(trace: unknown): { errorPath: SummarizedCall[]; errorM
|
|
|
130
132
|
return findError(trace as Record<string, unknown>, [])
|
|
131
133
|
}
|
|
132
134
|
|
|
133
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Coerce a JSON value into what viem expects for an ABI type. Tuples, arrays and anything
|
|
137
|
+
* else pass through: viem validates them, and guessing here would only mangle them.
|
|
138
|
+
*/
|
|
139
|
+
function coerce(value: unknown, type: string): unknown {
|
|
140
|
+
if (value === null || value === undefined) return null
|
|
141
|
+
|
|
142
|
+
if (type === 'address' || type === 'string' || type.startsWith('bytes')) {
|
|
143
|
+
return String(value)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (type === 'bool') {
|
|
147
|
+
if (typeof value === 'boolean') return value
|
|
148
|
+
if (typeof value === 'string') return value.toLowerCase() === 'true'
|
|
149
|
+
return Boolean(value)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (type.startsWith('uint') || type.startsWith('int')) {
|
|
153
|
+
if (typeof value === 'number' || typeof value === 'string') return BigInt(value)
|
|
154
|
+
throw new Error(`Invalid type for ${type}: ${typeof value}`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return value
|
|
158
|
+
}
|
|
159
|
+
|
|
134
160
|
export function convertArgumentsToTypes(
|
|
135
161
|
args: (string | number | boolean | null)[],
|
|
136
162
|
abiInputs: readonly AbiParameter[]
|
|
137
163
|
): unknown[] {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
164
|
+
if (args.length > abiInputs.length) {
|
|
165
|
+
throw new Error(`Too many arguments provided. Expected ${abiInputs.length}, got ${args.length}`)
|
|
166
|
+
}
|
|
142
167
|
|
|
168
|
+
return args.map((arg, index) => {
|
|
143
169
|
const param = abiInputs[index]
|
|
144
170
|
if (!param?.type) {
|
|
145
171
|
throw new Error(`Missing type information for parameter at index ${index}`)
|
|
146
172
|
}
|
|
147
|
-
|
|
148
|
-
const paramType = param.type
|
|
149
|
-
|
|
150
|
-
// Handle null values
|
|
151
|
-
if (arg === null) {
|
|
152
|
-
return null
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Handle different parameter types
|
|
156
|
-
if (paramType === 'address') {
|
|
157
|
-
return String(arg)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (paramType === 'bool') {
|
|
161
|
-
if (typeof arg === 'boolean') return arg
|
|
162
|
-
if (typeof arg === 'string') return arg.toLowerCase() === 'true'
|
|
163
|
-
return Boolean(arg)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (paramType.startsWith('uint') || paramType.startsWith('int')) {
|
|
167
|
-
if (typeof arg === 'number') return BigInt(arg)
|
|
168
|
-
if (typeof arg === 'string') return BigInt(arg)
|
|
169
|
-
throw new Error(`Invalid type for ${paramType}: ${typeof arg}`)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (paramType === 'string') {
|
|
173
|
-
return String(arg)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (paramType === 'bytes' || paramType.startsWith('bytes')) {
|
|
177
|
-
return String(arg)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// For other types, try to convert appropriately
|
|
181
|
-
if (typeof arg === 'string' && arg.startsWith('0x')) {
|
|
182
|
-
return arg // Assume it's already properly formatted
|
|
183
|
-
}
|
|
184
|
-
return arg
|
|
173
|
+
return coerce(arg, param.type)
|
|
185
174
|
})
|
|
186
175
|
}
|
|
187
176
|
|
|
188
|
-
// Convert event arguments to appropriate types
|
|
189
177
|
export function convertEventArgsToTypes(
|
|
190
178
|
eventArgs: Record<string, unknown>,
|
|
191
179
|
abiInputs: readonly AbiParameter[]
|
|
192
180
|
): Record<string, unknown> {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (!param) {
|
|
200
|
-
throw new Error(`Parameter '${argName}' not found in event ABI`)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (!param.type) {
|
|
204
|
-
throw new Error(`Missing type information for parameter '${argName}'`)
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const paramType = param.type
|
|
208
|
-
|
|
209
|
-
// Handle null/undefined values
|
|
210
|
-
if (argValue === null || argValue === undefined) {
|
|
211
|
-
converted[argName] = null
|
|
212
|
-
continue
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Handle different parameter types
|
|
216
|
-
if (paramType === 'address') {
|
|
217
|
-
converted[argName] = String(argValue)
|
|
218
|
-
} else if (paramType === 'bool') {
|
|
219
|
-
if (typeof argValue === 'boolean') {
|
|
220
|
-
converted[argName] = argValue
|
|
221
|
-
} else if (typeof argValue === 'string') {
|
|
222
|
-
converted[argName] = argValue.toLowerCase() === 'true'
|
|
223
|
-
} else {
|
|
224
|
-
converted[argName] = Boolean(argValue)
|
|
225
|
-
}
|
|
226
|
-
} else if (paramType.startsWith('uint') || paramType.startsWith('int')) {
|
|
227
|
-
if (typeof argValue === 'number') {
|
|
228
|
-
converted[argName] = BigInt(argValue)
|
|
229
|
-
} else if (typeof argValue === 'string') {
|
|
230
|
-
converted[argName] = BigInt(argValue)
|
|
231
|
-
} else {
|
|
232
|
-
throw new Error(`Invalid type for ${paramType}: ${typeof argValue}`)
|
|
181
|
+
return Object.fromEntries(
|
|
182
|
+
Object.entries(eventArgs).map(([argName, argValue]) => {
|
|
183
|
+
const param = abiInputs.find((input) => input.name === argName)
|
|
184
|
+
if (!param) {
|
|
185
|
+
throw new Error(`Parameter '${argName}' not found in event ABI`)
|
|
233
186
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
} else if (paramType === 'bytes' || paramType.startsWith('bytes')) {
|
|
237
|
-
converted[argName] = String(argValue)
|
|
238
|
-
} else {
|
|
239
|
-
// For other types, handle arrays and try to convert appropriately
|
|
240
|
-
if (Array.isArray(argValue)) {
|
|
241
|
-
converted[argName] = argValue
|
|
242
|
-
} else if (typeof argValue === 'string' && argValue.startsWith('0x')) {
|
|
243
|
-
converted[argName] = argValue // Assume it's already properly formatted
|
|
244
|
-
} else {
|
|
245
|
-
converted[argName] = argValue
|
|
187
|
+
if (!param.type) {
|
|
188
|
+
throw new Error(`Missing type information for parameter '${argName}'`)
|
|
246
189
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return converted
|
|
190
|
+
return [argName, coerce(argValue, param.type)]
|
|
191
|
+
})
|
|
192
|
+
)
|
|
251
193
|
}
|
|
252
194
|
|
|
253
195
|
// Format response as tool result
|
|
@@ -264,26 +206,12 @@ export function formatResponse(data: unknown): ToolResult {
|
|
|
264
206
|
}
|
|
265
207
|
|
|
266
208
|
// Parse command line arguments
|
|
267
|
-
export function parseCommandLineArgs(): {
|
|
268
|
-
|
|
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
|
-
} = {}
|
|
209
|
+
export function parseCommandLineArgs(): Config & { showHelp?: boolean } {
|
|
210
|
+
const config: Config & { showHelp?: boolean } = {}
|
|
283
211
|
|
|
284
212
|
function getArgValue(argName: string): string | undefined {
|
|
285
213
|
const args = process.argv
|
|
286
|
-
const index = args.
|
|
214
|
+
const index = args.indexOf(argName)
|
|
287
215
|
return index !== -1 && index + 1 < args.length ? args[index + 1] : undefined
|
|
288
216
|
}
|
|
289
217
|
|
|
@@ -302,9 +230,10 @@ export function parseCommandLineArgs(): {
|
|
|
302
230
|
config.alchemyApiKey = process.env.ALCHEMY_API_KEY || getArgValue('--alchemy-api-key')
|
|
303
231
|
config.infuraApiKey = process.env.INFURA_API_KEY || getArgValue('--infura-api-key')
|
|
304
232
|
config.hypersyncApiKey = process.env.HYPERSYNC_API_KEY || getArgValue('--hypersync-api-key')
|
|
233
|
+
config.walletConnectProjectId = process.env.WALLETCONNECT_PROJECT_ID || getArgValue('--walletconnect-project-id')
|
|
305
234
|
|
|
306
235
|
// Parse custom RPC URLs
|
|
307
|
-
const customRpcs = getArgValue('--custom-rpc')
|
|
236
|
+
const customRpcs = process.env.CUSTOM_RPC || getArgValue('--custom-rpc')
|
|
308
237
|
if (customRpcs) {
|
|
309
238
|
try {
|
|
310
239
|
config.customRpcUrls = JSON.parse(customRpcs)
|
package/src/wallet-client.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import { WebSocket } from 'ws'
|
|
2
1
|
import { execFile } from 'node:child_process'
|
|
3
2
|
import { randomBytes } from 'node:crypto'
|
|
4
3
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
5
4
|
import { homedir } from 'node:os'
|
|
6
5
|
import { join } from 'node:path'
|
|
7
|
-
import {
|
|
6
|
+
import { LOCAL_PORT_ATTEMPTS, type TransactionRequest, type TransactionResponse, WalletRelay } from 'web3-wallet-relay'
|
|
7
|
+
import { WebSocket } from 'ws'
|
|
8
8
|
|
|
9
9
|
const REQUEST_TIMEOUT = 300_000
|
|
10
|
+
|
|
11
|
+
/** Serving over HTTP means no desktop here: never try to open a browser, and never
|
|
12
|
+
* hand out a loopback URL as the place to open the signing page. */
|
|
13
|
+
const HOSTED = Boolean(process.env.MCP_HTTP_PORT)
|
|
14
|
+
|
|
15
|
+
function pairingUrlFor(port: number, token: string): string {
|
|
16
|
+
const base = HOSTED ? (process.env.MCP_PUBLIC_URL ?? `http://localhost:${port}`) : `http://127.0.0.1:${port}`
|
|
17
|
+
return `${base.replace(/\/$/, '')}/#t=${token}`
|
|
18
|
+
}
|
|
10
19
|
const SIGNER_WAIT_TIMEOUT = 30_000
|
|
11
20
|
const CONNECT_ATTEMPTS = 8
|
|
12
21
|
const MAX_CONNECT_BACKOFF = 15_000
|
|
@@ -31,13 +40,14 @@ function browserApp(userAgent?: string): string | undefined {
|
|
|
31
40
|
|
|
32
41
|
// Every MCP process on this machine looks for a relay here before starting one, so all
|
|
33
42
|
// sessions end up sharing a single wallet page instead of each opening its own tab.
|
|
34
|
-
|
|
43
|
+
// Kept in step with the relay: it walks the same span when 3456 is taken.
|
|
44
|
+
const LOCAL_PORTS = Array.from({ length: LOCAL_PORT_ATTEMPTS }, (_, i) => 3456 + i)
|
|
35
45
|
|
|
36
46
|
/**
|
|
37
47
|
* Token shared by every local session, so a process can join a relay another one owns.
|
|
38
48
|
* Stored 0600 because holding it is enough to push transactions at the signer.
|
|
39
49
|
*/
|
|
40
|
-
function localToken(): string {
|
|
50
|
+
export function localToken(): string {
|
|
41
51
|
const dir = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'web3-tools-mcp')
|
|
42
52
|
const file = join(dir, 'relay-token')
|
|
43
53
|
|
|
@@ -143,7 +153,7 @@ export class WalletClient {
|
|
|
143
153
|
for (const port of ports) {
|
|
144
154
|
try {
|
|
145
155
|
await this.openSocket(`ws://127.0.0.1:${port}`, token)
|
|
146
|
-
this.pairingUrl =
|
|
156
|
+
this.pairingUrl = pairingUrlFor(port, token)
|
|
147
157
|
return
|
|
148
158
|
} catch {
|
|
149
159
|
// Nothing on this port, or something that isn't our relay — keep looking.
|
|
@@ -253,7 +263,14 @@ export class WalletClient {
|
|
|
253
263
|
// about the request over its own socket and raises itself (tab title + desktop
|
|
254
264
|
// notification); asking the OS to open a URL cannot reliably focus an existing tab —
|
|
255
265
|
// Chrome opens another one — and every attempt to do so left a stray tab behind.
|
|
256
|
-
if (
|
|
266
|
+
if (this.pages === 0) {
|
|
267
|
+
// Nothing to open a page with and nobody already watching one: waiting out the
|
|
268
|
+
// timeout would just stall the caller in front of a screen that does not exist.
|
|
269
|
+
if (!this.canOpenBrowser) {
|
|
270
|
+
throw new Error(`No wallet page is open. Open ${this.getUrl()}, connect your wallet, then try again.`)
|
|
271
|
+
}
|
|
272
|
+
this.openBrowser()
|
|
273
|
+
}
|
|
257
274
|
|
|
258
275
|
const deadline = Date.now() + this.signerWaitTimeout
|
|
259
276
|
while (this.signers === 0 && Date.now() < deadline) {
|
|
@@ -305,8 +322,13 @@ export class WalletClient {
|
|
|
305
322
|
return `http://127.0.0.1:${LOCAL_PORTS[0]}/#t=${this.relayOptions.token ?? localToken()}`
|
|
306
323
|
}
|
|
307
324
|
|
|
325
|
+
/** A relay we do not own, or a server with no desktop, has no browser for us to open. */
|
|
326
|
+
private get canOpenBrowser(): boolean {
|
|
327
|
+
return !this.isRemote && !HOSTED
|
|
328
|
+
}
|
|
329
|
+
|
|
308
330
|
openBrowser() {
|
|
309
|
-
if (this.
|
|
331
|
+
if (!this.canOpenBrowser) return
|
|
310
332
|
this.lastOpenedAt = Date.now()
|
|
311
333
|
this.open(this.getUrl(), (url) => console.error(`[Wallet] Could not open a browser — visit ${url}`))
|
|
312
334
|
}
|
|
@@ -374,7 +396,7 @@ export class WalletClient {
|
|
|
374
396
|
|
|
375
397
|
let client: WalletClient | null = null
|
|
376
398
|
|
|
377
|
-
export function getWalletClient(): WalletClient {
|
|
378
|
-
if (!client) client = new WalletClient()
|
|
399
|
+
export function getWalletClient(relayOptions?: { port?: number; token?: string }): WalletClient {
|
|
400
|
+
if (!client) client = new WalletClient(relayOptions)
|
|
379
401
|
return client
|
|
380
402
|
}
|
|
@@ -0,0 +1,269 @@
|
|
|
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
|
+
/**
|
|
35
|
+
* Where this server lives, as shown to the wallet. A hosted server knows its public URL;
|
|
36
|
+
* a local one is only reachable from the machine it runs on, and says so.
|
|
37
|
+
*/
|
|
38
|
+
function serverUrl(): string {
|
|
39
|
+
if (process.env.MCP_PUBLIC_URL) return process.env.MCP_PUBLIC_URL.replace(/\/$/, '')
|
|
40
|
+
if (process.env.MCP_HTTP_PORT) return `http://localhost:${process.env.MCP_HTTP_PORT}`
|
|
41
|
+
return 'http://localhost'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function storePath(): string {
|
|
45
|
+
const dir = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'web3-tools-mcp')
|
|
46
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
47
|
+
return join(dir, 'walletconnect.db')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class PhoneSigner {
|
|
51
|
+
private client: SignClientType | undefined
|
|
52
|
+
private starting: Promise<void> | undefined
|
|
53
|
+
private pairing: { uri: string; expiresAt: number } | undefined
|
|
54
|
+
/** Label shown in the wallet; whoever is driving this server says who they are. */
|
|
55
|
+
private agent: string | undefined
|
|
56
|
+
|
|
57
|
+
constructor(private projectId: string) {}
|
|
58
|
+
|
|
59
|
+
private async start(): Promise<void> {
|
|
60
|
+
if (this.client) return
|
|
61
|
+
if (this.starting) return this.starting
|
|
62
|
+
|
|
63
|
+
this.starting = (async () => {
|
|
64
|
+
const SignClient = await importSignClient()
|
|
65
|
+
// Redis when hosted (a free-tier filesystem is ephemeral, and losing the store means
|
|
66
|
+
// re-pairing by QR after every sleep); a file next to the other config locally.
|
|
67
|
+
const storage = getKeyValueStorage()
|
|
68
|
+
this.client = (await SignClient.init({
|
|
69
|
+
projectId: this.projectId,
|
|
70
|
+
...(storage ? { storage } : { storageOptions: { database: storePath() } }),
|
|
71
|
+
// What the wallet shows when asking you to approve. Approving does not connect a
|
|
72
|
+
// website — it lets an agent request signatures for as long as the session lives,
|
|
73
|
+
// so the dialog says that. The name is self-reported and proves nothing; the URL
|
|
74
|
+
// is what distinguishes this server from any other.
|
|
75
|
+
metadata: {
|
|
76
|
+
name: this.agent ? `${this.agent} via web3-tools-mcp` : 'web3-tools-mcp (AI agent)',
|
|
77
|
+
description: 'An AI agent uses this server to request transactions. You approve each one here.',
|
|
78
|
+
url: serverUrl(),
|
|
79
|
+
icons: []
|
|
80
|
+
}
|
|
81
|
+
})) as SignClientType
|
|
82
|
+
})().finally(() => {
|
|
83
|
+
this.starting = undefined
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return this.starting
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The most recent still-valid session, if the phone is already paired. */
|
|
90
|
+
async session(): Promise<Session | undefined> {
|
|
91
|
+
await this.start()
|
|
92
|
+
const sessions = this.client?.session.getAll() ?? []
|
|
93
|
+
const live = sessions.filter((s) => s.expiry * 1000 > Date.now())
|
|
94
|
+
const session = live[live.length - 1]
|
|
95
|
+
if (!session) return undefined
|
|
96
|
+
|
|
97
|
+
const accounts = session.namespaces.eip155?.accounts ?? []
|
|
98
|
+
return {
|
|
99
|
+
topic: session.topic,
|
|
100
|
+
// "eip155:8453:0xabc…" → address / chain id
|
|
101
|
+
accounts: [...new Set(accounts.map((a) => a.split(':')[2] as string))],
|
|
102
|
+
chains: [...new Set(accounts.map((a) => Number(a.split(':')[1])))],
|
|
103
|
+
peer: session.peer?.metadata?.name
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async isPaired(): Promise<boolean> {
|
|
108
|
+
return Boolean(await this.session())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Start a pairing and return its URI immediately, resolving the wallet's approval in the
|
|
113
|
+
* background — a tool result only reaches the user once it returns, so blocking here
|
|
114
|
+
* would hide the QR they are supposed to scan.
|
|
115
|
+
*/
|
|
116
|
+
async pair(chains: ChainName[], agent?: string): Promise<string> {
|
|
117
|
+
// Metadata is fixed when the client starts, so a new label needs a new client. Only
|
|
118
|
+
// safe while nothing is connected — an existing session owns the old client.
|
|
119
|
+
if (agent && agent !== this.agent && this.client && this.client.session.getAll().length === 0) {
|
|
120
|
+
this.client = undefined
|
|
121
|
+
}
|
|
122
|
+
if (agent) this.agent = agent
|
|
123
|
+
|
|
124
|
+
await this.start()
|
|
125
|
+
if (!this.client) throw new Error('WalletConnect failed to start')
|
|
126
|
+
|
|
127
|
+
const clientManager = getClientManager()
|
|
128
|
+
const eip155 = chains.map((chain) => `eip155:${clientManager.getChainId(chain)}`)
|
|
129
|
+
|
|
130
|
+
const { uri, approval } = await this.client.connect({
|
|
131
|
+
// Optional rather than required: wallets reject a session outright when they cannot
|
|
132
|
+
// serve every required chain, and few support all thirteen.
|
|
133
|
+
optionalNamespaces: { eip155: { chains: eip155, methods: METHODS, events: EVENTS } }
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
if (!uri) throw new Error('WalletConnect did not return a pairing URI')
|
|
137
|
+
this.pairing = { uri, expiresAt: Date.now() + APPROVAL_TIMEOUT }
|
|
138
|
+
|
|
139
|
+
approval()
|
|
140
|
+
.then((session) => console.error(`[WalletConnect] Paired with ${session.peer?.metadata?.name ?? 'a wallet'}`))
|
|
141
|
+
.catch((error) => console.error('[WalletConnect] Pairing was not completed:', error.message))
|
|
142
|
+
|
|
143
|
+
return uri
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Send a request to the paired wallet. Throws if the chain is outside the session. */
|
|
147
|
+
async request(chain: ChainName, method: string, params: unknown[]): Promise<unknown> {
|
|
148
|
+
const session = await this.session()
|
|
149
|
+
if (!session || !this.client) throw new Error('No phone wallet paired')
|
|
150
|
+
|
|
151
|
+
const chainId = getClientManager().getChainId(chain)
|
|
152
|
+
if (!session.chains.includes(chainId)) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`The paired wallet did not approve ${chain} (chain ${chainId}). It approved: ${session.chains.join(', ')}. Re-pair to add it.`
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return this.client.request({
|
|
159
|
+
topic: session.topic,
|
|
160
|
+
chainId: `eip155:${chainId}`,
|
|
161
|
+
request: { method, params }
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Every live session, not just the one that would be used for signing. */
|
|
166
|
+
async sessions(): Promise<Session[]> {
|
|
167
|
+
await this.start()
|
|
168
|
+
return (this.client?.session.getAll() ?? []).map((session) => ({
|
|
169
|
+
topic: session.topic,
|
|
170
|
+
accounts: [...new Set((session.namespaces.eip155?.accounts ?? []).map((a) => a.split(':')[2] as string))],
|
|
171
|
+
chains: [...new Set((session.namespaces.eip155?.accounts ?? []).map((a) => Number(a.split(':')[1])))],
|
|
172
|
+
peer: session.peer?.metadata?.name
|
|
173
|
+
}))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Disconnect everything: sessions, and the pairings underneath them. Each pairing
|
|
178
|
+
* attempt leaves one behind whether or not a wallet ever approved it, so they
|
|
179
|
+
* accumulate quietly in the store.
|
|
180
|
+
*/
|
|
181
|
+
async disconnectAll(): Promise<{ sessions: number; pairings: number }> {
|
|
182
|
+
await this.start()
|
|
183
|
+
if (!this.client) return { sessions: 0, pairings: 0 }
|
|
184
|
+
|
|
185
|
+
const sessions = this.client.session.getAll()
|
|
186
|
+
for (const session of sessions) {
|
|
187
|
+
await this.client
|
|
188
|
+
.disconnect({ topic: session.topic, reason: { code: 6000, message: 'User disconnected' } })
|
|
189
|
+
.catch((error) => console.error('[WalletConnect] Could not disconnect a session:', error.message))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const pairings = this.client.core.pairing.getPairings()
|
|
193
|
+
for (const pairing of pairings) {
|
|
194
|
+
await this.client.core.pairing
|
|
195
|
+
.disconnect({ topic: pairing.topic })
|
|
196
|
+
.catch((error) => console.error('[WalletConnect] Could not drop a pairing:', error.message))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { sessions: sessions.length, pairings: pairings.length }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async unpair(): Promise<void> {
|
|
203
|
+
await this.disconnectAll()
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface WalletEntry {
|
|
208
|
+
name: string
|
|
209
|
+
native?: string
|
|
210
|
+
universal?: string
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let wallets: WalletEntry[] | undefined
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Wallets from WalletConnect's registry, so the links we hand out are the schemes each
|
|
217
|
+
* wallet actually registered rather than ones we invented. Fetched once per process; a
|
|
218
|
+
* failure is not fatal, it just means no ready-made links.
|
|
219
|
+
*/
|
|
220
|
+
async function walletRegistry(projectId: string): Promise<WalletEntry[]> {
|
|
221
|
+
if (wallets) return wallets
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const response = await fetch(`https://explorer-api.walletconnect.com/v3/wallets?projectId=${projectId}&entries=100&page=1`)
|
|
225
|
+
const listings =
|
|
226
|
+
((await response.json()) as { listings?: Record<string, { name: string; mobile?: WalletEntry }> }).listings ?? {}
|
|
227
|
+
|
|
228
|
+
wallets = Object.values(listings)
|
|
229
|
+
.map(({ name, mobile }) => ({ name, native: mobile?.native || undefined, universal: mobile?.universal || undefined }))
|
|
230
|
+
.filter((wallet) => wallet.native || wallet.universal)
|
|
231
|
+
} catch {
|
|
232
|
+
wallets = []
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return wallets
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Links that open a wallet straight onto this pairing, for when the agent is running on
|
|
240
|
+
* the phone itself and there is no second screen to scan from.
|
|
241
|
+
*
|
|
242
|
+
* Native schemes are preferred over universal links: chat apps are documented to mishandle
|
|
243
|
+
* the latter, opening the wallet with no prompt or diverting to an app store.
|
|
244
|
+
*/
|
|
245
|
+
export async function pairingLinks(projectId: string, uri: string, search?: string): Promise<Record<string, string>> {
|
|
246
|
+
const registry = await walletRegistry(projectId)
|
|
247
|
+
const encoded = encodeURIComponent(uri)
|
|
248
|
+
|
|
249
|
+
const matches = search
|
|
250
|
+
? registry.filter((wallet) => wallet.name.toLowerCase().includes(search.toLowerCase()))
|
|
251
|
+
: registry.slice(0, 12)
|
|
252
|
+
|
|
253
|
+
return Object.fromEntries(
|
|
254
|
+
matches.map((wallet) => {
|
|
255
|
+
const base = wallet.native ?? `${wallet.universal?.replace(/\/$/, '')}/`
|
|
256
|
+
return [wallet.name, `${base}wc?uri=${encoded}`]
|
|
257
|
+
})
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let signer: PhoneSigner | null = null
|
|
262
|
+
|
|
263
|
+
/** Configured only when a WalletConnect project id is present. */
|
|
264
|
+
export function getPhoneSigner(): PhoneSigner | null {
|
|
265
|
+
const projectId = getClientManager().getConfig().walletConnectProjectId
|
|
266
|
+
if (!projectId) return null
|
|
267
|
+
if (!signer) signer = new PhoneSigner(projectId)
|
|
268
|
+
return signer
|
|
269
|
+
}
|