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.
- package/CHANGELOG.md +117 -0
- package/README.md +5 -0
- package/dist/anvil.d.ts +17 -19
- package/dist/anvil.d.ts.map +1 -1
- package/dist/anvil.js +124 -228
- package/dist/anvil.js.map +1 -1
- package/dist/chains.d.ts +9 -1
- package/dist/chains.d.ts.map +1 -1
- package/dist/chains.js +11 -2
- package/dist/chains.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/context.d.ts +22 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +24 -0
- package/dist/context.js.map +1 -0
- package/dist/hosted.d.ts +12 -0
- package/dist/hosted.d.ts.map +1 -0
- package/dist/hosted.js +12 -0
- package/dist/hosted.js.map +1 -0
- package/dist/http-server.d.ts +13 -0
- package/dist/http-server.d.ts.map +1 -1
- package/dist/http-server.js +13 -4
- package/dist/http-server.js.map +1 -1
- package/dist/index.js +19 -15
- package/dist/index.js.map +1 -1
- package/dist/lib.d.ts +24 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +23 -0
- package/dist/lib.js.map +1 -0
- package/dist/log.d.ts +16 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +20 -0
- package/dist/log.js.map +1 -0
- package/dist/mcp-server.d.ts +4 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +43 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/oauth.d.ts +41 -6
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +75 -41
- package/dist/oauth.js.map +1 -1
- package/dist/preview.d.ts +7 -0
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +14 -2
- package/dist/preview.js.map +1 -1
- package/dist/signing-requests.d.ts +81 -0
- package/dist/signing-requests.d.ts.map +1 -0
- package/dist/signing-requests.js +138 -0
- package/dist/signing-requests.js.map +1 -0
- package/dist/tools/index.d.ts +27 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +37 -15
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/{advanced.d.ts → read/advanced.d.ts} +30 -26
- package/dist/tools/read/advanced.d.ts.map +1 -0
- package/dist/tools/{advanced.js → read/advanced.js} +10 -10
- package/dist/tools/read/advanced.js.map +1 -0
- package/dist/tools/{balance.d.ts → read/balance.d.ts} +8 -7
- package/dist/tools/read/balance.d.ts.map +1 -0
- package/dist/tools/{balance.js → read/balance.js} +2 -2
- package/dist/tools/read/balance.js.map +1 -0
- package/dist/tools/{contract-info.d.ts → read/contract-info.d.ts} +18 -15
- package/dist/tools/read/contract-info.d.ts.map +1 -0
- package/dist/tools/{contract-info.js → read/contract-info.js} +2 -2
- package/dist/tools/read/contract-info.js.map +1 -0
- package/dist/tools/{contract.d.ts → read/contract.d.ts} +14 -12
- package/dist/tools/read/contract.d.ts.map +1 -0
- package/dist/tools/{contract.js → read/contract.js} +2 -2
- package/dist/tools/read/contract.js.map +1 -0
- package/dist/tools/{ens.d.ts → read/ens.d.ts} +30 -25
- package/dist/tools/read/ens.d.ts.map +1 -0
- package/dist/tools/{ens.js → read/ens.js} +2 -2
- package/dist/tools/read/ens.js.map +1 -0
- package/dist/tools/read/gas.d.ts +171 -0
- package/dist/tools/read/gas.d.ts.map +1 -0
- package/dist/tools/read/gas.js +368 -0
- package/dist/tools/read/gas.js.map +1 -0
- package/dist/tools/{logs.d.ts → read/logs.d.ts} +6 -5
- package/dist/tools/read/logs.d.ts.map +1 -0
- package/dist/tools/{logs.js → read/logs.js} +2 -2
- package/dist/tools/read/logs.js.map +1 -0
- package/dist/tools/{signatures.d.ts → read/signatures.d.ts} +8 -4
- package/dist/tools/read/signatures.d.ts.map +1 -0
- package/dist/tools/{signatures.js → read/signatures.js} +1 -1
- package/dist/tools/read/signatures.js.map +1 -0
- package/dist/tools/read/signing.d.ts +27 -0
- package/dist/tools/read/signing.d.ts.map +1 -0
- package/dist/tools/read/signing.js +75 -0
- package/dist/tools/read/signing.js.map +1 -0
- package/dist/tools/sign/errors.d.ts +9 -0
- package/dist/tools/sign/errors.d.ts.map +1 -0
- package/dist/tools/sign/errors.js +29 -0
- package/dist/tools/sign/errors.js.map +1 -0
- package/dist/tools/{transactions.d.ts → sign/transactions.d.ts} +48 -62
- package/dist/tools/sign/transactions.d.ts.map +1 -0
- package/dist/tools/sign/transactions.js +316 -0
- package/dist/tools/sign/transactions.js.map +1 -0
- package/dist/tools/sign/wallet.d.ts +50 -0
- package/dist/tools/sign/wallet.d.ts.map +1 -0
- package/dist/tools/sign/wallet.js +180 -0
- package/dist/tools/sign/wallet.js.map +1 -0
- package/dist/utils.d.ts +5 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +5 -2
- package/dist/utils.js.map +1 -1
- package/dist/wallet-client.d.ts +33 -4
- package/dist/wallet-client.d.ts.map +1 -1
- package/dist/wallet-client.js +90 -24
- package/dist/wallet-client.js.map +1 -1
- package/dist/walletconnect.d.ts +111 -13
- package/dist/walletconnect.d.ts.map +1 -1
- package/dist/walletconnect.js +248 -68
- package/dist/walletconnect.js.map +1 -1
- package/package.json +10 -4
- package/src/anvil.ts +150 -271
- package/src/chains.ts +14 -2
- package/src/client.ts +0 -1
- package/src/context.ts +27 -0
- package/src/hosted.ts +11 -0
- package/src/http-server.ts +30 -5
- package/src/index.ts +19 -16
- package/src/lib.ts +24 -0
- package/src/log.ts +25 -0
- package/src/mcp-server.ts +47 -0
- package/src/oauth.ts +98 -45
- package/src/preview.ts +21 -2
- package/src/signing-requests.ts +198 -0
- package/src/tools/index.ts +67 -16
- package/src/tools/{advanced.ts → read/advanced.ts} +13 -11
- package/src/tools/{balance.ts → read/balance.ts} +3 -3
- package/src/tools/{contract-info.ts → read/contract-info.ts} +3 -3
- package/src/tools/{contract.ts → read/contract.ts} +3 -3
- package/src/tools/{ens.ts → read/ens.ts} +3 -3
- package/src/tools/read/gas.ts +480 -0
- package/src/tools/{logs.ts → read/logs.ts} +3 -3
- package/src/tools/{signatures.ts → read/signatures.ts} +2 -2
- package/src/tools/read/signing.ts +86 -0
- package/src/tools/sign/errors.ts +28 -0
- package/src/tools/sign/transactions.ts +428 -0
- package/src/tools/sign/wallet.ts +216 -0
- package/src/utils.ts +5 -2
- package/src/wallet-client.ts +91 -22
- package/src/walletconnect.ts +286 -66
- package/dist/tools/advanced.d.ts.map +0 -1
- package/dist/tools/advanced.js.map +0 -1
- package/dist/tools/balance.d.ts.map +0 -1
- package/dist/tools/balance.js.map +0 -1
- package/dist/tools/contract-info.d.ts.map +0 -1
- package/dist/tools/contract-info.js.map +0 -1
- package/dist/tools/contract.d.ts.map +0 -1
- package/dist/tools/contract.js.map +0 -1
- package/dist/tools/ens.d.ts.map +0 -1
- package/dist/tools/ens.js.map +0 -1
- package/dist/tools/gas.d.ts +0 -99
- package/dist/tools/gas.d.ts.map +0 -1
- package/dist/tools/gas.js +0 -181
- package/dist/tools/gas.js.map +0 -1
- package/dist/tools/logs.d.ts.map +0 -1
- package/dist/tools/logs.js.map +0 -1
- package/dist/tools/signatures.d.ts.map +0 -1
- package/dist/tools/signatures.js.map +0 -1
- package/dist/tools/transactions.d.ts.map +0 -1
- package/dist/tools/transactions.js +0 -361
- package/dist/tools/transactions.js.map +0 -1
- package/src/tools/gas.ts +0 -219
- package/src/tools/transactions.ts +0 -452
package/src/wallet-client.ts
CHANGED
|
@@ -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
|
-
/**
|
|
12
|
-
*
|
|
13
|
-
|
|
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
|
-
|
|
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=${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}`,
|
|
156
|
-
this.pairingUrl = pairingUrlFor(port,
|
|
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()}`,
|
|
166
|
-
this.pairingUrl = this.relay.
|
|
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))
|
|
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)
|
|
322
|
-
|
|
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
|
-
|
|
438
|
+
const clients = new Map<string, WalletClient>()
|
|
398
439
|
|
|
399
|
-
|
|
400
|
-
|
|
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
|
+
}
|
package/src/walletconnect.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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) =>
|
|
146
|
-
|
|
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
|
|
152
|
-
async request(
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
171
|
-
async sessions(): Promise<Session[]> {
|
|
172
|
-
await this.
|
|
173
|
-
return
|
|
174
|
-
|
|
175
|
-
accounts
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
|
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
|
-
|
|
191
|
-
|
|
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
|
|
194
|
-
.catch((error) =>
|
|
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) =>
|
|
398
|
+
.catch((error) => log('warning', 'WalletConnect', `Could not drop a pairing: ${error.message}`))
|
|
202
399
|
}
|
|
203
400
|
|
|
204
|
-
return { sessions:
|
|
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"}
|