web3-tools-mcp 3.0.0 → 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 (91) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/README.md +76 -52
  3. package/dist/chain-meta.d.ts.map +1 -1
  4. package/dist/chain-meta.js +19 -8
  5. package/dist/chain-meta.js.map +1 -1
  6. package/dist/clear-signing.d.ts.map +1 -1
  7. package/dist/clear-signing.js +17 -6
  8. package/dist/clear-signing.js.map +1 -1
  9. package/dist/http-server.d.ts.map +1 -1
  10. package/dist/http-server.js +9 -3
  11. package/dist/http-server.js.map +1 -1
  12. package/dist/index.js +5 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/kv-storage.d.ts +2 -1
  15. package/dist/kv-storage.d.ts.map +1 -1
  16. package/dist/kv-storage.js +5 -2
  17. package/dist/kv-storage.js.map +1 -1
  18. package/dist/oauth.d.ts +18 -4
  19. package/dist/oauth.d.ts.map +1 -1
  20. package/dist/oauth.js +62 -26
  21. package/dist/oauth.js.map +1 -1
  22. package/dist/preview.d.ts.map +1 -1
  23. package/dist/preview.js +7 -5
  24. package/dist/preview.js.map +1 -1
  25. package/dist/tools/index.d.ts.map +1 -1
  26. package/dist/tools/index.js +14 -1
  27. package/dist/tools/index.js.map +1 -1
  28. package/dist/tools/read/advanced.d.ts +9 -4
  29. package/dist/tools/read/advanced.d.ts.map +1 -1
  30. package/dist/tools/read/advanced.js +45 -33
  31. package/dist/tools/read/advanced.js.map +1 -1
  32. package/dist/tools/read/balance.d.ts.map +1 -1
  33. package/dist/tools/read/balance.js +17 -32
  34. package/dist/tools/read/balance.js.map +1 -1
  35. package/dist/tools/read/contract-info.d.ts +2 -0
  36. package/dist/tools/read/contract-info.d.ts.map +1 -1
  37. package/dist/tools/read/contract-info.js +53 -122
  38. package/dist/tools/read/contract-info.js.map +1 -1
  39. package/dist/tools/read/contract.d.ts.map +1 -1
  40. package/dist/tools/read/contract.js +2 -1
  41. package/dist/tools/read/contract.js.map +1 -1
  42. package/dist/tools/read/ens.d.ts +20 -40
  43. package/dist/tools/read/ens.d.ts.map +1 -1
  44. package/dist/tools/read/ens.js +25 -33
  45. package/dist/tools/read/ens.js.map +1 -1
  46. package/dist/tools/read/gas.d.ts.map +1 -1
  47. package/dist/tools/read/gas.js +13 -48
  48. package/dist/tools/read/gas.js.map +1 -1
  49. package/dist/tools/read/logs.d.ts +61 -0
  50. package/dist/tools/read/logs.d.ts.map +1 -1
  51. package/dist/tools/read/logs.js +96 -115
  52. package/dist/tools/read/logs.js.map +1 -1
  53. package/dist/tools/read/signatures.d.ts.map +1 -1
  54. package/dist/tools/read/signatures.js +4 -3
  55. package/dist/tools/read/signatures.js.map +1 -1
  56. package/dist/tools/read/signing.d.ts.map +1 -1
  57. package/dist/tools/read/signing.js +8 -2
  58. package/dist/tools/read/signing.js.map +1 -1
  59. package/dist/tools/sign/transactions.d.ts +4 -4
  60. package/dist/tools/sign/transactions.d.ts.map +1 -1
  61. package/dist/tools/sign/transactions.js +54 -9
  62. package/dist/tools/sign/transactions.js.map +1 -1
  63. package/dist/utils.d.ts +18 -0
  64. package/dist/utils.d.ts.map +1 -1
  65. package/dist/utils.js +43 -1
  66. package/dist/utils.js.map +1 -1
  67. package/dist/wallet-client.d.ts +1 -0
  68. package/dist/wallet-client.d.ts.map +1 -1
  69. package/dist/wallet-client.js +16 -3
  70. package/dist/wallet-client.js.map +1 -1
  71. package/package.json +4 -7
  72. package/src/chain-meta.ts +16 -7
  73. package/src/clear-signing.ts +15 -9
  74. package/src/http-server.ts +8 -3
  75. package/src/index.ts +5 -1
  76. package/src/kv-storage.ts +6 -3
  77. package/src/oauth.ts +93 -34
  78. package/src/preview.ts +7 -5
  79. package/src/tools/index.ts +12 -1
  80. package/src/tools/read/advanced.ts +63 -34
  81. package/src/tools/read/balance.ts +18 -33
  82. package/src/tools/read/contract-info.ts +64 -144
  83. package/src/tools/read/contract.ts +2 -1
  84. package/src/tools/read/ens.ts +26 -33
  85. package/src/tools/read/gas.ts +13 -50
  86. package/src/tools/read/logs.ts +131 -143
  87. package/src/tools/read/signatures.ts +4 -4
  88. package/src/tools/read/signing.ts +8 -2
  89. package/src/tools/sign/transactions.ts +60 -9
  90. package/src/utils.ts +47 -1
  91. package/src/wallet-client.ts +17 -3
