web3-tools-mcp 2.0.2 → 3.1.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 (180) hide show
  1. package/CHANGELOG.md +182 -1
  2. package/README.md +76 -47
  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/chain-meta.d.ts.map +1 -1
  8. package/dist/chain-meta.js +19 -8
  9. package/dist/chain-meta.js.map +1 -1
  10. package/dist/chains.d.ts +9 -1
  11. package/dist/chains.d.ts.map +1 -1
  12. package/dist/chains.js +11 -2
  13. package/dist/chains.js.map +1 -1
  14. package/dist/clear-signing.d.ts.map +1 -1
  15. package/dist/clear-signing.js +17 -6
  16. package/dist/clear-signing.js.map +1 -1
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js.map +1 -1
  19. package/dist/context.d.ts +22 -0
  20. package/dist/context.d.ts.map +1 -0
  21. package/dist/context.js +24 -0
  22. package/dist/context.js.map +1 -0
  23. package/dist/hosted.d.ts +12 -0
  24. package/dist/hosted.d.ts.map +1 -0
  25. package/dist/hosted.js +12 -0
  26. package/dist/hosted.js.map +1 -0
  27. package/dist/http-server.d.ts +13 -0
  28. package/dist/http-server.d.ts.map +1 -1
  29. package/dist/http-server.js +22 -7
  30. package/dist/http-server.js.map +1 -1
  31. package/dist/index.js +20 -12
  32. package/dist/index.js.map +1 -1
  33. package/dist/kv-storage.d.ts +2 -1
  34. package/dist/kv-storage.d.ts.map +1 -1
  35. package/dist/kv-storage.js +5 -2
  36. package/dist/kv-storage.js.map +1 -1
  37. package/dist/lib.d.ts +24 -0
  38. package/dist/lib.d.ts.map +1 -0
  39. package/dist/lib.js +23 -0
  40. package/dist/lib.js.map +1 -0
  41. package/dist/mcp-server.d.ts +4 -0
  42. package/dist/mcp-server.d.ts.map +1 -0
  43. package/dist/mcp-server.js +43 -0
  44. package/dist/mcp-server.js.map +1 -0
  45. package/dist/oauth.d.ts +57 -8
  46. package/dist/oauth.d.ts.map +1 -1
  47. package/dist/oauth.js +133 -63
  48. package/dist/oauth.js.map +1 -1
  49. package/dist/preview.d.ts +7 -0
  50. package/dist/preview.d.ts.map +1 -1
  51. package/dist/preview.js +21 -7
  52. package/dist/preview.js.map +1 -1
  53. package/dist/signing-requests.d.ts +81 -0
  54. package/dist/signing-requests.d.ts.map +1 -0
  55. package/dist/signing-requests.js +138 -0
  56. package/dist/signing-requests.js.map +1 -0
  57. package/dist/tools/index.d.ts +27 -1
  58. package/dist/tools/index.d.ts.map +1 -1
  59. package/dist/tools/index.js +50 -15
  60. package/dist/tools/index.js.map +1 -1
  61. package/dist/tools/{advanced.d.ts → read/advanced.d.ts} +39 -30
  62. package/dist/tools/read/advanced.d.ts.map +1 -0
  63. package/dist/tools/{advanced.js → read/advanced.js} +53 -41
  64. package/dist/tools/read/advanced.js.map +1 -0
  65. package/dist/tools/{balance.d.ts → read/balance.d.ts} +8 -7
  66. package/dist/tools/read/balance.d.ts.map +1 -0
  67. package/dist/tools/{balance.js → read/balance.js} +18 -33
  68. package/dist/tools/read/balance.js.map +1 -0
  69. package/dist/tools/{contract-info.d.ts → read/contract-info.d.ts} +20 -15
  70. package/dist/tools/read/contract-info.d.ts.map +1 -0
  71. package/dist/tools/{contract-info.js → read/contract-info.js} +54 -123
  72. package/dist/tools/read/contract-info.js.map +1 -0
  73. package/dist/tools/{contract.d.ts → read/contract.d.ts} +14 -12
  74. package/dist/tools/read/contract.d.ts.map +1 -0
  75. package/dist/tools/{contract.js → read/contract.js} +3 -2
  76. package/dist/tools/read/contract.js.map +1 -0
  77. package/dist/tools/read/ens.d.ts +104 -0
  78. package/dist/tools/read/ens.d.ts.map +1 -0
  79. package/dist/tools/{ens.js → read/ens.js} +25 -33
  80. package/dist/tools/read/ens.js.map +1 -0
  81. package/dist/tools/read/gas.d.ts +171 -0
  82. package/dist/tools/read/gas.d.ts.map +1 -0
  83. package/dist/tools/read/gas.js +333 -0
  84. package/dist/tools/read/gas.js.map +1 -0
  85. package/dist/tools/read/logs.d.ts +101 -0
  86. package/dist/tools/read/logs.d.ts.map +1 -0
  87. package/dist/tools/read/logs.js +211 -0
  88. package/dist/tools/read/logs.js.map +1 -0
  89. package/dist/tools/{signatures.d.ts → read/signatures.d.ts} +8 -4
  90. package/dist/tools/read/signatures.d.ts.map +1 -0
  91. package/dist/tools/{signatures.js → read/signatures.js} +4 -3
  92. package/dist/tools/read/signatures.js.map +1 -0
  93. package/dist/tools/read/signing.d.ts +27 -0
  94. package/dist/tools/read/signing.d.ts.map +1 -0
  95. package/dist/tools/read/signing.js +81 -0
  96. package/dist/tools/read/signing.js.map +1 -0
  97. package/dist/tools/sign/errors.d.ts +9 -0
  98. package/dist/tools/sign/errors.d.ts.map +1 -0
  99. package/dist/tools/sign/errors.js +29 -0
  100. package/dist/tools/sign/errors.js.map +1 -0
  101. package/dist/tools/sign/transactions.d.ts +141 -0
  102. package/dist/tools/sign/transactions.d.ts.map +1 -0
  103. package/dist/tools/sign/transactions.js +361 -0
  104. package/dist/tools/sign/transactions.js.map +1 -0
  105. package/dist/tools/sign/wallet.d.ts +50 -0
  106. package/dist/tools/sign/wallet.d.ts.map +1 -0
  107. package/dist/tools/sign/wallet.js +180 -0
  108. package/dist/tools/sign/wallet.js.map +1 -0
  109. package/dist/utils.d.ts +23 -2
  110. package/dist/utils.d.ts.map +1 -1
  111. package/dist/utils.js +48 -3
  112. package/dist/utils.js.map +1 -1
  113. package/dist/wallet-client.d.ts +34 -4
  114. package/dist/wallet-client.d.ts.map +1 -1
  115. package/dist/wallet-client.js +106 -27
  116. package/dist/wallet-client.js.map +1 -1
  117. package/dist/walletconnect.d.ts +100 -13
  118. package/dist/walletconnect.d.ts.map +1 -1
  119. package/dist/walletconnect.js +215 -50
  120. package/dist/walletconnect.js.map +1 -1
  121. package/package.json +12 -9
  122. package/src/anvil.ts +150 -271
  123. package/src/chain-meta.ts +16 -7
  124. package/src/chains.ts +14 -2
  125. package/src/clear-signing.ts +15 -9
  126. package/src/client.ts +0 -1
  127. package/src/context.ts +27 -0
  128. package/src/hosted.ts +11 -0
  129. package/src/http-server.ts +38 -8
  130. package/src/index.ts +20 -13
  131. package/src/kv-storage.ts +6 -3
  132. package/src/lib.ts +24 -0
  133. package/src/mcp-server.ts +47 -0
  134. package/src/oauth.ts +181 -69
  135. package/src/preview.ts +28 -7
  136. package/src/signing-requests.ts +198 -0
  137. package/src/tools/index.ts +78 -16
  138. package/src/tools/{advanced.ts → read/advanced.ts} +74 -43
  139. package/src/tools/{balance.ts → read/balance.ts} +20 -35
  140. package/src/tools/{contract-info.ts → read/contract-info.ts} +66 -146
  141. package/src/tools/{contract.ts → read/contract.ts} +4 -3
  142. package/src/tools/{ens.ts → read/ens.ts} +27 -34
  143. package/src/tools/read/gas.ts +443 -0
  144. package/src/tools/read/logs.ts +281 -0
  145. package/src/tools/{signatures.ts → read/signatures.ts} +5 -5
  146. package/src/tools/read/signing.ts +92 -0
  147. package/src/tools/sign/errors.ts +28 -0
  148. package/src/tools/sign/transactions.ts +479 -0
  149. package/src/tools/sign/wallet.ts +216 -0
  150. package/src/utils.ts +52 -3
  151. package/src/wallet-client.ts +107 -24
  152. package/src/walletconnect.ts +250 -48
  153. package/dist/tools/advanced.d.ts.map +0 -1
  154. package/dist/tools/advanced.js.map +0 -1
  155. package/dist/tools/balance.d.ts.map +0 -1
  156. package/dist/tools/balance.js.map +0 -1
  157. package/dist/tools/contract-info.d.ts.map +0 -1
  158. package/dist/tools/contract-info.js.map +0 -1
  159. package/dist/tools/contract.d.ts.map +0 -1
  160. package/dist/tools/contract.js.map +0 -1
  161. package/dist/tools/ens.d.ts +0 -119
  162. package/dist/tools/ens.d.ts.map +0 -1
  163. package/dist/tools/ens.js.map +0 -1
  164. package/dist/tools/gas.d.ts +0 -99
  165. package/dist/tools/gas.d.ts.map +0 -1
  166. package/dist/tools/gas.js +0 -181
  167. package/dist/tools/gas.js.map +0 -1
  168. package/dist/tools/logs.d.ts +0 -39
  169. package/dist/tools/logs.d.ts.map +0 -1
  170. package/dist/tools/logs.js +0 -230
  171. package/dist/tools/logs.js.map +0 -1
  172. package/dist/tools/signatures.d.ts.map +0 -1
  173. package/dist/tools/signatures.js.map +0 -1
  174. package/dist/tools/transactions.d.ts +0 -155
  175. package/dist/tools/transactions.d.ts.map +0 -1
  176. package/dist/tools/transactions.js +0 -365
  177. package/dist/tools/transactions.js.map +0 -1
  178. package/src/tools/gas.ts +0 -219
  179. package/src/tools/logs.ts +0 -293
  180. package/src/tools/transactions.ts +0 -456
