web3-tools-mcp 2.0.1 → 3.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 (167) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +5 -0
  3. package/dist/anvil.d.ts +17 -19
  4. package/dist/anvil.d.ts.map +1 -1
  5. package/dist/anvil.js +124 -228
  6. package/dist/anvil.js.map +1 -1
  7. package/dist/chains.d.ts +9 -1
  8. package/dist/chains.d.ts.map +1 -1
  9. package/dist/chains.js +11 -2
  10. package/dist/chains.js.map +1 -1
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js.map +1 -1
  13. package/dist/context.d.ts +22 -0
  14. package/dist/context.d.ts.map +1 -0
  15. package/dist/context.js +24 -0
  16. package/dist/context.js.map +1 -0
  17. package/dist/hosted.d.ts +12 -0
  18. package/dist/hosted.d.ts.map +1 -0
  19. package/dist/hosted.js +12 -0
  20. package/dist/hosted.js.map +1 -0
  21. package/dist/http-server.d.ts +13 -0
  22. package/dist/http-server.d.ts.map +1 -1
  23. package/dist/http-server.js +13 -4
  24. package/dist/http-server.js.map +1 -1
  25. package/dist/index.js +19 -15
  26. package/dist/index.js.map +1 -1
  27. package/dist/lib.d.ts +24 -0
  28. package/dist/lib.d.ts.map +1 -0
  29. package/dist/lib.js +23 -0
  30. package/dist/lib.js.map +1 -0
  31. package/dist/log.d.ts +16 -0
  32. package/dist/log.d.ts.map +1 -0
  33. package/dist/log.js +20 -0
  34. package/dist/log.js.map +1 -0
  35. package/dist/mcp-server.d.ts +4 -0
  36. package/dist/mcp-server.d.ts.map +1 -0
  37. package/dist/mcp-server.js +43 -0
  38. package/dist/mcp-server.js.map +1 -0
  39. package/dist/oauth.d.ts +41 -6
  40. package/dist/oauth.d.ts.map +1 -1
  41. package/dist/oauth.js +75 -41
  42. package/dist/oauth.js.map +1 -1
  43. package/dist/preview.d.ts +7 -0
  44. package/dist/preview.d.ts.map +1 -1
  45. package/dist/preview.js +14 -2
  46. package/dist/preview.js.map +1 -1
  47. package/dist/signing-requests.d.ts +81 -0
  48. package/dist/signing-requests.d.ts.map +1 -0
  49. package/dist/signing-requests.js +138 -0
  50. package/dist/signing-requests.js.map +1 -0
  51. package/dist/tools/index.d.ts +27 -1
  52. package/dist/tools/index.d.ts.map +1 -1
  53. package/dist/tools/index.js +37 -15
  54. package/dist/tools/index.js.map +1 -1
  55. package/dist/tools/{advanced.d.ts → read/advanced.d.ts} +30 -26
  56. package/dist/tools/read/advanced.d.ts.map +1 -0
  57. package/dist/tools/{advanced.js → read/advanced.js} +10 -10
  58. package/dist/tools/read/advanced.js.map +1 -0
  59. package/dist/tools/{balance.d.ts → read/balance.d.ts} +8 -7
  60. package/dist/tools/read/balance.d.ts.map +1 -0
  61. package/dist/tools/{balance.js → read/balance.js} +2 -2
  62. package/dist/tools/read/balance.js.map +1 -0
  63. package/dist/tools/{contract-info.d.ts → read/contract-info.d.ts} +18 -15
  64. package/dist/tools/read/contract-info.d.ts.map +1 -0
  65. package/dist/tools/{contract-info.js → read/contract-info.js} +2 -2
  66. package/dist/tools/read/contract-info.js.map +1 -0
  67. package/dist/tools/{contract.d.ts → read/contract.d.ts} +14 -12
  68. package/dist/tools/read/contract.d.ts.map +1 -0
  69. package/dist/tools/{contract.js → read/contract.js} +2 -2
  70. package/dist/tools/read/contract.js.map +1 -0
  71. package/dist/tools/{ens.d.ts → read/ens.d.ts} +30 -25
  72. package/dist/tools/read/ens.d.ts.map +1 -0
  73. package/dist/tools/{ens.js → read/ens.js} +2 -2
  74. package/dist/tools/read/ens.js.map +1 -0
  75. package/dist/tools/read/gas.d.ts +171 -0
  76. package/dist/tools/read/gas.d.ts.map +1 -0
  77. package/dist/tools/read/gas.js +368 -0
  78. package/dist/tools/read/gas.js.map +1 -0
  79. package/dist/tools/{logs.d.ts → read/logs.d.ts} +6 -5
  80. package/dist/tools/read/logs.d.ts.map +1 -0
  81. package/dist/tools/{logs.js → read/logs.js} +2 -2
  82. package/dist/tools/read/logs.js.map +1 -0
  83. package/dist/tools/{signatures.d.ts → read/signatures.d.ts} +8 -4
  84. package/dist/tools/read/signatures.d.ts.map +1 -0
  85. package/dist/tools/{signatures.js → read/signatures.js} +1 -1
  86. package/dist/tools/read/signatures.js.map +1 -0
  87. package/dist/tools/read/signing.d.ts +27 -0
  88. package/dist/tools/read/signing.d.ts.map +1 -0
  89. package/dist/tools/read/signing.js +75 -0
  90. package/dist/tools/read/signing.js.map +1 -0
  91. package/dist/tools/sign/errors.d.ts +9 -0
  92. package/dist/tools/sign/errors.d.ts.map +1 -0
  93. package/dist/tools/sign/errors.js +29 -0
  94. package/dist/tools/sign/errors.js.map +1 -0
  95. package/dist/tools/{transactions.d.ts → sign/transactions.d.ts} +48 -62
  96. package/dist/tools/sign/transactions.d.ts.map +1 -0
  97. package/dist/tools/sign/transactions.js +316 -0
  98. package/dist/tools/sign/transactions.js.map +1 -0
  99. package/dist/tools/sign/wallet.d.ts +50 -0
  100. package/dist/tools/sign/wallet.d.ts.map +1 -0
  101. package/dist/tools/sign/wallet.js +180 -0
  102. package/dist/tools/sign/wallet.js.map +1 -0
  103. package/dist/utils.d.ts +5 -2
  104. package/dist/utils.d.ts.map +1 -1
  105. package/dist/utils.js +5 -2
  106. package/dist/utils.js.map +1 -1
  107. package/dist/wallet-client.d.ts +33 -4
  108. package/dist/wallet-client.d.ts.map +1 -1
  109. package/dist/wallet-client.js +90 -24
  110. package/dist/wallet-client.js.map +1 -1
  111. package/dist/walletconnect.d.ts +111 -13
  112. package/dist/walletconnect.d.ts.map +1 -1
  113. package/dist/walletconnect.js +248 -68
  114. package/dist/walletconnect.js.map +1 -1
  115. package/package.json +10 -4
  116. package/src/anvil.ts +150 -271
  117. package/src/chains.ts +14 -2
  118. package/src/client.ts +0 -1
  119. package/src/context.ts +27 -0
  120. package/src/hosted.ts +11 -0
  121. package/src/http-server.ts +30 -5
  122. package/src/index.ts +19 -16
  123. package/src/lib.ts +24 -0
  124. package/src/log.ts +25 -0
  125. package/src/mcp-server.ts +47 -0
  126. package/src/oauth.ts +98 -45
  127. package/src/preview.ts +21 -2
  128. package/src/signing-requests.ts +198 -0
  129. package/src/tools/index.ts +67 -16
  130. package/src/tools/{advanced.ts → read/advanced.ts} +13 -11
  131. package/src/tools/{balance.ts → read/balance.ts} +3 -3
  132. package/src/tools/{contract-info.ts → read/contract-info.ts} +3 -3
  133. package/src/tools/{contract.ts → read/contract.ts} +3 -3
  134. package/src/tools/{ens.ts → read/ens.ts} +3 -3
  135. package/src/tools/read/gas.ts +480 -0
  136. package/src/tools/{logs.ts → read/logs.ts} +3 -3
  137. package/src/tools/{signatures.ts → read/signatures.ts} +2 -2
  138. package/src/tools/read/signing.ts +86 -0
  139. package/src/tools/sign/errors.ts +28 -0
  140. package/src/tools/sign/transactions.ts +428 -0
  141. package/src/tools/sign/wallet.ts +216 -0
  142. package/src/utils.ts +5 -2
  143. package/src/wallet-client.ts +91 -22
  144. package/src/walletconnect.ts +286 -66
  145. package/dist/tools/advanced.d.ts.map +0 -1
  146. package/dist/tools/advanced.js.map +0 -1
  147. package/dist/tools/balance.d.ts.map +0 -1
  148. package/dist/tools/balance.js.map +0 -1
  149. package/dist/tools/contract-info.d.ts.map +0 -1
  150. package/dist/tools/contract-info.js.map +0 -1
  151. package/dist/tools/contract.d.ts.map +0 -1
  152. package/dist/tools/contract.js.map +0 -1
  153. package/dist/tools/ens.d.ts.map +0 -1
  154. package/dist/tools/ens.js.map +0 -1
  155. package/dist/tools/gas.d.ts +0 -99
  156. package/dist/tools/gas.d.ts.map +0 -1
  157. package/dist/tools/gas.js +0 -181
  158. package/dist/tools/gas.js.map +0 -1
  159. package/dist/tools/logs.d.ts.map +0 -1
  160. package/dist/tools/logs.js.map +0 -1
  161. package/dist/tools/signatures.d.ts.map +0 -1
  162. package/dist/tools/signatures.js.map +0 -1
  163. package/dist/tools/transactions.d.ts.map +0 -1
  164. package/dist/tools/transactions.js +0 -361
  165. package/dist/tools/transactions.js.map +0 -1
  166. package/src/tools/gas.ts +0 -219
  167. package/src/tools/transactions.ts +0 -452
