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