web3-tools-mcp 1.6.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +328 -234
  3. package/dist/anvil.d.ts +1 -1
  4. package/dist/anvil.d.ts.map +1 -1
  5. package/dist/anvil.js +11 -9
  6. package/dist/anvil.js.map +1 -1
  7. package/dist/chain-meta.d.ts.map +1 -1
  8. package/dist/chain-meta.js +7 -3
  9. package/dist/chain-meta.js.map +1 -1
  10. package/dist/chains.d.ts +960 -0
  11. package/dist/chains.d.ts.map +1 -0
  12. package/dist/chains.js +108 -0
  13. package/dist/chains.js.map +1 -0
  14. package/dist/client.d.ts +10 -1717
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +33 -218
  17. package/dist/client.js.map +1 -1
  18. package/dist/http-server.d.ts +6 -0
  19. package/dist/http-server.d.ts.map +1 -1
  20. package/dist/http-server.js +55 -40
  21. package/dist/http-server.js.map +1 -1
  22. package/dist/index.js +50 -29
  23. package/dist/index.js.map +1 -1
  24. package/dist/oauth.d.ts +31 -0
  25. package/dist/oauth.d.ts.map +1 -0
  26. package/dist/oauth.js +227 -0
  27. package/dist/oauth.js.map +1 -0
  28. package/dist/preview.d.ts.map +1 -1
  29. package/dist/preview.js +2 -2
  30. package/dist/preview.js.map +1 -1
  31. package/dist/tools/advanced.d.ts +4 -4
  32. package/dist/tools/advanced.d.ts.map +1 -1
  33. package/dist/tools/advanced.js +228 -247
  34. package/dist/tools/advanced.js.map +1 -1
  35. package/dist/tools/balance.d.ts +1 -1
  36. package/dist/tools/balance.d.ts.map +1 -1
  37. package/dist/tools/balance.js +97 -103
  38. package/dist/tools/balance.js.map +1 -1
  39. package/dist/tools/contract-info.d.ts +3 -3
  40. package/dist/tools/contract-info.d.ts.map +1 -1
  41. package/dist/tools/contract-info.js +150 -161
  42. package/dist/tools/contract-info.js.map +1 -1
  43. package/dist/tools/contract.d.ts +4 -4
  44. package/dist/tools/contract.d.ts.map +1 -1
  45. package/dist/tools/contract.js +91 -110
  46. package/dist/tools/contract.js.map +1 -1
  47. package/dist/tools/ens.d.ts +6 -6
  48. package/dist/tools/ens.d.ts.map +1 -1
  49. package/dist/tools/ens.js +239 -297
  50. package/dist/tools/ens.js.map +1 -1
  51. package/dist/tools/gas.d.ts +4 -4
  52. package/dist/tools/gas.d.ts.map +1 -1
  53. package/dist/tools/gas.js +90 -138
  54. package/dist/tools/gas.js.map +1 -1
  55. package/dist/tools/index.d.ts +1 -1
  56. package/dist/tools/index.d.ts.map +1 -1
  57. package/dist/tools/logs.d.ts +1 -1
  58. package/dist/tools/logs.d.ts.map +1 -1
  59. package/dist/tools/logs.js +36 -37
  60. package/dist/tools/logs.js.map +1 -1
  61. package/dist/tools/signatures.d.ts.map +1 -1
  62. package/dist/tools/signatures.js +67 -89
  63. package/dist/tools/signatures.js.map +1 -1
  64. package/dist/tools/transactions.d.ts +35 -5
  65. package/dist/tools/transactions.d.ts.map +1 -1
  66. package/dist/tools/transactions.js +148 -74
  67. package/dist/tools/transactions.js.map +1 -1
  68. package/dist/types.d.ts +1 -1
  69. package/dist/types.d.ts.map +1 -1
  70. package/dist/utils.d.ts +1 -1
  71. package/dist/utils.d.ts.map +1 -1
  72. package/dist/utils.js +35 -98
  73. package/dist/utils.js.map +1 -1
  74. package/dist/wallet-client.d.ts +11 -1
  75. package/dist/wallet-client.d.ts.map +1 -1
  76. package/dist/wallet-client.js +27 -9
  77. package/dist/wallet-client.js.map +1 -1
  78. package/dist/walletconnect.d.ts +22 -1
  79. package/dist/walletconnect.d.ts.map +1 -1
  80. package/dist/walletconnect.js +101 -8
  81. package/dist/walletconnect.js.map +1 -1
  82. package/package.json +17 -9
  83. package/src/anvil.ts +15 -18
  84. package/src/chain-meta.ts +8 -4
  85. package/src/chains.ts +145 -0
  86. package/src/client.ts +39 -254
  87. package/src/http-server.ts +67 -44
  88. package/src/index.ts +77 -53
  89. package/src/oauth.ts +270 -0
  90. package/src/preview.ts +3 -12
  91. package/src/tools/advanced.ts +195 -213
  92. package/src/tools/balance.ts +103 -108
  93. package/src/tools/contract-info.ts +163 -184
  94. package/src/tools/contract.ts +123 -144
  95. package/src/tools/ens.ts +290 -345
  96. package/src/tools/gas.ts +122 -170
  97. package/src/tools/index.ts +2 -2
  98. package/src/tools/logs.ts +45 -43
  99. package/src/tools/signatures.ts +68 -86
  100. package/src/tools/transactions.ts +194 -87
  101. package/src/types.ts +1 -15
  102. package/src/utils.ts +52 -110
  103. package/src/wallet-client.ts +31 -9
  104. package/src/walletconnect.ts +119 -7