@@ -3,18 +3,23 @@ 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, type TransactionRequest, type TransactionResponse, WalletRelay } from 'web3-wallet-relay'
6
+ import { LOCAL_PORT_ATTEMPTS, roomToken, type TransactionRequest, type TransactionResponse, WalletRelay } from 'web3-wallet-relay'
7
7
  import { WebSocket } from 'ws'
8
+ import { HOSTED } from './hosted.js'
8
9
 
9
10
  const REQUEST_TIMEOUT = 300_000
10
11
 
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)
12
+ /**
13
+ * How long a client with nothing in flight keeps its socket. A hosted server makes one per
14
+ * person who ever signed, and without this each would hold a relay connection for the life
15
+ * of the process. The next call reconnects.
16
+ */
17
+ const IDLE_TIMEOUT = 10 * 60_000
14
18
 
15
- function pairingUrlFor(port: number, token: string): string {
19
+ /** Always the signer token: this URL goes to a browser, which may not act as a requester. */
20
+ function pairingUrlFor(port: number, secret: string, identity: string): string {
16
21
  const base = HOSTED ? (process.env.MCP_PUBLIC_URL ?? `http://localhost:${port}`) : `http://127.0.0.1:${port}`
17
- return `${base.replace(/\/$/, '')}/#t=${token}`
22
+ return `${base.replace(/\/$/, '')}/#t=${roomToken(secret, identity, 'signer')}`
18
23
  }
19
24
  const SIGNER_WAIT_TIMEOUT = 30_000
20
25
  const CONNECT_ATTEMPTS = 8
@@ -85,13 +90,23 @@ export class WalletClient {
85
90
  private pageUrl: string | undefined
86
91
  private pageAgent: string | undefined
87
92
  private lastOpenedAt = 0
88
- /** Instance field so tests can shorten the wait instead of sitting out the real one. */
93
+ private idleTimer: NodeJS.Timeout | null = null
94
+ /** Instance fields so tests can shorten the waits instead of sitting out the real ones. */
89
95
  private signerWaitTimeout = SIGNER_WAIT_TIMEOUT
96
+ private idleTimeout = IDLE_TIMEOUT
90
97
 
91
98
  private readonly remoteUrl = process.env.WALLET_SERVER_URL
92
99
  private readonly remoteToken = process.env.WALLET_TOKEN
93
100
 
94
- constructor(private relayOptions: { port?: number; token?: string } = {}) {}
101
+ /**
102
+ * `identity` is the room this client works in. Everything below it — the socket's token,
103
+ * the pairing link it hands out — is signed for that room, so two identities on one relay
104
+ * cannot see each other's pages or transactions.
105
+ */
106
+ constructor(
107
+ private identity: string,
108
+ private relayOptions: { port?: number; token?: string } = {}
109
+ ) {}
95
110
 
96
111
  get isRemote(): boolean {
97
112
  return Boolean(this.remoteUrl)
@@ -124,8 +139,8 @@ export class WalletClient {
124
139
  // minute to come back.
125
140
  for (let attempt = 0; attempt < CONNECT_ATTEMPTS; attempt++) {
126
141
  try {
127
- await this.openSocket(url, this.remoteToken)
128
- this.pairingUrl = `${this.remoteUrl}#t=${this.remoteToken}`
142
+ await this.openSocket(url, roomToken(this.remoteToken, this.identity, 'requester'))
143
+ this.pairingUrl = `${this.remoteUrl}#t=${roomToken(this.remoteToken, this.identity, 'signer')}`
129
144
  return
130
145
  } catch (error) {
131
146
  lastError = error as Error
@@ -147,23 +162,24 @@ export class WalletClient {
147
162
  * the fix would be for the higher port to hand over when a lower one answers.
148
163
  */
149
164
  private async connectLocal(): Promise<void> {
150
- const token = this.relayOptions.token ?? localToken()
165
+ const secret = this.relayOptions.token ?? localToken()
166
+ const requester = roomToken(secret, this.identity, 'requester')
151
167
  const ports = this.relayOptions.port ? [this.relayOptions.port] : LOCAL_PORTS
152
168
 
153
169
  for (const port of ports) {
154
170
  try {
155
- await this.openSocket(`ws://127.0.0.1:${port}`, token)
156
- this.pairingUrl = pairingUrlFor(port, token)
171
+ await this.openSocket(`ws://127.0.0.1:${port}`, requester)
172
+ this.pairingUrl = pairingUrlFor(port, secret, this.identity)
157
173
  return
158
174
  } catch {
159
175
  // Nothing on this port, or something that isn't our relay — keep looking.
160
176
  }
161
177
  }
162
178
 
163
- this.relay = new WalletRelay({ port: ports[0], token })
179
+ this.relay = new WalletRelay({ port: ports[0], token: secret })
164
180
  await this.relay.start()
165
- await this.openSocket(`ws://127.0.0.1:${this.relay.getPort()}`, token)
166
- this.pairingUrl = this.relay.getUrl()
181
+ await this.openSocket(`ws://127.0.0.1:${this.relay.getPort()}`, requester)
182
+ this.pairingUrl = pairingUrlFor(this.relay.getPort(), secret, this.identity)
167
183
  }
168
184
 
169
185
  /**
@@ -198,6 +214,7 @@ export class WalletClient {
198
214
  clearTimeout(timer)
199
215
  ws.off('message', onHandshake)
200
216
  this.ws = ws
217
+ this.rearmIdle()
201
218
  resolve()
202
219
  }
203
220
 
@@ -249,11 +266,27 @@ export class WalletClient {
249
266
  const pending = this.pending.get(message.id)
250
267
  if (!pending) return
251
268
  this.pending.delete(message.id)
269
+ this.rearmIdle()
252
270
 
253
271
  if (message.success) pending.resolve(message.result)
254
272
  else pending.reject(new Error(message.error || 'Transaction failed'))
255
273
  }
256
274
 
275
+ /**
276
+ * Drop the socket, and this client, once nothing has needed it for a while. The owner of
277
+ * a local relay is exempt: it is the one that has to stop that relay at shutdown.
278
+ */
279
+ private rearmIdle() {
280
+ if (this.idleTimer) clearTimeout(this.idleTimer)
281
+ if (this.relay) return
282
+ this.idleTimer = setTimeout(() => {
283
+ if (this.pending.size > 0) return this.rearmIdle()
284
+ this.ws?.close()
285
+ if (clients.get(this.identity) === this) clients.delete(this.identity)
286
+ }, this.idleTimeout)
287
+ this.idleTimer.unref()
288
+ }
289
+
257
290
  /** Wait for a wallet page to connect, opening the browser first when running locally. */
258
291
  async waitForSigner(): Promise<void> {
259
292
  await this.connect()
@@ -291,10 +324,14 @@ export class WalletClient {
291
324
 
292
325
  return new Promise((resolve, reject) => {
293
326
  this.pending.set(request.id, { resolve, reject })
327
+ this.rearmIdle()
294
328
  ws.send(JSON.stringify(request))
295
329
 
296
330
  setTimeout(() => {
297
- if (this.pending.delete(request.id)) reject(new Error('Transaction request timed out'))
331
+ if (this.pending.delete(request.id)) {
332
+ this.rearmIdle()
333
+ reject(new Error('Transaction request timed out'))
334
+ }
298
335
  }, REQUEST_TIMEOUT)
299
336
  })
300
337
  }
@@ -318,8 +355,11 @@ export class WalletClient {
318
355
  */
319
356
  getUrl(): string {
320
357
  if (this.pairingUrl) return this.pairingUrl
321
- if (this.remoteUrl) return `${this.remoteUrl}#t=${this.remoteToken ?? ''}`
322
- return `http://127.0.0.1:${LOCAL_PORTS[0]}/#t=${this.relayOptions.token ?? localToken()}`
358
+ if (this.remoteUrl) {
359
+ const token = this.remoteToken ? roomToken(this.remoteToken, this.identity, 'signer') : ''
360
+ return `${this.remoteUrl}#t=${token}`
361
+ }
362
+ return pairingUrlFor(LOCAL_PORTS[0], this.relayOptions.token ?? localToken(), this.identity)
323
363
  }
324
364
 
325
365
  /** A relay we do not own, or a server with no desktop, has no browser for us to open. */
@@ -388,15 +428,44 @@ export class WalletClient {
388
428
  }
389
429
 
390
430
  async stop(): Promise<void> {
431
+ if (this.idleTimer) clearTimeout(this.idleTimer)
391
432
  this.ws?.close()
392
433
  await this.relay?.stop()
393
434
  this.relay = null
394
435
  }
395
436
  }
396
437
 
397
- let client: WalletClient | null = null
438
+ const clients = new Map<string, WalletClient>()
398
439
 
399
- export function getWalletClient(relayOptions?: { port?: number; token?: string }): WalletClient {
400
- if (!client) client = new WalletClient(relayOptions)
440
+ let relayOptions: { port?: number; token?: string } = {}
441
+
442
+ /**
443
+ * Where the relay is, for every client this process makes.
444
+ *
445
+ * A property of the deployment rather than of any one person: a hosted server runs the
446
+ * relay on its own port with its own secret, and each identity's client has to reach that
447
+ * one. Set it before anybody signs — a client that cannot find a relay starts its own,
448
+ * which then has nobody in it.
449
+ */
450
+ export function configureWalletRelay(options: { port?: number; token?: string }): void {
451
+ relayOptions = options
452
+ }
453
+
454
+ /**
455
+ * One client per identity, because everything it holds is that person's: the socket, the
456
+ * in-flight requests, and which page is open with which account. A single-user server has
457
+ * exactly one, which is every self-hosted run.
458
+ */
459
+ export function getWalletClient(identity: string): WalletClient {
460
+ let client = clients.get(identity)
461
+ if (!client) {
462
+ client = new WalletClient(identity, relayOptions)
463
+ clients.set(identity, client)
464
+ }
401
465
  return client
402
466
  }
467
+
468
+ /** Every live client, for shutdown — callers outside a tool call have no identity of their own. */
469
+ export function allWalletClients(): WalletClient[] {
470
+ return [...clients.values()]
471
+ }
@@ -1,8 +1,11 @@
1
1
  import { mkdirSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
2
3
  import { homedir } from 'node:os'
3
4
  import { join } from 'node:path'
4
5
  import { getClientManager } from './client.js'
6
+ import { DEFAULT_IDENTITY } from './context.js'
5
7
  import { getKeyValueStorage } from './kv-storage.js'
8
+ import { log } from './log.js'
6
9
  import type { ChainName } from './types.js'
7
10
 
8
11
  /**
@@ -27,6 +30,42 @@ interface Session {
27
30
  peer?: string
28
31
  }
29
32
 
33
+ /** Sessions do not record who paired them, so the mapping is kept alongside in the same store. */
34
+ const BINDINGS_KEY = 'web3-tools-mcp:session-identities'
35
+
36
+ /**
37
+ * The sessions an identity may sign with.
38
+ *
39
+ * Matched by the binding recorded when the session was approved, never by the accounts it
40
+ * reports — a wallet states its own account list, so trusting that would let a peer claim
41
+ * someone else's address and be handed their signing requests.
42
+ *
43
+ * An unbound session is one paired before bindings existed. It counts only for the shared
44
+ * identity, which is a server with a single user; under a real identity it belongs to
45
+ * nobody rather than to whoever asks first.
46
+ */
47
+ export function ownedSessions<T extends { topic: string }>(live: T[], bindings: Record<string, string>, identity: string): T[] {
48
+ return live.filter((s) => bindings[s.topic] === identity || (identity === DEFAULT_IDENTITY && !bindings[s.topic]))
49
+ }
50
+
51
+ /**
52
+ * Bindings with no session behind them any more.
53
+ *
54
+ * An empty session list yields nothing: a store that has not hydrated looks exactly like one
55
+ * with no sessions, and clearing every binding would strand wallets that are still paired.
56
+ */
57
+ export function orphanedBindings(live: { topic: string }[], bindings: Record<string, string>): string[] {
58
+ if (live.length === 0) return []
59
+ const known = new Set(live.map((session) => session.topic))
60
+ return Object.keys(bindings).filter((topic) => !known.has(topic))
61
+ }
62
+
63
+ /** What was paired before and is not paired now — a wallet that ended its own session. */
64
+ export function droppedSessions<T extends { topic: string }>(before: T[], after: { topic: string }[]): T[] {
65
+ const live = new Set(after.map((session) => session.topic))
66
+ return before.filter((session) => !live.has(session.topic))
67
+ }
68
+
30
69
  const METHODS = ['eth_sendTransaction', 'personal_sign', 'eth_signTypedData_v4']
31
70
  const EVENTS = ['chainChanged', 'accountsChanged']
32
71
  const APPROVAL_TIMEOUT = 300_000
@@ -36,9 +75,7 @@ const APPROVAL_TIMEOUT = 300_000
36
75
  * a local one is only reachable from the machine it runs on, and says so.
37
76
  */
38
77
  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'
78
+ return process.env.MCP_PUBLIC_URL?.replace(/\/$/, '') ?? 'http://localhost'
42
79
  }
43
80
 
44
81
  function storePath(): string {
@@ -53,6 +90,8 @@ export class PhoneSigner {
53
90
  private pairing: { uri: string; expiresAt: number } | undefined
54
91
  /** Label shown in the wallet; whoever is driving this server says who they are. */
55
92
  private agent: string | undefined
93
+ /** The last edit of the bindings key; the next one queues behind it. */
94
+ private bindingsEdit: Promise<unknown> = Promise.resolve()
56
95
 
57
96
  constructor(private projectId: string) {}
58
97
 
@@ -86,26 +125,100 @@ export class PhoneSigner {
86
125
  return this.starting
87
126
  }
88
127
 
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
128
+ /**
129
+ * Who each session belongs to, kept in the client's own store so it lands in the file or
130
+ * in Redis exactly as the sessions themselves do.
131
+ */
132
+ private async bindings(): Promise<Record<string, string>> {
133
+ return (await this.client?.core.storage.getItem<Record<string, string>>(BINDINGS_KEY)) ?? {}
134
+ }
135
+
136
+ /**
137
+ * All bindings live under one key, so an edit is a read-modify-write — and two approvals
138
+ * settling together would each read the same map and the second would erase the first.
139
+ * Edits queue behind each other instead.
140
+ *
141
+ * ponytail: serialised per process only. Several server processes on one Redis still
142
+ * race; one key per topic would end that.
143
+ */
144
+ private updateBindings(edit: (bindings: Record<string, string>) => void): Promise<void> {
145
+ const next = this.bindingsEdit.then(async () => {
146
+ const bindings = await this.bindings()
147
+ edit(bindings)
148
+ await this.client?.core.storage.setItem(BINDINGS_KEY, bindings)
149
+ })
150
+ this.bindingsEdit = next.catch(() => {})
151
+ return next
152
+ }
153
+
154
+ private bind(topic: string, identity: string): Promise<void> {
155
+ return this.updateBindings((bindings) => {
156
+ bindings[topic] = identity
157
+ })
158
+ }
159
+
160
+ /**
161
+ * Some wallets keep one session per site, so approving a second pairing in the same app
162
+ * ends the first — the session vanishes from the store and the binding is left pointing at
163
+ * nothing. Worth saying out loud: `pair_phone_wallet` has just listed that wallet as still
164
+ * paired, and the next `wallet_status` will quietly show one fewer.
165
+ */
166
+ private async reportReplaced(identity: string, before: Session[]): Promise<void> {
167
+ const dropped = droppedSessions(before, await this.sessions(identity))
168
+ if (dropped.length === 0) return
169
+
170
+ for (const session of dropped) {
171
+ log(
172
+ 'warning',
173
+ 'WalletConnect',
174
+ `${session.peer ?? 'A wallet'} ended its earlier pairing for ${session.accounts[0] ?? 'an account'} when this one was approved — ` +
175
+ 'it keeps one session per site. Pair a different wallet app to hold both at once.'
176
+ )
104
177
  }
178
+
179
+ await this.sweepBindings()
105
180
  }
106
181
 
107
- async isPaired(): Promise<boolean> {
108
- return Boolean(await this.session())
182
+ /**
183
+ * Bindings outlive the sessions they point at, so they accumulate. Swept here rather than
184
+ * on every read: a pairing has just settled, which is the one moment the session store is
185
+ * known to be populated.
186
+ */
187
+ private async sweepBindings(): Promise<void> {
188
+ const orphans = orphanedBindings(this.client?.session.getAll() ?? [], await this.bindings())
189
+ if (orphans.length === 0) return
190
+
191
+ await this.updateBindings((bindings) => {
192
+ for (const topic of orphans) delete bindings[topic]
193
+ })
194
+ log('info', 'WalletConnect', `Cleared ${orphans.length} binding(s) whose session is gone`)
195
+ }
196
+
197
+ /** Every session this identity owns, live or not — what disconnecting should sweep. */
198
+ private async owned(identity: string) {
199
+ await this.start()
200
+ return ownedSessions(this.client?.session.getAll() ?? [], await this.bindings(), identity)
201
+ }
202
+
203
+ /**
204
+ * One of this identity's paired wallets.
205
+ *
206
+ * `account` picks between them when more than one is paired; without it the most recent
207
+ * wins, which is the only one most people have. Selecting by address is safe here in a
208
+ * way that *identifying* by address would not be: the candidates are already narrowed to
209
+ * sessions this identity was bound to when they were approved, so a wallet claiming an
210
+ * address it does not hold can only reach its own owner.
211
+ */
212
+ async session(identity: string = DEFAULT_IDENTITY, account?: string): Promise<Session | undefined> {
213
+ const paired = await this.sessions(identity)
214
+ if (!account) return paired[paired.length - 1]
215
+
216
+ const wanted = account.toLowerCase()
217
+ return paired.find((session) => session.accounts.some((a) => a.toLowerCase() === wanted))
218
+ }
219
+
220
+ async isPaired(identity?: string): Promise<boolean> {
221
+ return Boolean(await this.session(identity))
109
222
  }
110
223
 
111
224
  /**
@@ -113,22 +226,41 @@ export class PhoneSigner {
113
226
  * background — a tool result only reaches the user once it returns, so blocking here
114
227
  * would hide the QR they are supposed to scan.
115
228
  */
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
- // Close it rather than dropping the reference. Two clients sharing one storage
121
- // directory overwrite each other's subscription record, and the one that loses stops
122
- // receiving replies on its own session — a signing request then reaches the wallet,
123
- // gets approved, and the answer is published where nobody is listening.
124
- await this.client.core.relayer.transportClose().catch(() => {})
125
- this.client = undefined
126
- }
127
- if (agent) this.agent = agent
229
+ /**
230
+ * Name who is asking, for the wallet's approval dialog. Takes effect only before the
231
+ * client starts, because WalletConnect fixes metadata at init — call it first.
232
+ *
233
+ * There is deliberately no way to relabel a running client. Replacing it leaves the old
234
+ * one's engine alive, and its heartbeat sweeps "orphaned" subscriptions against its own,
235
+ * empty session list — unsubscribing the new session's topic two seconds after it
236
+ * settles, so every reply the wallet sends goes to a topic nobody is listening on. A
237
+ * stale name in the dialog is a far smaller cost than signing that silently hangs.
238
+ */
239
+ /**
240
+ * Hand every session one identity owns to another. A login pairs before anybody is known,
241
+ * under a placeholder, and the wallet it proved becomes that person's to sign with.
242
+ */
243
+ async rebind(from: string, to: string): Promise<void> {
244
+ await this.start()
245
+ await this.updateBindings((bindings) => {
246
+ for (const [topic, identity] of Object.entries(bindings)) {
247
+ if (identity === from) bindings[topic] = to
248
+ }
249
+ })
250
+ }
251
+
252
+ nameAgent(agent?: string): void {
253
+ if (agent && !this.client) this.agent = agent
254
+ }
128
255
 
256
+ async pair(chains: ChainName[], identity: string = DEFAULT_IDENTITY): Promise<string> {
129
257
  await this.start()
258
+ log('info', 'WalletConnect', `Pairing as "${this.agent ?? 'default name'}" for ${chains.length} chain(s)`)
130
259
  if (!this.client) throw new Error('WalletConnect failed to start')
131
260
 
261
+ // What this identity had before, so the approval can tell whether the wallet kept it.
262
+ const before = await this.sessions(identity)
263
+
132
264
  const clientManager = getClientManager()
133
265
  const eip155 = chains.map((chain) => `eip155:${clientManager.getChainId(chain)}`)
134
266
 
@@ -142,16 +274,37 @@ export class PhoneSigner {
142
274
  this.pairing = { uri, expiresAt: Date.now() + APPROVAL_TIMEOUT }
143
275
 
144
276
  approval()
145
- .then((session) => console.error(`[WalletConnect] Paired with ${session.peer?.metadata?.name ?? 'a wallet'}`))
146
- .catch((error) => console.error('[WalletConnect] Pairing was not completed:', error.message))
277
+ .then(async (session) => {
278
+ // Recorded as the session settles: whoever asked for this pairing owns it.
279
+ await this.bind(session.topic, identity)
280
+ log('info', 'WalletConnect', `Paired with ${session.peer?.metadata?.name ?? 'a wallet'}`)
281
+ await this.reportReplaced(identity, before)
282
+ })
283
+ .catch((error) => log('warning', 'WalletConnect', `Pairing was not completed: ${error.message}`))
147
284
 
148
285
  return uri
149
286
  }
150
287
 
151
- /** Send a request to the paired wallet. Throws if the chain is outside the session. */
152
- async request(chain: ChainName, method: string, params: unknown[]): Promise<unknown> {
153
- const session = await this.session()
154
- if (!session || !this.client) throw new Error('No phone wallet paired')
288
+ /** Send a request to one of this identity's wallets. Throws if the chain is outside the session. */
289
+ async request(
290
+ chain: ChainName,
291
+ method: string,
292
+ params: unknown[],
293
+ target: {
294
+ identity?: string
295
+ account?: string
296
+ /**
297
+ * Called once the request is with the wallet and only a human is left. Everything that
298
+ * can fail quickly — no session, a chain this one never approved — has already thrown
299
+ * by then, so the caller can stop waiting without hiding a real error.
300
+ */
301
+ onWaiting?: () => void
302
+ } = {}
303
+ ): Promise<unknown> {
304
+ const session = await this.session(target.identity, target.account)
305
+ if (!session || !this.client) {
306
+ throw new Error(target.account ? `No phone wallet paired for account ${target.account}` : 'No phone wallet paired')
307
+ }
155
308
 
156
309
  const chainId = getClientManager().getChainId(chain)
157
310
  if (!session.chains.includes(chainId)) {
@@ -160,52 +313,92 @@ export class PhoneSigner {
160
313
  )
161
314
  }
162
315
 
163
- return this.client.request({
164
- topic: session.topic,
165
- chainId: `eip155:${chainId}`,
166
- request: { method, params }
167
- })
316
+ const started = Date.now()
317
+ log(
318
+ 'info',
319
+ 'WalletConnect',
320
+ `→ ${method} on ${chain}, session ${session.topic.slice(0, 8)}, waiting for ${session.peer ?? 'wallet'}`
321
+ )
322
+ try {
323
+ const answer = this.client.request({
324
+ topic: session.topic,
325
+ chainId: `eip155:${chainId}`,
326
+ request: { method, params }
327
+ })
328
+
329
+ // Neither MetaMask nor Rabby pushes a notification, so nobody is going to notice this
330
+ // in the next few seconds unless they already had the wallet open.
331
+ target.onWaiting?.()
332
+
333
+ const result = await answer
334
+ log('info', 'WalletConnect', `← ${method} answered in ${Date.now() - started}ms`)
335
+ return result
336
+ } catch (error) {
337
+ const reason = (error as { message?: string })?.message ?? String(error)
338
+ log('warning', 'WalletConnect', `← ${method} failed after ${Date.now() - started}ms: ${reason}`)
339
+ throw error
340
+ }
168
341
  }
169
342
 
170
- /** Every live session, not just the one that would be used for signing. */
171
- async sessions(): Promise<Session[]> {
172
- await this.start()
173
- return (this.client?.session.getAll() ?? []).map((session) => ({
174
- topic: session.topic,
175
- accounts: [...new Set((session.namespaces.eip155?.accounts ?? []).map((a) => a.split(':')[2] as string))],
176
- chains: [...new Set((session.namespaces.eip155?.accounts ?? []).map((a) => Number(a.split(':')[1])))],
177
- peer: session.peer?.metadata?.name
178
- }))
343
+ /** Every wallet this identity can currently sign with, oldest pairing first. */
344
+ async sessions(identity: string = DEFAULT_IDENTITY): Promise<Session[]> {
345
+ const live = (await this.owned(identity)).filter((session) => session.expiry * 1000 > Date.now())
346
+ return live.map((session) => {
347
+ // "eip155:8453:0xabc…" → address / chain id
348
+ const accounts = session.namespaces.eip155?.accounts ?? []
349
+ return {
350
+ topic: session.topic,
351
+ accounts: [...new Set(accounts.map((a) => a.split(':')[2] as string))],
352
+ chains: [...new Set(accounts.map((a) => Number(a.split(':')[1])))],
353
+ peer: session.peer?.metadata?.name
354
+ }
355
+ })
179
356
  }
180
357
 
181
358
  /**
182
- * Disconnect everything: sessions, and the pairings underneath them. Each pairing
183
- * attempt leaves one behind whether or not a wallet ever approved it, so they
184
- * accumulate quietly in the store.
359
+ * Disconnect one of this identity's wallets, or all of them when no account is named.
360
+ *
361
+ * Pairings underneath are swept only once no session is left at all. Each pairing attempt
362
+ * leaves one behind whether or not a wallet ever approved it, so they accumulate quietly —
363
+ * but a pairing does not record who made it, so dropping them while anyone still has a
364
+ * session could take somebody else's with it.
185
365
  */
186
- async disconnectAll(): Promise<{ sessions: number; pairings: number }> {
366
+ async disconnect(identity: string = DEFAULT_IDENTITY, account?: string): Promise<{ sessions: number; pairings: number }> {
187
367
  await this.start()
188
368
  if (!this.client) return { sessions: 0, pairings: 0 }
189
369
 
190
- const sessions = this.client.session.getAll()
191
- for (const session of sessions) {
370
+ let topics: Set<string>
371
+ if (account) {
372
+ // Same rule that picks a wallet to sign with, so you can drop exactly what you chose.
373
+ const match = await this.session(identity, account)
374
+ if (!match) throw new Error(`No paired wallet holds ${account}`)
375
+ topics = new Set([match.topic])
376
+ } else {
377
+ // Expired sessions included: they are still this identity's to clean up.
378
+ topics = new Set((await this.owned(identity)).map((session) => session.topic))
379
+ }
380
+
381
+ for (const topic of topics) {
192
382
  await this.client
193
- .disconnect({ topic: session.topic, reason: { code: 6000, message: 'User disconnected' } })
194
- .catch((error) => console.error('[WalletConnect] Could not disconnect a session:', error.message))
383
+ .disconnect({ topic, reason: { code: 6000, message: 'User disconnected' } })
384
+ .catch((error) => log('warning', 'WalletConnect', `Could not disconnect a session: ${error.message}`))
195
385
  }
196
386
 
387
+ await this.updateBindings((bindings) => {
388
+ for (const topic of topics) delete bindings[topic]
389
+ })
390
+
391
+ const remaining = this.client.session.getAll().length
392
+ if (remaining > 0) return { sessions: topics.size, pairings: 0 }
393
+
197
394
  const pairings = this.client.core.pairing.getPairings()
198
395
  for (const pairing of pairings) {
199
396
  await this.client.core.pairing
200
397
  .disconnect({ topic: pairing.topic })
201
- .catch((error) => console.error('[WalletConnect] Could not drop a pairing:', error.message))
398
+ .catch((error) => log('warning', 'WalletConnect', `Could not drop a pairing: ${error.message}`))
202
399
  }
203
400
 
204
- return { sessions: sessions.length, pairings: pairings.length }
205
- }
206
-
207
- async unpair(): Promise<void> {
208
- await this.disconnectAll()
401
+ return { sessions: topics.size, pairings: pairings.length }
209
402
  }
210
403
  }
211
404
 
@@ -263,6 +456,33 @@ export async function pairingLinks(projectId: string, uri: string, search?: stri
263
456
  )
264
457
  }
265
458
 
459
+ // qrcode-terminal's own encoder, already a dependency for the chat QR; it ships untyped.
460
+ const require = createRequire(import.meta.url)
461
+ const QRCode = require('qrcode-terminal/vendor/QRCode') as new (
462
+ typeNumber: number,
463
+ level: number
464
+ ) => { addData(text: string): void; make(): void; getModuleCount(): number; isDark(row: number, col: number): boolean }
465
+ const LOW_CORRECTION = 1
466
+
467
+ /** A QR as SVG, for a page to show. The terminal QR is only legible in monospace. */
468
+ export function qrSvg(text: string): string {
469
+ const qr = new QRCode(-1, LOW_CORRECTION)
470
+ qr.addData(text)
471
+ qr.make()
472
+
473
+ const size = qr.getModuleCount()
474
+ const quiet = 4
475
+ let path = ''
476
+ for (let row = 0; row < size; row++) {
477
+ for (let col = 0; col < size; col++) {
478
+ if (qr.isDark(row, col)) path += `M${col + quiet} ${row + quiet}h1v1h-1z`
479
+ }
480
+ }
481
+
482
+ const box = size + quiet * 2
483
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${box} ${box}" shape-rendering="crispEdges"><rect width="${box}" height="${box}" fill="#fff"/><path d="${path}"/></svg>`
484
+ }
485
+
266
486
  let signer: PhoneSigner | null = null
267
487
 
268
488
  /** Configured only when a WalletConnect project id is present. */
@@ -1 +0,0 @@
1
- {"version":3,"file":"advanced.d.ts","sourceRoot":"","sources":["../../src/tools/advanced.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAMvB,wBAqZC"}