@@ -15,7 +15,7 @@ import {
15
15
  import { z } from 'zod'
16
16
  import { getClientManager, SUPPORTED_CHAINS } from '../../client.js'
17
17
  import type { ChainName } from '../../types.js'
18
- import { convertEventArgsToTypes, createTool, formatResponse } from '../../utils.js'
18
+ import { convertEventArgsToTypes, createTool, formatResponse, rpcReason } from '../../utils.js'
19
19
 
20
20
  /**
21
21
  * A topic is always 32 bytes, so an address, a number or a short hex value has to be
@@ -34,15 +34,71 @@ function toTopic(value: unknown): Hex {
34
34
  throw new Error(`Cannot filter on indexed value ${JSON.stringify(value)} — expected an address, number or hex string`)
35
35
  }
36
36
 
37
+ /** More than this is cut short, with the block to resume from. */
38
+ export const MAX_LOGS = 1000
39
+
40
+ interface DecodedLog {
41
+ address: Address
42
+ blockHash: Hex | null
43
+ blockNumber: bigint | null
44
+ data: Hex
45
+ logIndex: number | null | undefined
46
+ topics: readonly Hex[]
47
+ transactionHash: Hex | null
48
+ transactionIndex: number | null | undefined
49
+ eventName: string
50
+ args: unknown
51
+ }
52
+
53
+ function serializeLog(log: DecodedLog) {
54
+ return {
55
+ address: log.address,
56
+ blockHash: log.blockHash,
57
+ blockNumber: log.blockNumber?.toString(),
58
+ data: log.data,
59
+ logIndex: log.logIndex,
60
+ topics: log.topics,
61
+ transactionHash: log.transactionHash,
62
+ transactionIndex: log.transactionIndex,
63
+ decoded: { eventName: log.eventName, args: log.args }
64
+ }
65
+ }
66
+
67
+ /** Resuming from `nextBlock` may repeat logs of that block already returned. */
68
+ export function capLogs(logs: DecodedLog[]) {
69
+ if (logs.length <= MAX_LOGS) return { logs: logs.map(serializeLog) }
70
+ return {
71
+ logs: logs.slice(0, MAX_LOGS).map(serializeLog),
72
+ truncated: true,
73
+ nextBlock: logs[MAX_LOGS]?.blockNumber?.toString()
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The same range eth_getLogs would scan — each end defaults to the latest block — in
79
+ * Hypersync's terms, where `toBlock` is exclusive.
80
+ */
81
+ export function hypersyncRange(fromBlock: string | undefined, toBlock: string | undefined, latest: number) {
82
+ return {
83
+ fromBlock: fromBlock ? Number(BigInt(fromBlock)) : latest,
84
+ toBlock: (toBlock ? Number(BigInt(toBlock)) : latest) + 1
85
+ }
86
+ }
87
+
88
+ /** One topic, or a list of alternatives for an OR filter. */
89
+ export function toTopics(value: unknown): Hex | Hex[] {
90
+ return Array.isArray(value) ? value.map(toTopic) : toTopic(value)
91
+ }
92
+
37
93
  async function getLogsWithHypersync(
38
94
  chainName: ChainName,
39
- eventAbi: string,
95
+ abiItem: AbiEvent,
40
96
  hypersyncApiKey?: string,
41
97
  address?: string,
42
98
  fromBlock?: string,
43
99
  toBlock?: string,
44
100
  eventArgs?: Record<string, unknown>
45
- ) {
101
+ ): Promise<DecodedLog[]> {
46
102
  const hypersyncUrl = getClientManager().getHypersyncUrl(chainName)
47
103
  if (!hypersyncUrl) {
48
104
  throw new Error(`Hypersync not supported for chain: ${chainName}`)
@@ -53,25 +109,26 @@ async function getLogsWithHypersync(
53
109
  bearerToken: hypersyncApiKey
54
110
  })
55
111
 
56
- const abiItem = parseAbiItem(eventAbi) as AbiEvent
57
- const eventSignature = toEventSignature(abiItem)
58
- const topic0 = keccak256(toBytes(eventSignature))
112
+ const topic0 = keccak256(toBytes(toEventSignature(abiItem)))
59
113
 
60
114
  // Build topics array for filtering
61
- const topics: (string | string[] | null)[] = [topic0]
115
+ const topics: (Hex | Hex[] | null)[] = [topic0]
62
116
 
63
117
  // Add indexed parameter filtering if eventArgs provided
64
- if (eventArgs && Object.keys(eventArgs).length > 0) {
118
+ if (eventArgs) {
65
119
  const indexedInputs = abiItem.inputs.filter((input) => input.indexed)
66
120
 
67
121
  for (let i = 0; i < indexedInputs.length && i < 3; i++) {
68
122
  const input = indexedInputs[i]
69
123
  const argValue = input?.name ? eventArgs[input.name] : undefined
70
124
 
71
- topics.push(argValue === undefined ? null : toTopic(argValue))
125
+ topics.push(argValue === undefined ? null : toTopics(argValue))
72
126
  }
73
127
  }
74
128
 
129
+ const latest = fromBlock && toBlock ? 0 : await client.getHeight()
130
+ const range = hypersyncRange(fromBlock, toBlock, latest)
131
+
75
132
  const query = {
76
133
  fieldSelection: {
77
134
  log: [
@@ -88,8 +145,7 @@ async function getLogsWithHypersync(
88
145
  LogField.LogIndex
89
146
  ]
90
147
  },
91
- fromBlock: fromBlock ? Number.parseInt(fromBlock, 10) : 0,
92
- toBlock: toBlock ? Number.parseInt(toBlock, 10) : undefined,
148
+ ...range,
93
149
  logs: [
94
150
  {
95
151
  address: address ? [address] : undefined,
@@ -100,11 +156,17 @@ async function getLogsWithHypersync(
100
156
  ]
101
157
  }
102
158
 
103
- const res = await client.get(query)
159
+ // One response covers only part of a long range; `nextBlock` says where it stopped.
160
+ const logs: Log[] = []
161
+ for (let from = range.fromBlock; from < range.toBlock && logs.length <= MAX_LOGS; ) {
162
+ const res = await client.get({ ...query, fromBlock: from })
163
+ logs.push(...res.data.logs)
164
+ if (res.nextBlock <= from) break
165
+ from = res.nextBlock
166
+ }
104
167
 
105
- // Convert hypersync logs to viem-compatible format
106
- const decodedLogs = res.data.logs
107
- .map((log: Log) => {
168
+ return logs
169
+ .map((log: Log): DecodedLog | null => {
108
170
  const topics = log.topics.filter((topic): topic is string => Boolean(topic)) as [Hex, ...Hex[]]
109
171
  const data = (log.data ?? '0x') as Hex
110
172
 
@@ -113,7 +175,7 @@ async function getLogsWithHypersync(
113
175
  return {
114
176
  address: log.address as Address,
115
177
  blockHash: log.blockHash as Hex,
116
- blockNumber: log.blockNumber ? BigInt(log.blockNumber) : 0n,
178
+ blockNumber: log.blockNumber !== undefined ? BigInt(log.blockNumber) : null,
117
179
  data,
118
180
  logIndex: log.logIndex,
119
181
  topics,
@@ -127,165 +189,91 @@ async function getLogsWithHypersync(
127
189
  return null
128
190
  }
129
191
  })
130
- .filter((log): log is NonNullable<typeof log> => log !== null)
131
-
132
- return decodedLogs
192
+ .filter((log): log is DecodedLog => log !== null)
133
193
  }
134
194
 
135
195
  export default {
136
196
  get_logs: createTool(
137
197
  'Query Contract Events',
138
- 'Search and decode contract events with filtering. Uses viem with hypersync fallback. Specify address and block ranges for best performance.',
198
+ `Search and decode contract events with filtering. Uses viem with hypersync fallback. Specify address and block ranges for best performance. Returns at most ${MAX_LOGS} logs; past that, truncated is true and nextBlock says where to resume.`,
139
199
  z.object({
140
200
  chain: z.enum(SUPPORTED_CHAINS).describe('The blockchain network to use'),
141
201
  eventAbi: z
142
202
  .string()
143
203
  .describe('Event ABI definition (e.g., "event Transfer(address indexed from, address indexed to, uint256 value)")'),
144
204
  address: z.string().optional().describe('Contract address (RECOMMENDED for performance)'),
145
- fromBlock: z.string().optional().describe('Start block (defaults to latest-1000)'),
146
- toBlock: z.string().optional().describe('End block (defaults to latest)'),
205
+ fromBlock: z.string().optional().describe('Start block (defaults to the latest block)'),
206
+ toBlock: z.string().optional().describe('End block, inclusive (defaults to the latest block)'),
147
207
  eventArgs: z
148
208
  .record(z.union([z.string(), z.number(), z.boolean(), z.array(z.union([z.string(), z.number(), z.boolean()]))]))
149
209
  .optional()
150
- .describe('Filter by indexed parameters: {"from": "0x123...", "to": "0x456..."}')
210
+ .describe(
211
+ 'Filter by indexed parameters: {"from": "0x123...", "to": "0x456..."}. A list matches any of its values: {"to": ["0x1...", "0x2..."]}'
212
+ )
151
213
  }),
152
214
  async (args) => {
153
215
  const clientManager = getClientManager()
154
- const config = (clientManager as any).config
216
+ const chain = args.chain as ChainName
217
+ const abiItem = parseAbiItem(args.eventAbi) as AbiEvent
155
218
 
156
- // First try with regular viem client
157
- try {
158
- const client = clientManager.getClient(args.chain as ChainName)
159
- const abiItem = parseAbiItem(args.eventAbi) as AbiEvent
160
-
161
- // Determine block range
162
- const fromBlockNum = args.fromBlock ? BigInt(args.fromBlock) : undefined
163
- const toBlockNum = args.toBlock ? BigInt(args.toBlock) : undefined
164
-
165
- // Prepare getLogs parameters
166
- const getLogsParams: {
167
- address?: Address
168
- event: AbiEvent
169
- fromBlock?: bigint
170
- toBlock?: bigint
171
- args?: Record<string, unknown>
172
- } = {
173
- event: abiItem,
174
- fromBlock: fromBlockNum,
175
- toBlock: toBlockNum
176
- }
177
-
178
- // Add address filter if provided
179
- if (args.address && isAddress(args.address)) {
180
- getLogsParams.address = args.address as Address
181
- }
182
-
183
- // Add event argument filters if provided
184
- if (args.eventArgs && Object.keys(args.eventArgs).length > 0) {
185
- // Convert event arguments to proper types based on ABI
186
- const convertedEventArgs = convertEventArgsToTypes(args.eventArgs, abiItem.inputs)
187
- getLogsParams.args = convertedEventArgs
188
- }
189
-
190
- // Use client.getLogs with proper typing
191
- const logs = await client.getLogs(getLogsParams)
192
-
193
- // Convert logs to serializable format
194
- const serializedLogs = logs.map((log) => {
195
- const baseLogData = {
196
- address: log.address,
197
- blockHash: log.blockHash,
198
- blockNumber: log.blockNumber?.toString(),
199
- data: log.data,
200
- logIndex: log.logIndex,
201
- topics: log.topics,
202
- transactionHash: log.transactionHash,
203
- transactionIndex: log.transactionIndex
204
- }
219
+ if (args.address && !isAddress(args.address)) {
220
+ throw new Error(`Invalid contract address: ${args.address}`)
221
+ }
205
222
 
206
- return {
207
- ...baseLogData,
208
- decoded: {
209
- eventName: log.eventName,
210
- args: log.args
211
- }
212
- }
213
- })
223
+ const eventArgs =
224
+ args.eventArgs && Object.keys(args.eventArgs).length > 0
225
+ ? convertEventArgsToTypes(args.eventArgs, abiItem.inputs)
226
+ : undefined
227
+ const fromBlockNum = args.fromBlock ? BigInt(args.fromBlock) : undefined
228
+ const toBlockNum = args.toBlock ? BigInt(args.toBlock) : undefined
214
229
 
230
+ const respond = (logs: DecodedLog[], dataSource: 'viem' | 'hypersync') => {
231
+ const capped = capLogs(logs)
215
232
  return formatResponse({
216
- logs: serializedLogs,
233
+ ...capped,
217
234
  eventSignature: toEventSignature(abiItem),
218
235
  chain: args.chain,
219
236
  fromBlock: fromBlockNum?.toString() || 'latest',
220
237
  toBlock: toBlockNum?.toString() || 'latest',
221
- count: logs.length,
222
- dataSource: 'viem',
238
+ count: capped.logs.length,
239
+ dataSource,
223
240
  filters: {
224
241
  address: args.address || null,
225
242
  eventArgs: args.eventArgs || null
226
243
  }
227
244
  })
228
- } catch (viemError) {
229
- // If viem fails and we have hypersync support for this chain, try hypersync as fallback
230
- if (clientManager.getHypersyncUrl(args.chain as ChainName)) {
231
- console.warn(`Viem getLogs failed for ${args.chain}, trying hypersync fallback:`, viemError)
232
-
233
- try {
234
- const hypersyncLogs = await getLogsWithHypersync(
235
- args.chain as ChainName,
236
- args.eventAbi,
237
- config.hypersyncApiKey,
238
- args.address,
239
- args.fromBlock,
240
- args.toBlock,
241
- args.eventArgs
242
- )
243
-
244
- const abiItem = parseAbiItem(args.eventAbi) as AbiEvent
245
- const fromBlockNum = args.fromBlock ? BigInt(args.fromBlock) : undefined
246
- const toBlockNum = args.toBlock ? BigInt(args.toBlock) : undefined
247
-
248
- // Convert hypersync logs to the same format as viem logs
249
- const serializedLogs = hypersyncLogs.map((log) => {
250
- const baseLogData = {
251
- address: log.address,
252
- blockHash: log.blockHash,
253
- blockNumber: log.blockNumber?.toString(),
254
- data: log.data,
255
- logIndex: log.logIndex,
256
- topics: log.topics,
257
- transactionHash: log.transactionHash,
258
- transactionIndex: log.transactionIndex
259
- }
245
+ }
260
246
 
261
- return {
262
- ...baseLogData,
263
- decoded: {
264
- eventName: log.eventName,
265
- args: log.args
266
- }
267
- }
268
- })
247
+ try {
248
+ const logs = await clientManager.getClient(chain).getLogs({
249
+ address: args.address as Address | undefined,
250
+ event: abiItem,
251
+ args: eventArgs,
252
+ fromBlock: fromBlockNum,
253
+ toBlock: toBlockNum
254
+ })
255
+ return respond(logs as DecodedLog[], 'viem')
256
+ } catch (viemError) {
257
+ if (!clientManager.getHypersyncUrl(chain)) {
258
+ throw new Error(`Failed to get logs with viem (hypersync not available for ${args.chain}): ${rpcReason(viemError)}`)
259
+ }
260
+ console.warn(`Viem getLogs failed for ${args.chain}, trying hypersync fallback: ${rpcReason(viemError)}`)
269
261
 
270
- return formatResponse({
271
- logs: serializedLogs,
272
- eventSignature: toEventSignature(abiItem),
273
- chain: args.chain,
274
- fromBlock: fromBlockNum?.toString() || 'latest',
275
- toBlock: toBlockNum?.toString() || 'latest',
276
- count: hypersyncLogs.length,
277
- dataSource: 'hypersync',
278
- filters: {
279
- address: args.address || null,
280
- eventArgs: args.eventArgs || null
281
- }
282
- })
283
- } catch (hypersyncError) {
284
- throw new Error(`Both viem and hypersync failed. Viem error: ${viemError}. Hypersync error: ${hypersyncError}`)
285
- }
286
- } else {
287
- // No hypersync fallback available for this chain
288
- throw new Error(`Failed to get logs with viem (hypersync not available for ${args.chain}): ${viemError}`)
262
+ try {
263
+ const logs = await getLogsWithHypersync(
264
+ chain,
265
+ abiItem,
266
+ clientManager.getConfig().hypersyncApiKey,
267
+ args.address,
268
+ args.fromBlock,
269
+ args.toBlock,
270
+ eventArgs
271
+ )
272
+ return respond(logs, 'hypersync')
273
+ } catch (hypersyncError) {
274
+ throw new Error(
275
+ `Both viem and hypersync failed. Viem error: ${rpcReason(viemError)}. Hypersync error: ${rpcReason(hypersyncError)}`
276
+ )
289
277
  }
290
278
  }
291
279
  }
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  type AbiEvent,
3
3
  type AbiFunction,
4
- type AbiParameter,
5
4
  encodeFunctionData,
6
5
  keccak256,
7
6
  parseAbiItem,
@@ -11,7 +10,7 @@ import {
11
10
  } from 'viem'
12
11
  import { z } from 'zod'
13
12
  import type { AbiError } from '../../types.js'
14
- import { createTool, formatResponse } from '../../utils.js'
13
+ import { convertArgumentsToTypes, createTool, formatResponse } from '../../utils.js'
15
14
 
16
15
  const FunctionAbiSchema = z.object({
17
16
  functionAbi: z.string().describe('Function ABI definition (e.g., "function transfer(address to, uint256 amount)")')
@@ -92,7 +91,8 @@ export default {
92
91
  async (args) => {
93
92
  const results = args.items.map((item) => {
94
93
  const abiItem = parseAbiItem(item.errorAbi) as AbiError
95
- const signature = `${abiItem.name}(${abiItem.inputs.map((input: AbiParameter) => input.type).join(',')})`
94
+ // Formatted as a function, which an error's signature is; viem keeps an "error " prefix otherwise.
95
+ const signature = toFunctionSignature({ ...abiItem, type: 'function', outputs: [], stateMutability: 'nonpayable' })
96
96
  const hash = keccak256(toBytes(signature))
97
97
  const selector = hash.slice(0, 10)
98
98
 
@@ -125,7 +125,7 @@ export default {
125
125
  const data = encodeFunctionData({
126
126
  abi: [abiItem],
127
127
  functionName: abiItem.name,
128
- args: args.args as readonly unknown[]
128
+ args: convertArgumentsToTypes(args.args, abiItem.inputs)
129
129
  })
130
130
 
131
131
  return formatResponse({
@@ -52,12 +52,18 @@ export default {
52
52
  }
53
53
 
54
54
  if (request.state === 'failed') {
55
+ // Only an explicit rejection proves nothing was signed. A timeout or a dropped
56
+ // connection can land after the wallet signed and broadcast.
57
+ const rejected = request.stage === 'approval' && /rejected|denied/i.test(request.error ?? '')
55
58
  return formatResponse({
56
59
  success: false,
57
- status: 'rejected_or_failed',
60
+ status: rejected ? 'rejected' : 'outcome_unknown',
58
61
  what: request.summary,
59
62
  error: request.error,
60
- message: 'Nothing was signed. The wallet rejected it, or it expired before anybody approved.'
63
+ ...(request.txHash && { transactionHash: request.txHash }),
64
+ message: rejected
65
+ ? 'The wallet rejected it. Nothing was signed.'
66
+ : 'It did not complete here, but it may still have been signed or broadcast. Do not retry until the wallet or a block explorer shows it did not go through.'
61
67
  })
62
68
  }
63
69
 
@@ -1,12 +1,13 @@
1
1
  import { randomBytes } from 'node:crypto'
2
2
  import { type AbiFunction, type Address, encodeFunctionData, isAddress, parseAbiItem, parseUnits, toHex } from 'viem'
3
3
  import { z } from 'zod'
4
+ import { tokenMeta } from '../../chain-meta.js'
4
5
  import { getClientManager, SUPPORTED_CHAINS } from '../../client.js'
5
6
  import { log } from '../../log.js'
6
7
  import { buildTxPreview, type RawTx, type TxPreview } from '../../preview.js'
7
8
  import { type SigningProgress, type SigningRequest, signingCall } from '../../signing-requests.js'
8
9
  import type { ChainName } from '../../types.js'
9
- import { createTool, formatResponse } from '../../utils.js'
10
+ import { convertArgumentsToTypes, createTool, formatResponse } from '../../utils.js'
10
11
  import { getWalletClient } from '../../wallet-client.js'
11
12
  import { getPhoneSigner } from '../../walletconnect.js'
12
13
  import { failure } from './errors.js'
@@ -74,6 +75,33 @@ async function requirePhoneSession(identity: string, account?: string) {
74
75
  return { phone, session, from }
75
76
  }
76
77
 
78
+ /** The phone's wallet reaches chains through its own RPC, which cannot see this machine's node. */
79
+ function requireReachable(chain: ChainName, signWith: SignWith) {
80
+ if (chain === 'localhost' && signWith === 'phone') {
81
+ throw new Error('A phone wallet cannot sign on localhost: it has no route to this machine\'s node. Use signWith "browser".')
82
+ }
83
+ }
84
+
85
+ /**
86
+ * The token's own decimals. A caller-supplied figure is only a cross-check: trusting a wrong
87
+ * one moves orders of magnitude more or less than was asked for.
88
+ */
89
+ async function tokenDecimals(chain: ChainName, token: Address, claimed?: number): Promise<number> {
90
+ const onChain = (await tokenMeta(chain, token).catch(() => undefined))?.decimals
91
+ if (onChain === undefined) {
92
+ if (claimed === undefined) {
93
+ throw new Error(
94
+ `Could not read decimals() from ${token} on ${chain}. Check it is an ERC20 token on this chain, or pass "decimals" explicitly.`
95
+ )
96
+ }
97
+ return claimed
98
+ }
99
+ if (claimed !== undefined && claimed !== onChain) {
100
+ throw new Error(`"decimals" was ${claimed}, but ${token} reports ${onChain} on ${chain}. Nothing was sent.`)
101
+ }
102
+ return onChain
103
+ }
104
+
77
105
  /** Long enough for a congested chain, short enough that a stuck one is reported rather than hung on. */
78
106
  const RECEIPT_TIMEOUT = 180_000
79
107
 
@@ -140,6 +168,7 @@ async function requestSignature(
140
168
  progress: SigningProgress
141
169
  ) {
142
170
  log('info', 'Transaction', `${chain} → ${tx.to}, signing with ${signWith}`)
171
+ requireReachable(chain, signWith)
143
172
  if (signWith === 'phone') return signOnPhone(chain, tx, identity, account, progress)
144
173
 
145
174
  const wallet = getWalletClient(identity)
@@ -212,6 +241,8 @@ function previewSummary(preview: TxPreview) {
212
241
  return {
213
242
  action: preview.decoded?.intent ?? preview.decoded?.functionName,
214
243
  protocol: preview.decoded?.protocol ?? preview.toLabel,
244
+ // Labels come from the contracts themselves, so the address they belong to goes too.
245
+ to: preview.to,
215
246
  details: preview.decoded?.fields.map((f) => `${f.name}: ${f.value}${f.warning ? ` (${f.warning})` : ''}`),
216
247
  simulation: preview.simulation
217
248
  }
@@ -273,7 +304,11 @@ export default {
273
304
  tokenAddress: z.string().describe('ERC20 token contract address'),
274
305
  to: z.string().describe('Recipient address'),
275
306
  amount: z.string().describe('Amount in token units (e.g., "100" for 100 USDC)'),
276
- decimals: z.number().optional().default(18).describe('Token decimals (default: 18)'),
307
+ decimals: z
308
+ .number()
309
+ .int()
310
+ .optional()
311
+ .describe('Token decimals. Read from the token when omitted; when given, must match what the token reports.'),
277
312
  signWith: SignWithSchema,
278
313
  account: AccountSchema
279
314
  }),
@@ -281,10 +316,11 @@ export default {
281
316
  try {
282
317
  const tokenAddress = requireAddress('token address', args.tokenAddress)
283
318
  const to = requireAddress('recipient address', args.to)
319
+ const decimals = await tokenDecimals(args.chain as ChainName, tokenAddress, args.decimals)
284
320
 
285
321
  const data = encodeFunctionData({
286
322
  abi: [parseAbiItem('function transfer(address to, uint256 amount)')],
287
- args: [to, parseUnits(args.amount, args.decimals ?? 18)]
323
+ args: [to, parseUnits(args.amount, decimals)]
288
324
  })
289
325
 
290
326
  const outcome = await signingCall(
@@ -327,9 +363,19 @@ export default {
327
363
  contractAddress: z.string().describe('Contract address'),
328
364
  functionAbi: z.string().describe('Function ABI definition (e.g., "function transfer(address to, uint256 amount)")'),
329
365
  args: z
330
- .array(z.union([z.string(), z.number(), z.boolean()]))
366
+ .array(
367
+ z.union([
368
+ z.string(),
369
+ // JSON numbers past 2^53 arrive already rounded, so signing one would send a different value.
370
+ z.number().refine(Number.isSafeInteger, {
371
+ message:
372
+ 'Numbers must be integers within ±2^53-1. Pass large or exact values as strings, e.g. "1000000000000000000".'
373
+ }),
374
+ z.boolean()
375
+ ])
376
+ )
331
377
  .optional()
332
- .describe('Function arguments in order matching the ABI signature'),
378
+ .describe('Function arguments in order matching the ABI signature. Pass uint/int values as strings.'),
333
379
  value: z.string().optional().describe('Optional native value to send with transaction (in ETH units, e.g., "0.1")'),
334
380
  signWith: SignWithSchema,
335
381
  account: AccountSchema
@@ -342,7 +388,7 @@ export default {
342
388
  const data = encodeFunctionData({
343
389
  abi: [abiItem],
344
390
  functionName: abiItem.name,
345
- args: (args.args || []) as readonly unknown[]
391
+ args: convertArgumentsToTypes(args.args || [], abiItem.inputs)
346
392
  })
347
393
  const value = args.value ? `0x${parseUnits(args.value, 18).toString(16)}` : '0x0'
348
394
 
@@ -389,8 +435,11 @@ export default {
389
435
  try {
390
436
  const sign = async (progress: SigningProgress) => {
391
437
  if (args.signWith === 'phone') {
392
- const { phone, from } = await requirePhoneSession(identity, args.account)
393
- return phone.request('mainnet', 'personal_sign', [toHex(args.message), from], {
438
+ const { phone, session, from } = await requirePhoneSession(identity, args.account)
439
+ // personal_sign is chain-agnostic, but the request still has to name a chain the session holds.
440
+ const chain = SUPPORTED_CHAINS.find((c) => session.chains.includes(getClientManager().getChainId(c)))
441
+ if (!chain) throw new Error(`The paired wallet approved no chain this server knows: ${session.chains.join(', ')}.`)
442
+ return phone.request(chain, 'personal_sign', [toHex(args.message), from], {
394
443
  identity,
395
444
  account: args.account,
396
445
  onWaiting: progress.waiting
@@ -403,7 +452,9 @@ export default {
403
452
  id: generateRequestId(),
404
453
  type: 'sign_message',
405
454
  chain: 'any',
406
- data: { message: args.message }
455
+ // Hex on both signers: a wallet reads a raw "0x…" string as bytes, so the same text
456
+ // would otherwise sign differently depending on where it was approved.
457
+ data: { message: toHex(args.message) }
407
458
  })
408
459
  }
409
460
 
package/src/utils.ts CHANGED
@@ -136,11 +136,13 @@ function findErrorInTrace(trace: unknown): { errorPath: SummarizedCall[]; errorM
136
136
  }
137
137
 
138
138
  /**
139
- * Coerce a JSON value into what viem expects for an ABI type. Tuples, arrays and anything
139
+ * Coerce a JSON value into what viem expects for an ABI type. A list is coerced element by
140
+ * element — an array argument, or an OR filter on an event argument. Tuples and anything
140
141
  * else pass through: viem validates them, and guessing here would only mangle them.
141
142
  */
142
143
  function coerce(value: unknown, type: string): unknown {
143
144
  if (value === null || value === undefined) return null
145
+ if (Array.isArray(value)) return value.map((item) => coerce(item, type))
144
146
 
145
147
  if (type === 'address' || type === 'string' || type.startsWith('bytes')) {
146
148
  return String(value)
@@ -195,6 +197,50 @@ export function convertEventArgsToTypes(
195
197
  )
196
198
  }
197
199
 
200
+ /** One tool call is one quota unit, so an unbounded batch would be a way around the quota. */
201
+ export const MAX_BATCH = 25
202
+
203
+ /**
204
+ * Why an RPC call failed, without the request URL — viem puts it in `message`, and with a
205
+ * provider key configured that URL carries the key.
206
+ */
207
+ export function rpcReason(error: unknown): string {
208
+ const { details, shortMessage } = error as { details?: string; shortMessage?: string }
209
+ return details || shortMessage || (error instanceof Error ? error.message : String(error))
210
+ }
211
+
212
+ /** Every configured key blanked out of `text`: RPC URLs carry them, and errors quote the URL. */
213
+ export function redactSecrets(text: string, config: Config): string {
214
+ return [config.alchemyApiKey, config.infuraApiKey, config.etherscanApiKey, config.hypersyncApiKey].reduce<string>(
215
+ (out, secret) => (secret ? out.replaceAll(secret, '[redacted]') : out),
216
+ text
217
+ )
218
+ }
219
+
220
+ /** A Map whose entries expire after `ttlMs`, dropping the oldest once it holds `maxSize`. */
221
+ export class TtlCache<V> {
222
+ private entries = new Map<string, { value: V; expires: number }>()
223
+
224
+ constructor(
225
+ private maxSize: number,
226
+ private ttlMs: number
227
+ ) {}
228
+
229
+ get(key: string): V | undefined {
230
+ const entry = this.entries.get(key)
231
+ if (!entry) return undefined
232
+ if (entry.expires > Date.now()) return entry.value
233
+ this.entries.delete(key)
234
+ return undefined
235
+ }
236
+
237
+ set(key: string, value: V): void {
238
+ this.entries.delete(key)
239
+ this.entries.set(key, { value, expires: Date.now() + this.ttlMs })
240
+ if (this.entries.size > this.maxSize) this.entries.delete(this.entries.keys().next().value as string)
241
+ }
242
+ }
243
+
198
244
  // Format response as tool result
199
245
  export function formatResponse(data: unknown): ToolResult {
200
246
  const serializable = convertBigIntToString(data)