package/src/anvil.ts CHANGED
@@ -1,226 +1,155 @@
1
- import { type ChildProcess, spawn } from 'node:child_process'
2
1
  import { createPublicClient, http } from 'viem'
3
2
  import { mainnet } from 'viem/chains'
4
-
5
- export interface AnvilInstance {
6
- process: ChildProcess
7
- port: number
8
- rpcUrl: string
9
- client: ReturnType<typeof createPublicClient>
3
+ import { HOSTED } from './hosted.js'
4
+
5
+ /**
6
+ * Tracing through a node that already supports `debug_*`, usually a local Anvil. Nothing
7
+ * is started here.
8
+ *
9
+ * Anvil serves no block below its own fork height — `debug_traceCall` at an earlier one
10
+ * fails with BlockOutOfRangeError — so replaying an old transaction means re-forking the
11
+ * node there with `anvil_reset`. That discards whatever state it held, which is why it
12
+ * happens only when ANVIL_ALLOW_RESET says so. A provider whose endpoint exposes
13
+ * `debug_traceTransaction` needs none of this and is tried first.
14
+ */
15
+
16
+ const DEFAULT_DEBUG_RPC = 'http://127.0.0.1:8545'
17
+
18
+ /** Re-forking clears the node, so it is never done to someone's dev chain uninvited. */
19
+ const ALLOW_RESET = ['1', 'true', 'yes'].includes((process.env.ANVIL_ALLOW_RESET ?? '').toLowerCase())
20
+
21
+ /**
22
+ * The node to ask for traces, or undefined when there is none.
23
+ *
24
+ * Hosted, only an explicit setting counts: defaulting to loopback would turn every trace
25
+ * into a request against the server's own private network.
26
+ */
27
+ export function debugRpcUrl(): string | undefined {
28
+ if (process.env.ANVIL_RPC_URL) return process.env.ANVIL_RPC_URL
29
+ return HOSTED ? undefined : DEFAULT_DEBUG_RPC
10
30
  }
11
31
 
12
- export interface AnvilConfig {
13
- forkUrl: string
14
- forkBlockNumber?: bigint
15
- port?: number
16
- chainId?: number
32
+ function debugClient(url: string) {
33
+ return createPublicClient({ chain: mainnet, transport: http(url) })
17
34
  }
18
35
 
19
- // Check if anvil is installed
20
- export async function isAnvilInstalled(): Promise<boolean> {
21
- return new Promise((resolve) => {
22
- const proc = spawn('anvil', ['--version'], { shell: true })
23
- proc.on('close', (code) => resolve(code === 0))
24
- proc.on('error', () => resolve(false))
25
- })
26
- }
36
+ /** Whether a tracing node is reachable and actually offers the debug namespace. */
37
+ export async function isDebugNodeAvailable(): Promise<boolean> {
38
+ const url = debugRpcUrl()
39
+ if (!url) return false
27
40
 
28
- // Find an available port
29
- async function findAvailablePort(startPort: number = 8545): Promise<number> {
30
- const net = await import('node:net')
31
- return new Promise((resolve, reject) => {
32
- const server = net.createServer()
33
- server.listen(startPort, () => {
34
- const address = server.address()
35
- if (address && typeof address === 'object') {
36
- server.close(() => resolve(address.port))
37
- } else {
38
- server.close(() => reject(new Error('Could not get port')))
39
- }
40
- })
41
- server.on('error', () => {
42
- // Port in use, try next one
43
- findAvailablePort(startPort + 1)
44
- .then(resolve)
45
- .catch(reject)
41
+ try {
42
+ const response = await fetch(url, {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'web3_clientVersion', params: [], id: 1 }),
46
+ signal: AbortSignal.timeout(2000)
46
47
  })
47
- })
48
- }
49
-
50
- // Start an Anvil instance
51
- export async function startAnvil(config: AnvilConfig): Promise<AnvilInstance> {
52
- const installed = await isAnvilInstalled()
53
- if (!installed) {
54
- throw new Error('Anvil is not installed. Please install Foundry: https://book.getfoundry.sh/getting-started/installation')
48
+ return response.ok
49
+ } catch {
50
+ return false
55
51
  }
52
+ }
56
53
 
57
- const port = config.port ?? (await findAvailablePort())
58
- const rpcUrl = `http://127.0.0.1:${port}`
59
-
60
- const args = ['--fork-url', config.forkUrl, '--port', port.toString(), '--silent']
54
+ export interface SimulateCallParams {
55
+ to: string
56
+ data?: string
57
+ from?: string
58
+ value?: bigint
59
+ gas?: bigint
60
+ }
61
61
 
62
- if (config.forkBlockNumber !== undefined) {
63
- args.push('--fork-block-number', config.forkBlockNumber.toString())
64
- }
62
+ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
65
63
 
66
- if (config.chainId !== undefined) {
67
- args.push('--chain-id', config.chainId.toString())
64
+ function callObject(params: SimulateCallParams) {
65
+ return {
66
+ to: params.to,
67
+ data: params.data,
68
+ from: params.from ?? ZERO_ADDRESS,
69
+ value: params.value ? `0x${params.value.toString(16)}` : undefined,
70
+ gas: params.gas ? `0x${params.gas.toString(16)}` : undefined
68
71
  }
72
+ }
69
73
 
70
- return new Promise((resolve, reject) => {
71
- const proc = spawn('anvil', args, {
72
- shell: true,
73
- stdio: ['ignore', 'pipe', 'pipe']
74
- })
75
-
76
- let stderr = ''
77
- proc.stderr?.on('data', (data) => {
78
- stderr += data.toString()
79
- })
74
+ function blockTag(blockNumber?: bigint): string {
75
+ return blockNumber === undefined ? 'latest' : `0x${blockNumber.toString(16)}`
76
+ }
80
77
 
81
- proc.on('error', (err) => {
82
- reject(new Error(`Failed to start Anvil: ${err.message}`))
83
- })
78
+ function requireDebugNode(): string {
79
+ const url = debugRpcUrl()
80
+ if (!url) {
81
+ throw new Error('No tracing node configured. Set ANVIL_RPC_URL to a node exposing debug_traceCall, such as a local Anvil.')
82
+ }
83
+ return url
84
+ }
84
85
 
85
- proc.on('close', (code) => {
86
- if (code !== 0 && code !== null) {
87
- reject(new Error(`Anvil exited with code ${code}: ${stderr}`))
88
- }
89
- })
86
+ function isBlockOutOfRange(error: unknown): boolean {
87
+ return /BlockOutOfRangeError|block height is/i.test((error as Error)?.message ?? '')
88
+ }
90
89
 
91
- // Wait for Anvil to be ready by polling the RPC
92
- const maxAttempts = 50 // 5 seconds
93
- let attempts = 0
90
+ type DebugClient = ReturnType<typeof debugClient>
94
91
 
95
- const checkReady = async () => {
96
- attempts++
97
- try {
98
- const response = await fetch(rpcUrl, {
99
- method: 'POST',
100
- headers: { 'Content-Type': 'application/json' },
101
- body: JSON.stringify({
102
- jsonrpc: '2.0',
103
- method: 'eth_blockNumber',
104
- params: [],
105
- id: 1
106
- })
107
- })
108
- if (response.ok) {
109
- // Add small delay to ensure all RPC methods are ready (including debug_*)
110
- await new Promise((r) => setTimeout(r, 100))
111
- const client = createPublicClient({
112
- chain: mainnet, // Chain is overridden by fork anyway
113
- transport: http(rpcUrl)
114
- })
115
- resolve({ process: proc, port, rpcUrl, client })
116
- } else if (attempts < maxAttempts) {
117
- setTimeout(checkReady, 100)
118
- } else {
119
- proc.kill()
120
- reject(new Error('Anvil failed to start within timeout'))
121
- }
122
- } catch {
123
- if (attempts < maxAttempts) {
124
- setTimeout(checkReady, 100)
125
- } else {
126
- proc.kill()
127
- reject(new Error('Anvil failed to start within timeout'))
128
- }
129
- }
92
+ /**
93
+ * Run something against a block, re-forking the node when it cannot reach that far back.
94
+ *
95
+ * Returns to head afterwards rather than leaving the node pinned to an old block, which
96
+ * would silently break whatever the owner does with it next. Its state is gone either way.
97
+ */
98
+ async function atBlock<T>(
99
+ client: DebugClient,
100
+ forkUrl: string,
101
+ blockNumber: bigint | undefined,
102
+ run: (block: string) => Promise<T>
103
+ ): Promise<T> {
104
+ try {
105
+ return await run(blockTag(blockNumber))
106
+ } catch (error) {
107
+ if (blockNumber === undefined || !isBlockOutOfRange(error)) throw error
108
+
109
+ if (!ALLOW_RESET) {
110
+ throw new Error(
111
+ `The tracing node is forked past block ${blockNumber}, and Anvil serves nothing earlier. Re-forking it there would clear its state, so set ANVIL_ALLOW_RESET=1 to allow that — or use an RPC whose endpoint supports debug_traceTransaction.`
112
+ )
130
113
  }
131
114
 
132
- // Start checking after a small delay
133
- setTimeout(checkReady, 200)
134
- })
135
- }
136
-
137
- // Stop an Anvil instance
138
- export function stopAnvil(instance: AnvilInstance): void {
139
- if (instance.process && !instance.process.killed) {
140
- instance.process.kill('SIGTERM')
141
- }
142
- }
115
+ const refork = (block?: bigint) =>
116
+ client.request({
117
+ method: 'anvil_reset' as never,
118
+ params: [{ forking: { jsonRpcUrl: forkUrl, ...(block === undefined ? {} : { blockNumber: Number(block) }) } }] as never
119
+ })
143
120
 
144
- // Helper to run a function with a temporary Anvil instance
145
- export async function withAnvil<T>(config: AnvilConfig, fn: (instance: AnvilInstance) => Promise<T>): Promise<T> {
146
- const instance = await startAnvil(config)
147
- try {
148
- return await fn(instance)
149
- } finally {
150
- stopAnvil(instance)
121
+ await refork(blockNumber)
122
+ try {
123
+ return await run('latest')
124
+ } finally {
125
+ await refork().catch(() => {})
126
+ }
151
127
  }
152
128
  }
153
129
 
154
- // Archive RPC URLs for chains that need historical state access
155
- const ARCHIVE_RPCS: Record<string, string> = {
156
- arbitrum: 'https://arbitrum-one.arpc.x.superfluid.dev'
130
+ function traceCall(client: DebugClient, call: ReturnType<typeof callObject>, block: string, tracer: string): Promise<unknown> {
131
+ return client.request({ method: 'debug_traceCall' as never, params: [call, block, { tracer }] as never })
157
132
  }
158
133
 
159
- // Trace a transaction using Anvil fork
134
+ /**
135
+ * Replay a transaction through the tracing node.
136
+ *
137
+ * Run against the block before it, so the state is what the transaction actually saw. The
138
+ * node needs archive access to that block; without it this fails rather than quietly
139
+ * tracing against the wrong state.
140
+ */
160
141
  export async function traceTransactionWithAnvil(
161
142
  forkUrl: string,
162
143
  transactionHash: string,
163
144
  blockNumber: bigint,
164
- tracer: 'callTracer' | 'prestateTracer' | 'stateDiffTracer' = 'callTracer',
165
- chainName?: string
145
+ tracer: 'callTracer' | 'prestateTracer' | 'stateDiffTracer' = 'callTracer'
166
146
  ): Promise<unknown> {
167
- // Use archive RPC if available for better historical state access
168
- const archiveUrl = chainName && ARCHIVE_RPCS[chainName] ? ARCHIVE_RPCS[chainName] : forkUrl
169
-
170
- // First, fetch the original transaction from the RPC
171
- const fetchClient = createPublicClient({
172
- chain: mainnet,
173
- transport: http(forkUrl)
174
- })
175
-
176
- const tx = await fetchClient.getTransaction({
177
- hash: transactionHash as `0x${string}`
178
- })
179
-
180
- if (!tx) {
181
- throw new Error(`Transaction ${transactionHash} not found`)
182
- }
183
-
184
- // Fork at the block BEFORE the transaction to replay it accurately
185
- // Note: This requires an archival RPC that has historical state
186
- const forkBlock = blockNumber > 0n ? blockNumber - 1n : 0n
187
-
188
- return withAnvil(
189
- {
190
- forkUrl: archiveUrl,
191
- forkBlockNumber: forkBlock
192
- },
193
- async (instance) => {
194
- // Use debug_traceCall to simulate and trace the transaction
195
- // Note: This replays the tx against the post-block state, which may differ slightly
196
- // from the original execution if state changed within the block
197
- const trace = await instance.client.request({
198
- method: 'debug_traceCall' as never,
199
- params: [
200
- {
201
- from: tx.from,
202
- to: tx.to,
203
- data: tx.input,
204
- value: tx.value ? `0x${tx.value.toString(16)}` : '0x0',
205
- gas: tx.gas ? `0x${tx.gas.toString(16)}` : undefined
206
- },
207
- 'latest',
208
- { tracer }
209
- ] as never
210
- })
147
+ const tx = await debugClient(forkUrl).getTransaction({ hash: transactionHash as `0x${string}` })
148
+ if (!tx) throw new Error(`Transaction ${transactionHash} not found`)
211
149
 
212
- return trace
213
- }
214
- )
215
- }
216
-
217
- // Simulate a call with tracing
218
- export interface SimulateCallParams {
219
- to: string
220
- data?: string
221
- from?: string
222
- value?: bigint
223
- gas?: bigint
150
+ const client = debugClient(requireDebugNode())
151
+ const call = callObject({ to: tx.to ?? ZERO_ADDRESS, data: tx.input, from: tx.from, value: tx.value, gas: tx.gas })
152
+ return atBlock(client, forkUrl, blockNumber > 0n ? blockNumber - 1n : 0n, (block) => traceCall(client, call, block, tracer))
224
153
  }
225
154
 
226
155
  export async function simulateCallWithTrace(
@@ -235,86 +164,36 @@ export async function simulateCallWithTrace(
235
164
  success: boolean
236
165
  revertReason?: string
237
166
  }> {
238
- return withAnvil(
239
- {
240
- forkUrl,
241
- forkBlockNumber: blockNumber
242
- },
243
- async (instance) => {
244
- // First, try a regular call to get the result
245
- let result = '0x'
246
- let success = true
247
- let revertReason: string | undefined
248
- let gasUsed = 0n
249
-
250
- try {
251
- // Use eth_call to get the result
252
- result = (await instance.client.request({
253
- method: 'eth_call' as never,
254
- params: [
255
- {
256
- to: params.to,
257
- data: params.data,
258
- from: params.from ?? '0x0000000000000000000000000000000000000000',
259
- value: params.value ? `0x${params.value.toString(16)}` : undefined,
260
- gas: params.gas ? `0x${params.gas.toString(16)}` : undefined
261
- },
262
- 'latest'
263
- ] as never
264
- })) as string
265
-
266
- // Estimate gas
267
- try {
268
- gasUsed = await instance.client.estimateGas({
269
- to: params.to as `0x${string}`,
270
- data: params.data as `0x${string}` | undefined,
271
- account: params.from as `0x${string}` | undefined,
272
- value: params.value
273
- })
274
- } catch {
275
- // Ignore gas estimation errors
276
- }
277
- } catch (error) {
278
- success = false
279
- const errorMessage = (error as Error).message
280
- // Try to extract revert reason
281
- const revertMatch = errorMessage.match(/revert(?:ed)?[:\s]*(.+?)(?:\n|$)/i)
282
- if (revertMatch) {
283
- revertReason = revertMatch[1].trim()
284
- } else {
285
- revertReason = errorMessage
286
- }
287
- }
167
+ const client = debugClient(requireDebugNode())
168
+ const call = callObject(params)
169
+
170
+ // Call, gas and trace all inside one atBlock, so they describe the same state: a block
171
+ // the node cannot serve re-forks it once, instead of being reported as a revert.
172
+ return atBlock(client, forkUrl, blockNumber, async (block) => {
173
+ let result = '0x'
174
+ let success = true
175
+ let revertReason: string | undefined
176
+ let gasUsed = 0n
177
+
178
+ try {
179
+ result = (await client.request({ method: 'eth_call' as never, params: [call, block] as never })) as string
180
+ gasUsed = await client
181
+ .request({ method: 'eth_estimateGas' as never, params: [call, block] as never })
182
+ .then((gas) => BigInt(gas as string))
183
+ // Gas estimation is a nicety; a failure here says nothing about the call itself.
184
+ .catch(() => 0n)
185
+ } catch (error) {
186
+ if (isBlockOutOfRange(error)) throw error
187
+ success = false
188
+ const message = (error as Error).message
189
+ revertReason = message.match(/revert(?:ed)?[:\s]*(.+?)(?:\n|$)/i)?.[1]?.trim() ?? message
190
+ }
288
191
 
289
- // Get the trace using debug_traceCall
290
- let trace: unknown = null
291
- try {
292
- trace = await instance.client.request({
293
- method: 'debug_traceCall' as never,
294
- params: [
295
- {
296
- to: params.to,
297
- data: params.data,
298
- from: params.from ?? '0x0000000000000000000000000000000000000000',
299
- value: params.value ? `0x${params.value.toString(16)}` : undefined,
300
- gas: params.gas ? `0x${params.gas.toString(16)}` : undefined
301
- },
302
- 'latest',
303
- { tracer }
304
- ] as never
305
- })
306
- } catch (traceError) {
307
- // If tracing fails, include the error
308
- trace = { error: (traceError as Error).message }
309
- }
192
+ const trace = await traceCall(client, call, block, tracer).catch((error) => {
193
+ if (isBlockOutOfRange(error)) throw error
194
+ return { error: (error as Error).message }
195
+ })
310
196
 
311
- return {
312
- result,
313
- trace,
314
- gasUsed,
315
- success,
316
- revertReason
317
- }
318
- }
319
- )
197
+ return { result, trace, gasUsed, success, revertReason }
198
+ })
320
199
  }
package/src/chain-meta.ts CHANGED
@@ -8,15 +8,21 @@ import { whatsabi } from '@shazow/whatsabi'
8
8
  import { type Abi, type Address, parseAbiItem } from 'viem'
9
9
  import { getClientManager } from './client.js'
10
10
  import type { ChainName } from './types.js'
11
+ import { TtlCache } from './utils.js'
11
12
 
12
13
  const ERC20_META_ABI = [
13
14
  parseAbiItem('function decimals() view returns (uint8)'),
14
15
  parseAbiItem('function symbol() view returns (string)')
15
16
  ]
16
17
 
17
- const abiCache = new Map<string, { abi: Abi; source: 'verified' | 'guessed'; proxy?: string; name?: string }>()
18
- const labelCache = new Map<string, string | undefined>()
19
- const metaCache = new Map<string, { symbol?: string; decimals?: number }>()
18
+ // An hour covers a session's repeats while still noticing an upgraded proxy.
19
+ const TTL_MS = 60 * 60 * 1000
20
+ const abiCache = new TtlCache<{ abi: Abi; source: 'verified' | 'guessed'; proxy?: string; name?: string }>(200, TTL_MS)
21
+ const labelCache = new TtlCache<{ label?: string }>(1000, TTL_MS)
22
+ const metaCache = new TtlCache<{ symbol?: string; decimals?: number }>(1000, TTL_MS)
23
+
24
+ /** A local node is reset and redeployed at will, so nothing learned from it stays true. */
25
+ const cacheable = (chain: ChainName) => chain !== 'localhost'
20
26
 
21
27
  export async function loadAbi(chain: ChainName, address: string) {
22
28
  const key = `${chain}:${address.toLowerCase()}`
@@ -49,7 +55,7 @@ export async function loadAbi(chain: ChainName, address: string) {
49
55
  proxy: result.address !== address ? result.address : undefined,
50
56
  name: result.contractResult?.name ?? undefined
51
57
  }
52
- abiCache.set(key, loaded)
58
+ if (cacheable(chain)) abiCache.set(key, loaded)
53
59
  return loaded
54
60
  }
55
61
 
@@ -71,7 +77,8 @@ export async function tokenMeta(chain: ChainName, token: string) {
71
77
  decimals: decimals.status === 'success' ? Number(decimals.result) : undefined,
72
78
  symbol: symbol.status === 'success' ? (symbol.result as string) : undefined
73
79
  }
74
- metaCache.set(key, meta)
80
+ // A failed call may be a flaky RPC rather than a token without that method.
81
+ if (cacheable(chain) && decimals.status === 'success' && symbol.status === 'success') metaCache.set(key, meta)
75
82
  return meta
76
83
  }
77
84
 
@@ -82,7 +89,8 @@ export async function tokenMeta(chain: ChainName, token: string) {
82
89
  */
83
90
  export async function addressLabel(chain: ChainName, address: string): Promise<string | undefined> {
84
91
  const key = `${chain}:${address.toLowerCase()}`
85
- if (labelCache.has(key)) return labelCache.get(key)
92
+ const cached = labelCache.get(key)
93
+ if (cached) return cached.label
86
94
 
87
95
  let label: string | undefined
88
96
  try {
@@ -95,9 +103,10 @@ export async function addressLabel(chain: ChainName, address: string): Promise<s
95
103
  }
96
104
  } catch {
97
105
  // Unknown address — show it bare rather than failing the preview.
106
+ return undefined
98
107
  }
99
108
 
100
- labelCache.set(key, label)
109
+ if (cacheable(chain)) labelCache.set(key, { label })
101
110
  return label
102
111
  }
103
112
 
package/src/chains.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  unichain,
15
15
  zksync
16
16
  } from 'viem/chains'
17
+ import { HOSTED } from './hosted.js'
17
18
 
18
19
  /**
19
20
  * Every chain this server knows, in one table. Adding one here is the whole change: the
@@ -141,5 +142,16 @@ export type ChainName = keyof typeof TABLE
141
142
  // literal entry would otherwise lose the fields that not every chain sets.
142
143
  export const CHAINS: Record<ChainName, ChainInfo> = TABLE
143
144
 
144
- /** Tuple rather than array, because `z.enum` needs a non-empty literal list. */
145
- export const SUPPORTED_CHAINS = Object.keys(CHAINS) as [ChainName, ...ChainName[]]
145
+ /**
146
+ * Chains the tools will accept.
147
+ *
148
+ * `localhost` is dropped when hosted. It resolves to the machine running the server, so on
149
+ * a shared host it is not the caller's dev node — it is a way to aim requests at whatever
150
+ * that host has listening on its own loopback.
151
+ *
152
+ * Tuple rather than array, because `z.enum` needs a non-empty literal list.
153
+ */
154
+ export const SUPPORTED_CHAINS = (HOSTED ? Object.keys(CHAINS).filter((name) => name !== 'localhost') : Object.keys(CHAINS)) as [
155
+ ChainName,
156
+ ...ChainName[]
157
+ ]
@@ -138,13 +138,19 @@ async function formatField(chain: ChainName, field: IndexField, root: unknown, t
138
138
  const token =
139
139
  field.params?.tokenAddress ?? (field.params?.tokenPath ? resolvePath(root, tx, field.params.tokenPath) : undefined)
140
140
 
141
- const meta =
142
- typeof token === 'string' && isAddress(token)
143
- ? await tokenMeta(chain, token).catch(() => ({ symbol: undefined, decimals: undefined }))
144
- : { symbol: undefined, decimals: undefined }
145
-
146
- const symbol = meta.symbol ? ` ${meta.symbol}` : ''
147
- out.value = amount > UNLIMITED_THRESHOLD ? `Unlimited${symbol}` : `${formatUnits(amount, meta.decimals ?? 18)}${symbol}`
141
+ const tokenAddress = typeof token === 'string' && isAddress(token) ? getAddress(token) : undefined
142
+ const meta = tokenAddress
143
+ ? await tokenMeta(chain, tokenAddress).catch(() => ({ symbol: undefined, decimals: undefined }))
144
+ : { symbol: undefined, decimals: undefined }
145
+ // `amount` is the native currency, which always has 18 decimals.
146
+ const decimals = meta.decimals ?? (format === 'amount' && !tokenAddress ? 18 : undefined)
147
+
148
+ // symbol() is whatever the token contract says it is, so the address always goes with it.
149
+ const symbol = `${meta.symbol ? ` ${meta.symbol}` : ''}${tokenAddress ? ` (${tokenAddress})` : ''}`
150
+ if (tokenAddress) out.address = tokenAddress
151
+ if (amount > UNLIMITED_THRESHOLD) out.value = `Unlimited${symbol}`
152
+ else if (decimals === undefined) out.value = `${amount}${symbol} (decimals unknown)`
153
+ else out.value = `${formatUnits(amount, decimals)}${symbol}`
148
154
  return out
149
155
  }
150
156
 
@@ -156,10 +162,10 @@ async function formatField(chain: ChainName, field: IndexField, root: unknown, t
156
162
  return out
157
163
  }
158
164
  const address = getAddress(raw as string)
159
- const name = registry()[address.toLowerCase()]?.protocol
165
+ const name = lookupContract(chain, address)?.protocol
160
166
  out.address = address
161
167
  out.name = name ? protocolLabel(name) : await addressLabel(chain, address).catch(() => undefined)
162
- out.value = out.name ?? address
168
+ out.value = out.name ? `${out.name} (${address})` : address
163
169
  return out
164
170
  }
165
171
 
package/src/client.ts CHANGED
@@ -44,7 +44,6 @@ export class ClientManager {
44
44
  getRpcUrl(chainName: ChainName): string {
45
45
  return getRpcUrl(chainName, this.config)
46
46
  }
47
-
48
47
  getChainId(chainName: ChainName): number {
49
48
  return CHAINS[chainName].chain.id
50
49
  }
package/src/context.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'
2
+
3
+ /**
4
+ * Who a tool call is acting for.
5
+ *
6
+ * A hosted server can carry several people, and signing must never cross between them: a
7
+ * transaction belongs to the person whose agent asked for it and reaches only their wallet.
8
+ *
9
+ * Passed explicitly from the tool wrapper down to whatever looks up a signer. Ambient
10
+ * storage would read better at the call sites and fail worse: a context lost across some
11
+ * later boundary would silently fall back to the shared identity, which is the same
12
+ * cross-tenant delivery the room boundary exists to prevent — and it would not show up in
13
+ * a test, because tests run inside the context.
14
+ */
15
+
16
+ /** Every self-hosted run, and every call arriving without an authenticated identity. */
17
+ export const DEFAULT_IDENTITY = 'default'
18
+
19
+ /**
20
+ * The identity an authenticated request belongs to. Hosted multi-tenancy is what fills
21
+ * this in; a single-credential server authenticates the deployment, not a person, so
22
+ * everyone on it is the same user.
23
+ */
24
+ export function identityFrom(authInfo?: AuthInfo): string {
25
+ const address = authInfo?.extra?.address
26
+ return typeof address === 'string' && address ? address : DEFAULT_IDENTITY
27
+ }
package/src/hosted.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Whether this process serves other people over HTTP, rather than one person on their own
3
+ * machine over stdio. It decides what a caller may reach: `localhost` as a chain, a tracing
4
+ * node on loopback, a browser to open. Every module that guards one of those reads it here.
5
+ *
6
+ * An environment variable rather than an argument, because the chain list is baked into the
7
+ * tool schemas as they are defined — so the answer has to exist before this package is
8
+ * imported, not when a server is started. The CLI sets MCP_HTTP_PORT; a host assembling its
9
+ * own server sets MCP_HOSTED=1, and startHttpServer refuses to run without one of them.
10
+ */
11
+ export const HOSTED = Boolean(process.env.MCP_HTTP_PORT || process.env.MCP_HOSTED)