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.
- package/CHANGELOG.md +86 -2
- package/README.md +76 -52
- package/dist/chain-meta.d.ts.map +1 -1
- package/dist/chain-meta.js +19 -8
- package/dist/chain-meta.js.map +1 -1
- package/dist/clear-signing.d.ts.map +1 -1
- package/dist/clear-signing.js +17 -6
- package/dist/clear-signing.js.map +1 -1
- package/dist/http-server.d.ts.map +1 -1
- package/dist/http-server.js +9 -3
- package/dist/http-server.js.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/kv-storage.d.ts +2 -1
- package/dist/kv-storage.d.ts.map +1 -1
- package/dist/kv-storage.js +5 -2
- package/dist/kv-storage.js.map +1 -1
- package/dist/oauth.d.ts +18 -4
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +62 -26
- package/dist/oauth.js.map +1 -1
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +7 -5
- package/dist/preview.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +14 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/read/advanced.d.ts +9 -4
- package/dist/tools/read/advanced.d.ts.map +1 -1
- package/dist/tools/read/advanced.js +45 -33
- package/dist/tools/read/advanced.js.map +1 -1
- package/dist/tools/read/balance.d.ts.map +1 -1
- package/dist/tools/read/balance.js +17 -32
- package/dist/tools/read/balance.js.map +1 -1
- package/dist/tools/read/contract-info.d.ts +2 -0
- package/dist/tools/read/contract-info.d.ts.map +1 -1
- package/dist/tools/read/contract-info.js +53 -122
- package/dist/tools/read/contract-info.js.map +1 -1
- package/dist/tools/read/contract.d.ts.map +1 -1
- package/dist/tools/read/contract.js +2 -1
- package/dist/tools/read/contract.js.map +1 -1
- package/dist/tools/read/ens.d.ts +20 -40
- package/dist/tools/read/ens.d.ts.map +1 -1
- package/dist/tools/read/ens.js +25 -33
- package/dist/tools/read/ens.js.map +1 -1
- package/dist/tools/read/gas.d.ts.map +1 -1
- package/dist/tools/read/gas.js +13 -48
- package/dist/tools/read/gas.js.map +1 -1
- package/dist/tools/read/logs.d.ts +61 -0
- package/dist/tools/read/logs.d.ts.map +1 -1
- package/dist/tools/read/logs.js +96 -115
- package/dist/tools/read/logs.js.map +1 -1
- package/dist/tools/read/signatures.d.ts.map +1 -1
- package/dist/tools/read/signatures.js +4 -3
- package/dist/tools/read/signatures.js.map +1 -1
- package/dist/tools/read/signing.d.ts.map +1 -1
- package/dist/tools/read/signing.js +8 -2
- package/dist/tools/read/signing.js.map +1 -1
- package/dist/tools/sign/transactions.d.ts +4 -4
- package/dist/tools/sign/transactions.d.ts.map +1 -1
- package/dist/tools/sign/transactions.js +54 -9
- package/dist/tools/sign/transactions.js.map +1 -1
- package/dist/utils.d.ts +18 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +43 -1
- package/dist/utils.js.map +1 -1
- package/dist/wallet-client.d.ts +1 -0
- package/dist/wallet-client.d.ts.map +1 -1
- package/dist/wallet-client.js +16 -3
- package/dist/wallet-client.js.map +1 -1
- package/package.json +4 -7
- package/src/chain-meta.ts +16 -7
- package/src/clear-signing.ts +15 -9
- package/src/http-server.ts +8 -3
- package/src/index.ts +5 -1
- package/src/kv-storage.ts +6 -3
- package/src/oauth.ts +93 -34
- package/src/preview.ts +7 -5
- package/src/tools/index.ts +12 -1
- package/src/tools/read/advanced.ts +63 -34
- package/src/tools/read/balance.ts +18 -33
- package/src/tools/read/contract-info.ts +64 -144
- package/src/tools/read/contract.ts +2 -1
- package/src/tools/read/ens.ts +26 -33
- package/src/tools/read/gas.ts +13 -50
- package/src/tools/read/logs.ts +131 -143
- package/src/tools/read/signatures.ts +4 -4
- package/src/tools/read/signing.ts +8 -2
- package/src/tools/sign/transactions.ts +60 -9
- package/src/utils.ts +47 -1
- package/src/wallet-client.ts +17 -3
package/src/tools/read/logs.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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: (
|
|
115
|
+
const topics: (Hex | Hex[] | null)[] = [topic0]
|
|
62
116
|
|
|
63
117
|
// Add indexed parameter filtering if eventArgs provided
|
|
64
|
-
if (eventArgs
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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) :
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
216
|
+
const chain = args.chain as ChainName
|
|
217
|
+
const abiItem = parseAbiItem(args.eventAbi) as AbiEvent
|
|
155
218
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
|
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: '
|
|
60
|
+
status: rejected ? 'rejected' : 'outcome_unknown',
|
|
58
61
|
what: request.summary,
|
|
59
62
|
error: request.error,
|
|
60
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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 || [])
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|