package/src/utils.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { AbiParameter } from 'viem'
2
+ import type { z } from 'zod'
2
3
  import type { Config, ToolResult } from './types.js'
3
- import { z } from 'zod'
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(call: Record<string, unknown>, path: SummarizedCall[]): { errorPath: SummarizedCall[]; errorMessage: string } | null {
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
- // Convert arguments to appropriate types based on ABI
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
- return args.map((arg, index) => {
139
- if (index >= abiInputs.length) {
140
- throw new Error(`Too many arguments provided. Expected ${abiInputs.length}, got ${args.length}`)
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
- const converted: Record<string, unknown> = {}
194
-
195
- for (const [argName, argValue] of Object.entries(eventArgs)) {
196
- // Find the parameter definition for this argument name
197
- const param = abiInputs.find(input => input.name === argName)
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
- } else if (paramType === 'string') {
235
- converted[argName] = String(argValue)
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
@@ -269,7 +211,7 @@ export function parseCommandLineArgs(): Config & { showHelp?: boolean } {
269
211
 
270
212
  function getArgValue(argName: string): string | undefined {
271
213
  const args = process.argv
272
- const index = args.findIndex(arg => arg === argName)
214
+ const index = args.indexOf(argName)
273
215
  return index !== -1 && index + 1 < args.length ? args[index + 1] : undefined
274
216
  }
275
217
 
@@ -291,7 +233,7 @@ export function parseCommandLineArgs(): Config & { showHelp?: boolean } {
291
233
  config.walletConnectProjectId = process.env.WALLETCONNECT_PROJECT_ID || getArgValue('--walletconnect-project-id')
292
234
 
293
235
  // Parse custom RPC URLs
294
- const customRpcs = getArgValue('--custom-rpc')
236
+ const customRpcs = process.env.CUSTOM_RPC || getArgValue('--custom-rpc')
295
237
  if (customRpcs) {
296
238
  try {
297
239
  config.customRpcUrls = JSON.parse(customRpcs)
@@ -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 { WalletRelay, type TransactionRequest, type TransactionResponse } from 'web3-wallet-relay'
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
- const LOCAL_PORTS = [3456, 3457, 3458, 3459, 3460]
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 = `http://127.0.0.1:${port}/#t=${token}`
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 (!this.isRemote && this.pages === 0) this.openBrowser()
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.isRemote) return
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
  }
@@ -31,6 +31,16 @@ const METHODS = ['eth_sendTransaction', 'personal_sign', 'eth_signTypedData_v4']
31
31
  const EVENTS = ['chainChanged', 'accountsChanged']
32
32
  const APPROVAL_TIMEOUT = 300_000
33
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
+
34
44
  function storePath(): string {
35
45
  const dir = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'web3-tools-mcp')
36
46
  mkdirSync(dir, { recursive: true, mode: 0o700 })
@@ -41,6 +51,8 @@ export class PhoneSigner {
41
51
  private client: SignClientType | undefined
42
52
  private starting: Promise<void> | undefined
43
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
44
56
 
45
57
  constructor(private projectId: string) {}
46
58
 
@@ -56,10 +68,14 @@ export class PhoneSigner {
56
68
  this.client = (await SignClient.init({
57
69
  projectId: this.projectId,
58
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.
59
75
  metadata: {
60
- name: 'web3-tools-mcp',
61
- description: 'Blockchain tools for AI agents',
62
- url: 'https://github.com/hajnalben/web3-tools-mcp',
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(),
63
79
  icons: []
64
80
  }
65
81
  })) as SignClientType
@@ -97,7 +113,14 @@ export class PhoneSigner {
97
113
  * background — a tool result only reaches the user once it returns, so blocking here
98
114
  * would hide the QR they are supposed to scan.
99
115
  */
100
- async pair(chains: ChainName[]): Promise<string> {
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
+
101
124
  await this.start()
102
125
  if (!this.client) throw new Error('WalletConnect failed to start')
103
126
 
@@ -139,11 +162,100 @@ export class PhoneSigner {
139
162
  })
140
163
  }
141
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
+
142
202
  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' } })
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 = []
146
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
+ )
147
259
  }
148
260
 
149
261
  let signer: PhoneSigner | null = null