web3-tools-mcp 3.0.0 → 3.2.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 +115 -2
- package/README.md +96 -60
- 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/chains.d.ts +609 -65
- package/dist/chains.d.ts.map +1 -1
- package/dist/chains.js +89 -28
- package/dist/chains.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/client.d.ts.map +1 -1
- package/dist/client.js +2 -4
- package/dist/client.js.map +1 -1
- package/dist/http-server.d.ts +1 -1
- package/dist/http-server.d.ts.map +1 -1
- package/dist/http-server.js +12 -5
- package/dist/http-server.js.map +1 -1
- package/dist/index.js +5 -6
- 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 +16 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/read/advanced.d.ts +25 -20
- 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 +6 -6
- 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 +14 -12
- 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 +10 -10
- 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/defi.d.ts +86 -0
- package/dist/tools/read/defi.d.ts.map +1 -0
- package/dist/tools/read/defi.js +210 -0
- package/dist/tools/read/defi.js.map +1 -0
- 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 +16 -16
- 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 +65 -4
- 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 +16 -16
- 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/tools/sign/wallet.d.ts +4 -4
- package/dist/types.d.ts +0 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +18 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +43 -2
- 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 +18 -5
- package/dist/wallet-client.js.map +1 -1
- package/package.json +4 -7
- package/src/chain-meta.ts +16 -7
- package/src/chains.ts +101 -31
- package/src/clear-signing.ts +15 -9
- package/src/client.ts +2 -3
- package/src/http-server.ts +11 -6
- package/src/index.ts +5 -6
- package/src/kv-storage.ts +6 -3
- package/src/oauth.ts +93 -34
- package/src/preview.ts +7 -5
- package/src/tools/index.ts +14 -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/defi.ts +287 -0
- 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/types.ts +0 -1
- package/src/utils.ts +47 -2
- package/src/wallet-client.ts +26 -5
|
@@ -2,7 +2,7 @@ import { isAddress } from 'viem'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { getClientManager, SUPPORTED_CHAINS } from '../../client.js'
|
|
4
4
|
import type { ChainName } from '../../types.js'
|
|
5
|
-
import { createTool, formatResponse } from '../../utils.js'
|
|
5
|
+
import { createTool, formatResponse, TtlCache } from '../../utils.js'
|
|
6
6
|
|
|
7
7
|
// Type for raw Etherscan contract info
|
|
8
8
|
interface EtherscanContractInfo {
|
|
@@ -21,66 +21,83 @@ interface EtherscanContractInfo {
|
|
|
21
21
|
SwarmSource: string
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
// Cache for contract source code and ABIs
|
|
25
24
|
interface CachedContract {
|
|
26
|
-
rawInfo: EtherscanContractInfo
|
|
25
|
+
rawInfo: EtherscanContractInfo
|
|
27
26
|
sourceFiles: Record<string, string>
|
|
28
|
-
abi?: any[]
|
|
29
27
|
metadata: {
|
|
30
28
|
contractName: string
|
|
31
29
|
compilerVersion: string
|
|
32
30
|
isProxy: boolean
|
|
33
31
|
implementationAddress?: string
|
|
34
32
|
}
|
|
35
|
-
timestamp: number
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
// Source is immutable, but a proxy's implementation is not; an hour notices an upgrade.
|
|
36
|
+
const contractCache = new TtlCache<CachedContract>(100, 60 * 60 * 1000)
|
|
40
37
|
|
|
41
38
|
// Helper to generate cache key
|
|
42
39
|
function getCacheKey(chainId: number, address: string): string {
|
|
43
40
|
return `${chainId}:${address.toLowerCase()}`
|
|
44
41
|
}
|
|
45
42
|
|
|
46
|
-
|
|
47
|
-
async function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
43
|
+
/** Etherscan's `result` for a query, or why there is none: it answers status "0" with the reason there. */
|
|
44
|
+
export async function etherscan<T>(chainId: number, params: string, etherscanApiKey: string): Promise<T> {
|
|
45
|
+
const response = await fetch(`https://api.etherscan.io/v2/api?chainid=${chainId}&${params}&apikey=${etherscanApiKey}`, {
|
|
46
|
+
signal: AbortSignal.timeout(15_000)
|
|
47
|
+
})
|
|
48
|
+
if (!response.ok) throw new Error(`Etherscan API error: HTTP ${response.status}`)
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
const data = (await response.json()) as { status: string; message?: string; result: unknown }
|
|
51
|
+
if (data.status !== '1') {
|
|
52
|
+
throw new Error(`Etherscan API error: ${typeof data.result === 'string' ? data.result : data.message || 'request failed'}`)
|
|
54
53
|
}
|
|
54
|
+
return data.result as T
|
|
55
|
+
}
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
/** Etherscan's SourceCode is one file, a `{{…}}`-wrapped standard JSON input, or a plain map of files. */
|
|
58
|
+
function parseSourceFiles(info: EtherscanContractInfo): Record<string, string> {
|
|
59
|
+
const single = { [`${info.ContractName}.sol`]: info.SourceCode }
|
|
60
|
+
if (!info.SourceCode.startsWith('{')) return single
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(info.SourceCode.startsWith('{{') ? info.SourceCode.slice(1, -1) : info.SourceCode)
|
|
64
|
+
const files = (parsed.sources ?? parsed) as Record<string, string | { content?: string }>
|
|
65
|
+
return Object.fromEntries(
|
|
66
|
+
Object.entries(files).map(([path, data]) => [path, typeof data === 'string' ? data : data.content || ''])
|
|
67
|
+
)
|
|
68
|
+
} catch {
|
|
69
|
+
return single
|
|
64
70
|
}
|
|
71
|
+
}
|
|
65
72
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
function fileStats(sourceFiles: Record<string, string>) {
|
|
74
|
+
return Object.entries(sourceFiles).map(([path, content]) => ({ path, lines: content.split('\n').length, size: content.length }))
|
|
75
|
+
}
|
|
69
76
|
|
|
70
|
-
|
|
77
|
+
// Shared helper to fetch contract info from Etherscan (with caching)
|
|
78
|
+
async function fetchContractInfo(chainId: number, address: string, etherscanApiKey: string): Promise<EtherscanContractInfo> {
|
|
79
|
+
const cacheKey = getCacheKey(chainId, address)
|
|
80
|
+
const cached = contractCache.get(cacheKey)
|
|
81
|
+
if (cached) return cached.rawInfo
|
|
82
|
+
|
|
83
|
+
const result = await etherscan<EtherscanContractInfo[]>(
|
|
84
|
+
chainId,
|
|
85
|
+
`module=contract&action=getsourcecode&address=${address}`,
|
|
86
|
+
etherscanApiKey
|
|
87
|
+
)
|
|
88
|
+
const contractInfo = result[0]
|
|
89
|
+
if (!contractInfo) throw new Error('Etherscan API error: No contract source found')
|
|
71
90
|
|
|
72
|
-
// Cache the raw info immediately
|
|
73
91
|
const isProxy = contractInfo.Proxy === '1'
|
|
74
92
|
contractCache.set(cacheKey, {
|
|
75
93
|
rawInfo: contractInfo,
|
|
76
|
-
sourceFiles: {},
|
|
94
|
+
sourceFiles: contractInfo.SourceCode ? parseSourceFiles(contractInfo) : {},
|
|
77
95
|
metadata: {
|
|
78
96
|
contractName: contractInfo.ContractName,
|
|
79
97
|
compilerVersion: contractInfo.CompilerVersion,
|
|
80
98
|
isProxy,
|
|
81
99
|
implementationAddress: isProxy ? contractInfo.Implementation : undefined
|
|
82
|
-
}
|
|
83
|
-
timestamp: Date.now()
|
|
100
|
+
}
|
|
84
101
|
})
|
|
85
102
|
|
|
86
103
|
return contractInfo
|
|
@@ -102,7 +119,7 @@ export default {
|
|
|
102
119
|
}),
|
|
103
120
|
async (args) => {
|
|
104
121
|
const clientManager = getClientManager()
|
|
105
|
-
const config = (
|
|
122
|
+
const config = clientManager.getConfig()
|
|
106
123
|
|
|
107
124
|
if (!config.etherscanApiKey) {
|
|
108
125
|
throw new Error(
|
|
@@ -189,23 +206,16 @@ export default {
|
|
|
189
206
|
// Try to get creation info if requested
|
|
190
207
|
if (includeSet.has('creation')) {
|
|
191
208
|
try {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const result = creationData.result[0]
|
|
200
|
-
if (result) {
|
|
201
|
-
abiResult.creationInfo = {
|
|
202
|
-
creator: result.contractCreator,
|
|
203
|
-
transactionHash: result.txHash
|
|
204
|
-
}
|
|
205
|
-
}
|
|
209
|
+
const [creation] = await etherscan<Array<{ contractCreator: string; txHash: string }>>(
|
|
210
|
+
chainId,
|
|
211
|
+
`module=contract&action=getcontractcreation&contractaddresses=${args.address}`,
|
|
212
|
+
config.etherscanApiKey
|
|
213
|
+
)
|
|
214
|
+
if (creation) {
|
|
215
|
+
abiResult.creationInfo = { creator: creation.contractCreator, transactionHash: creation.txHash }
|
|
206
216
|
}
|
|
207
|
-
} catch {
|
|
208
|
-
|
|
217
|
+
} catch (error) {
|
|
218
|
+
abiResult.creationError = (error as Error).message
|
|
209
219
|
}
|
|
210
220
|
}
|
|
211
221
|
|
|
@@ -249,14 +259,6 @@ export default {
|
|
|
249
259
|
}
|
|
250
260
|
}
|
|
251
261
|
|
|
252
|
-
// Update cache with ABI (rawInfo already cached by fetchContractInfo)
|
|
253
|
-
const cacheKey = getCacheKey(chainId, args.address)
|
|
254
|
-
const existing = contractCache.get(cacheKey)!
|
|
255
|
-
contractCache.set(cacheKey, {
|
|
256
|
-
...existing,
|
|
257
|
-
abi
|
|
258
|
-
})
|
|
259
|
-
|
|
260
262
|
return formatResponse(abiResult)
|
|
261
263
|
}
|
|
262
264
|
),
|
|
@@ -282,7 +284,7 @@ export default {
|
|
|
282
284
|
}),
|
|
283
285
|
async (args) => {
|
|
284
286
|
const clientManager = getClientManager()
|
|
285
|
-
const config = (
|
|
287
|
+
const config = clientManager.getConfig()
|
|
286
288
|
|
|
287
289
|
if (!config.etherscanApiKey) {
|
|
288
290
|
throw new Error('Etherscan API key is required. Use --etherscan-api-key or set ETHERSCAN_API_KEY environment variable.')
|
|
@@ -316,43 +318,8 @@ export default {
|
|
|
316
318
|
})
|
|
317
319
|
}
|
|
318
320
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
let sourceCodeString = contractInfo.SourceCode
|
|
322
|
-
|
|
323
|
-
// Handle multi-file sources (wrapped in {{ }} or [ ])
|
|
324
|
-
if (sourceCodeString.startsWith('{{') || sourceCodeString.startsWith('[')) {
|
|
325
|
-
try {
|
|
326
|
-
// Remove outer braces/brackets if present
|
|
327
|
-
if (sourceCodeString.startsWith('{{')) {
|
|
328
|
-
sourceCodeString = sourceCodeString.slice(1, -1)
|
|
329
|
-
}
|
|
330
|
-
const parsed = JSON.parse(sourceCodeString)
|
|
331
|
-
|
|
332
|
-
if (parsed.sources) {
|
|
333
|
-
// Standard JSON format
|
|
334
|
-
sourceFiles = Object.fromEntries(
|
|
335
|
-
Object.entries(parsed.sources).map(([path, data]: [string, any]) => [path, data.content || ''])
|
|
336
|
-
)
|
|
337
|
-
} else if (typeof parsed === 'object') {
|
|
338
|
-
// Simple object format
|
|
339
|
-
sourceFiles = parsed
|
|
340
|
-
}
|
|
341
|
-
} catch {
|
|
342
|
-
// If parsing fails, treat as single file
|
|
343
|
-
sourceFiles = { [`${contractInfo.ContractName}.sol`]: sourceCodeString }
|
|
344
|
-
}
|
|
345
|
-
} else {
|
|
346
|
-
// Single file source
|
|
347
|
-
sourceFiles = { [`${contractInfo.ContractName}.sol`]: sourceCodeString }
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Calculate file stats for summary mode
|
|
351
|
-
const fileStats = Object.entries(sourceFiles).map(([path, content]) => ({
|
|
352
|
-
path,
|
|
353
|
-
lines: (content as string).split('\n').length,
|
|
354
|
-
size: (content as string).length
|
|
355
|
-
}))
|
|
321
|
+
const sourceFiles = parseSourceFiles(contractInfo)
|
|
322
|
+
const files = fileStats(sourceFiles)
|
|
356
323
|
|
|
357
324
|
const result: Record<string, unknown> = {
|
|
358
325
|
success: true,
|
|
@@ -369,7 +336,7 @@ export default {
|
|
|
369
336
|
licenseType: contractInfo.LicenseType,
|
|
370
337
|
constructorArguments: contractInfo.ConstructorArguments,
|
|
371
338
|
fileCount: Object.keys(sourceFiles).length,
|
|
372
|
-
totalLines:
|
|
339
|
+
totalLines: files.reduce((sum, stat) => sum + stat.lines, 0),
|
|
373
340
|
etherscanUrl: clientManager.explorerUrl(args.chain as ChainName, `/address/${args.address}#code`)
|
|
374
341
|
}
|
|
375
342
|
|
|
@@ -377,7 +344,7 @@ export default {
|
|
|
377
344
|
if (args.includeSource === 'full') {
|
|
378
345
|
result.sourceFiles = sourceFiles
|
|
379
346
|
} else if (args.includeSource === 'summary') {
|
|
380
|
-
result.files =
|
|
347
|
+
result.files = files
|
|
381
348
|
}
|
|
382
349
|
// 'none' mode: no source files added
|
|
383
350
|
|
|
@@ -387,37 +354,8 @@ export default {
|
|
|
387
354
|
const implInfo = await fetchContractInfo(chainId, contractInfo.Implementation, config.etherscanApiKey)
|
|
388
355
|
|
|
389
356
|
if (implInfo && implInfo.SourceCode !== '') {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
let implSourceCodeString = implInfo.SourceCode
|
|
393
|
-
|
|
394
|
-
if (implSourceCodeString.startsWith('{{') || implSourceCodeString.startsWith('[')) {
|
|
395
|
-
try {
|
|
396
|
-
if (implSourceCodeString.startsWith('{{')) {
|
|
397
|
-
implSourceCodeString = implSourceCodeString.slice(1, -1)
|
|
398
|
-
}
|
|
399
|
-
const parsed = JSON.parse(implSourceCodeString)
|
|
400
|
-
|
|
401
|
-
if (parsed.sources) {
|
|
402
|
-
implSourceFiles = Object.fromEntries(
|
|
403
|
-
Object.entries(parsed.sources).map(([path, data]: [string, any]) => [path, data.content || ''])
|
|
404
|
-
)
|
|
405
|
-
} else if (typeof parsed === 'object') {
|
|
406
|
-
implSourceFiles = parsed
|
|
407
|
-
}
|
|
408
|
-
} catch {
|
|
409
|
-
implSourceFiles = { [`${implInfo.ContractName}.sol`]: implSourceCodeString }
|
|
410
|
-
}
|
|
411
|
-
} else {
|
|
412
|
-
implSourceFiles = { [`${implInfo.ContractName}.sol`]: implSourceCodeString }
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// Calculate implementation file stats
|
|
416
|
-
const implFileStats = Object.entries(implSourceFiles).map(([path, content]) => ({
|
|
417
|
-
path,
|
|
418
|
-
lines: (content as string).split('\n').length,
|
|
419
|
-
size: (content as string).length
|
|
420
|
-
}))
|
|
357
|
+
const implSourceFiles = parseSourceFiles(implInfo)
|
|
358
|
+
const implFileStats = fileStats(implSourceFiles)
|
|
421
359
|
|
|
422
360
|
const implResult: Record<string, unknown> = {
|
|
423
361
|
address: contractInfo.Implementation,
|
|
@@ -440,16 +378,6 @@ export default {
|
|
|
440
378
|
}
|
|
441
379
|
|
|
442
380
|
result.implementation = implResult
|
|
443
|
-
|
|
444
|
-
// Update cache with implementation source (rawInfo already cached by fetchContractInfo)
|
|
445
|
-
if (contractInfo.Implementation) {
|
|
446
|
-
const implCacheKey = getCacheKey(chainId, contractInfo.Implementation)
|
|
447
|
-
const existingImpl = contractCache.get(implCacheKey)!
|
|
448
|
-
contractCache.set(implCacheKey, {
|
|
449
|
-
...existingImpl,
|
|
450
|
-
sourceFiles: implSourceFiles
|
|
451
|
-
})
|
|
452
|
-
}
|
|
453
381
|
}
|
|
454
382
|
} catch (error) {
|
|
455
383
|
// Implementation fetch failed, continue without it
|
|
@@ -459,14 +387,6 @@ export default {
|
|
|
459
387
|
result.implementationAddress = contractInfo.Implementation
|
|
460
388
|
}
|
|
461
389
|
|
|
462
|
-
// Update cache with main contract source (rawInfo already cached by fetchContractInfo)
|
|
463
|
-
const cacheKey = getCacheKey(chainId, args.address)
|
|
464
|
-
const existing = contractCache.get(cacheKey)!
|
|
465
|
-
contractCache.set(cacheKey, {
|
|
466
|
-
...existing,
|
|
467
|
-
sourceFiles
|
|
468
|
-
})
|
|
469
|
-
|
|
470
390
|
return formatResponse(result)
|
|
471
391
|
}
|
|
472
392
|
),
|
|
@@ -2,7 +2,7 @@ import { type AbiFunction, type Address, isAddress, parseAbiItem } from 'viem'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { getClientManager, SUPPORTED_CHAINS } from '../../client.js'
|
|
4
4
|
import type { ChainName } from '../../types.js'
|
|
5
|
-
import { convertArgumentsToTypes, createTool, formatResponse } from '../../utils.js'
|
|
5
|
+
import { convertArgumentsToTypes, createTool, formatResponse, MAX_BATCH } from '../../utils.js'
|
|
6
6
|
|
|
7
7
|
const ContractCallSchema = z.object({
|
|
8
8
|
chain: z.enum(SUPPORTED_CHAINS).describe('Blockchain network (mainnet, base, arbitrum, polygon, optimism, celo, localhost)'),
|
|
@@ -27,6 +27,7 @@ export default {
|
|
|
27
27
|
z.object({
|
|
28
28
|
calls: z
|
|
29
29
|
.array(ContractCallSchema)
|
|
30
|
+
.max(MAX_BATCH)
|
|
30
31
|
.describe('Array of contract function calls. Automatically batched by chain/block for optimal performance.')
|
|
31
32
|
}),
|
|
32
33
|
async (args) => {
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { type Address, formatUnits, type Hex, isAddress, zeroAddress } from 'viem'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { CHAINS, type ChainName, SUPPORTED_CHAINS } from '../../chains.js'
|
|
4
|
+
import { getClientManager } from '../../client.js'
|
|
5
|
+
import { createTool, formatResponse, MAX_BATCH, rpcReason } from '../../utils.js'
|
|
6
|
+
|
|
7
|
+
/** Token ids per DefiLlama price request, to keep the URL short. */
|
|
8
|
+
const PRICE_BATCH = 50
|
|
9
|
+
const MAX_TOKEN_PAGES = 10
|
|
10
|
+
|
|
11
|
+
interface LlamaPrice {
|
|
12
|
+
symbol: string
|
|
13
|
+
price: number
|
|
14
|
+
decimals?: number
|
|
15
|
+
confidence?: number
|
|
16
|
+
timestamp: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface LlamaPool {
|
|
20
|
+
pool: string
|
|
21
|
+
chain: string
|
|
22
|
+
project: string
|
|
23
|
+
symbol: string
|
|
24
|
+
poolMeta: string | null
|
|
25
|
+
tvlUsd: number
|
|
26
|
+
apy: number | null
|
|
27
|
+
apyBase: number | null
|
|
28
|
+
apyReward: number | null
|
|
29
|
+
apyMean30d: number | null
|
|
30
|
+
stablecoin: boolean
|
|
31
|
+
ilRisk: string
|
|
32
|
+
exposure: string
|
|
33
|
+
outlier: boolean
|
|
34
|
+
underlyingTokens: string[] | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** DefiLlama's public API: free, keyless, and the same for every caller. */
|
|
38
|
+
async function llama<T>(url: string): Promise<T> {
|
|
39
|
+
const response = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(30_000) })
|
|
40
|
+
if (!response.ok) throw new Error(`DefiLlama answered ${response.status} for ${url}`)
|
|
41
|
+
return (await response.json()) as T
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function llamaNames(chain: ChainName) {
|
|
45
|
+
const names = CHAINS[chain].llama
|
|
46
|
+
if (!names) throw new Error(`DefiLlama has no data for ${chain}`)
|
|
47
|
+
return names
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Prices by lowercased token address; a token DefiLlama cannot price is absent. */
|
|
51
|
+
async function llamaPrices(chain: ChainName, tokens: string[], timestamp?: number): Promise<Map<string, LlamaPrice>> {
|
|
52
|
+
const prefix = llamaNames(chain).coins
|
|
53
|
+
const when = timestamp ? `historical/${timestamp}` : 'current'
|
|
54
|
+
const batches = Array.from({ length: Math.ceil(tokens.length / PRICE_BATCH) }, (_, index) =>
|
|
55
|
+
tokens.slice(index * PRICE_BATCH, (index + 1) * PRICE_BATCH)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const answers = await Promise.all(
|
|
59
|
+
batches.map((batch) =>
|
|
60
|
+
llama<{ coins: Record<string, LlamaPrice> }>(
|
|
61
|
+
`https://coins.llama.fi/prices/${when}/${batch.map((token) => `${prefix}:${token}`).join(',')}`
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
// DefiLlama keys its answer by the id as sent, but not reliably in the same case.
|
|
66
|
+
return new Map(
|
|
67
|
+
answers.flatMap(({ coins }) => Object.entries(coins).map(([id, price]) => [id.slice(prefix.length + 1).toLowerCase(), price]))
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every ERC20 the address holds a non-zero balance of, as Alchemy indexes them.
|
|
73
|
+
*
|
|
74
|
+
* ponytail: stops after MAX_TOKEN_PAGES pages; past that a wallet is nearly all spam, but a
|
|
75
|
+
* real token beyond the cut is missed. `truncated` says when that happened.
|
|
76
|
+
*/
|
|
77
|
+
async function erc20Balances(chain: ChainName, address: Address) {
|
|
78
|
+
const client = getClientManager().getClient(chain)
|
|
79
|
+
const balances: [string, bigint][] = []
|
|
80
|
+
let pageKey: string | undefined
|
|
81
|
+
|
|
82
|
+
for (let page = 0; page < MAX_TOKEN_PAGES; page++) {
|
|
83
|
+
const result = (await client.request({
|
|
84
|
+
method: 'alchemy_getTokenBalances',
|
|
85
|
+
params: [address, 'erc20', { maxCount: 100, ...(pageKey && { pageKey }) }]
|
|
86
|
+
} as never)) as { tokenBalances: { contractAddress: string; tokenBalance: Hex | null }[]; pageKey?: string }
|
|
87
|
+
|
|
88
|
+
for (const { contractAddress, tokenBalance } of result.tokenBalances) {
|
|
89
|
+
if (tokenBalance && BigInt(tokenBalance) > 0n) balances.push([contractAddress, BigInt(tokenBalance)])
|
|
90
|
+
}
|
|
91
|
+
pageKey = result.pageKey
|
|
92
|
+
if (!pageKey) break
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { balances, truncated: Boolean(pageKey) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function holdingsOn(chain: ChainName, address: Address, minValueUsd: number) {
|
|
99
|
+
const [native, erc20] = await Promise.all([
|
|
100
|
+
getClientManager().getClient(chain).getBalance({ address }),
|
|
101
|
+
erc20Balances(chain, address)
|
|
102
|
+
])
|
|
103
|
+
const held = native > 0n ? [[zeroAddress, native] as [string, bigint], ...erc20.balances] : erc20.balances
|
|
104
|
+
const prices = await llamaPrices(
|
|
105
|
+
chain,
|
|
106
|
+
held.map(([token]) => token)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const holdings = []
|
|
110
|
+
let unpriced = 0
|
|
111
|
+
let belowMinValue = 0
|
|
112
|
+
for (const [token, raw] of held) {
|
|
113
|
+
const price = prices.get(token.toLowerCase())
|
|
114
|
+
if (!price || price.decimals === undefined) {
|
|
115
|
+
unpriced++
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
const amount = formatUnits(raw, price.decimals)
|
|
119
|
+
const valueUsd = Number(amount) * price.price
|
|
120
|
+
if (valueUsd < minValueUsd) {
|
|
121
|
+
belowMinValue++
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
holdings.push({
|
|
125
|
+
chain,
|
|
126
|
+
token: token === zeroAddress ? 'native' : token,
|
|
127
|
+
symbol: price.symbol,
|
|
128
|
+
amount,
|
|
129
|
+
priceUsd: price.price,
|
|
130
|
+
valueUsd: Math.round(valueUsd * 100) / 100
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { chain, holdings, unpriced, belowMinValue, truncated: erc20.truncated }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default {
|
|
138
|
+
get_token_prices: createTool(
|
|
139
|
+
'Get Token Prices',
|
|
140
|
+
"USD prices from DefiLlama for tokens on one chain, now or at a past timestamp. Use the zero address for the chain's native token. " +
|
|
141
|
+
'Tokens DefiLlama cannot price are listed as unpriced — usually spam or illiquid.',
|
|
142
|
+
z.object({
|
|
143
|
+
chain: z.enum(SUPPORTED_CHAINS).describe('Blockchain network'),
|
|
144
|
+
tokens: z
|
|
145
|
+
.array(z.string())
|
|
146
|
+
.min(1)
|
|
147
|
+
.max(MAX_BATCH)
|
|
148
|
+
.describe(`Token contract addresses (up to ${MAX_BATCH}); ${zeroAddress} for the native token`),
|
|
149
|
+
timestamp: z.number().int().positive().optional().describe('Unix time in seconds for a past price (defaults to now)')
|
|
150
|
+
}),
|
|
151
|
+
async (args) => {
|
|
152
|
+
const invalid = args.tokens.find((token) => !isAddress(token))
|
|
153
|
+
if (invalid) throw new Error(`Invalid token address: ${invalid}`)
|
|
154
|
+
|
|
155
|
+
const byToken = await llamaPrices(args.chain as ChainName, args.tokens, args.timestamp)
|
|
156
|
+
|
|
157
|
+
const prices = args.tokens.map((token) => {
|
|
158
|
+
const price = byToken.get(token.toLowerCase())
|
|
159
|
+
if (!price) return { token, priced: false }
|
|
160
|
+
return {
|
|
161
|
+
token,
|
|
162
|
+
priced: true,
|
|
163
|
+
symbol: price.symbol,
|
|
164
|
+
priceUsd: price.price,
|
|
165
|
+
decimals: price.decimals,
|
|
166
|
+
confidence: price.confidence,
|
|
167
|
+
updatedAt: new Date(price.timestamp * 1000).toISOString()
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
return formatResponse({ chain: args.chain, source: 'DefiLlama', prices })
|
|
172
|
+
}
|
|
173
|
+
),
|
|
174
|
+
|
|
175
|
+
get_portfolio: createTool(
|
|
176
|
+
'Get Portfolio',
|
|
177
|
+
'What a wallet holds across chains, valued in USD: native and ERC20 balances found through Alchemy, priced by ' +
|
|
178
|
+
'DefiLlama, largest first. Tokens DefiLlama cannot price are left out — that drops the spam airdrops most ' +
|
|
179
|
+
'wallets collect. Positions inside DeFi protocols appear only as the receipt tokens DefiLlama happens to price.',
|
|
180
|
+
z.object({
|
|
181
|
+
address: z.string().describe('Wallet address'),
|
|
182
|
+
chains: z
|
|
183
|
+
.array(z.enum(SUPPORTED_CHAINS))
|
|
184
|
+
.optional()
|
|
185
|
+
.describe('Chains to look at (defaults to every chain where both the Alchemy token API and DefiLlama are available)'),
|
|
186
|
+
minValueUsd: z.number().optional().default(1).describe('Leave out holdings worth less than this, in USD (default 1)')
|
|
187
|
+
}),
|
|
188
|
+
async (args) => {
|
|
189
|
+
if (!isAddress(args.address)) throw new Error(`Invalid address: ${args.address}`)
|
|
190
|
+
const address = args.address
|
|
191
|
+
const chains = ((args.chains ?? SUPPORTED_CHAINS) as ChainName[]).filter(
|
|
192
|
+
(chain) => CHAINS[chain].alchemy && CHAINS[chain].alchemyTokenApi !== false && CHAINS[chain].llama
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
const scanned: Awaited<ReturnType<typeof holdingsOn>>[] = []
|
|
196
|
+
const failedChains: { chain: ChainName; error: string }[] = []
|
|
197
|
+
await Promise.all(
|
|
198
|
+
chains.map(async (chain) => {
|
|
199
|
+
try {
|
|
200
|
+
scanned.push(await holdingsOn(chain, address, args.minValueUsd))
|
|
201
|
+
} catch (error) {
|
|
202
|
+
failedChains.push({ chain, error: rpcReason(error) })
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
)
|
|
206
|
+
const holdings = scanned.flatMap((result) => result.holdings).sort((a, b) => b.valueUsd - a.valueUsd)
|
|
207
|
+
const truncatedChains = scanned.filter((result) => result.truncated).map((result) => result.chain)
|
|
208
|
+
|
|
209
|
+
return formatResponse({
|
|
210
|
+
address,
|
|
211
|
+
totalUsd: Math.round(holdings.reduce((sum, holding) => sum + holding.valueUsd, 0) * 100) / 100,
|
|
212
|
+
holdings,
|
|
213
|
+
hidden: {
|
|
214
|
+
unpriced: scanned.reduce((sum, result) => sum + result.unpriced, 0),
|
|
215
|
+
belowMinValue: scanned.reduce((sum, result) => sum + result.belowMinValue, 0)
|
|
216
|
+
},
|
|
217
|
+
...(truncatedChains.length > 0 && { truncatedChains }),
|
|
218
|
+
...(failedChains.length > 0 && { failedChains })
|
|
219
|
+
})
|
|
220
|
+
},
|
|
221
|
+
// alchemy_getTokenBalances is what finds the tokens; no other RPC here indexes them.
|
|
222
|
+
() => Boolean(getClientManager().getConfig().alchemyApiKey)
|
|
223
|
+
),
|
|
224
|
+
|
|
225
|
+
get_yield_pools: createTool(
|
|
226
|
+
'Get Yield Pools',
|
|
227
|
+
'DeFi lending, staking and liquidity pools from DefiLlama, highest APY first — e.g. where USDC earns most on ' +
|
|
228
|
+
'Arbitrum, or what Aave pays on Base. Pools DefiLlama flags as APY outliers are left out. APY is a percentage.',
|
|
229
|
+
z.object({
|
|
230
|
+
chain: z
|
|
231
|
+
.enum(SUPPORTED_CHAINS)
|
|
232
|
+
.optional()
|
|
233
|
+
.describe('Only pools on this chain (defaults to every chain this server supports)'),
|
|
234
|
+
symbol: z.string().optional().describe('Only pools holding this token, e.g. "USDC" (matches "USDC" and "USDC-WETH")'),
|
|
235
|
+
project: z.string().optional().describe('Only this protocol, as DefiLlama names it — "aave" matches aave-v3 and aave-v2'),
|
|
236
|
+
stablecoinOnly: z.boolean().optional().default(false).describe('Only pools of stablecoins'),
|
|
237
|
+
minTvlUsd: z.number().optional().default(1_000_000).describe('Leave out pools smaller than this, in USD (default 1M)'),
|
|
238
|
+
limit: z.number().int().min(1).max(100).optional().default(20).describe('How many pools to return (default 20)')
|
|
239
|
+
}),
|
|
240
|
+
async (args) => {
|
|
241
|
+
const chains = new Map<string, ChainName>(
|
|
242
|
+
(args.chain ? [args.chain as ChainName] : SUPPORTED_CHAINS)
|
|
243
|
+
.filter((chain) => CHAINS[chain].llama)
|
|
244
|
+
.map((chain) => [llamaNames(chain).yields, chain])
|
|
245
|
+
)
|
|
246
|
+
const symbol = args.symbol?.toUpperCase()
|
|
247
|
+
const project = args.project?.toLowerCase()
|
|
248
|
+
|
|
249
|
+
// ponytail: fetches DefiLlama's whole pool list (~12 MB) per call; cache it for a few minutes if calls pile up.
|
|
250
|
+
const { data } = await llama<{ data: LlamaPool[] }>('https://yields.llama.fi/pools')
|
|
251
|
+
|
|
252
|
+
const matching = data
|
|
253
|
+
.filter(
|
|
254
|
+
(pool) =>
|
|
255
|
+
chains.has(pool.chain) &&
|
|
256
|
+
!pool.outlier &&
|
|
257
|
+
pool.apy !== null &&
|
|
258
|
+
pool.tvlUsd >= args.minTvlUsd &&
|
|
259
|
+
(!args.stablecoinOnly || pool.stablecoin) &&
|
|
260
|
+
(!project || pool.project.includes(project)) &&
|
|
261
|
+
(!symbol || pool.symbol.toUpperCase().split('-').includes(symbol))
|
|
262
|
+
)
|
|
263
|
+
.sort((a, b) => (b.apy ?? 0) - (a.apy ?? 0))
|
|
264
|
+
|
|
265
|
+
return formatResponse({
|
|
266
|
+
source: 'DefiLlama',
|
|
267
|
+
matched: matching.length,
|
|
268
|
+
pools: matching.slice(0, args.limit).map((pool) => ({
|
|
269
|
+
chain: chains.get(pool.chain),
|
|
270
|
+
project: pool.project,
|
|
271
|
+
symbol: pool.symbol,
|
|
272
|
+
...(pool.poolMeta && { meta: pool.poolMeta }),
|
|
273
|
+
apy: pool.apy,
|
|
274
|
+
apyBase: pool.apyBase,
|
|
275
|
+
apyReward: pool.apyReward,
|
|
276
|
+
apyMean30d: pool.apyMean30d,
|
|
277
|
+
tvlUsd: Math.round(pool.tvlUsd),
|
|
278
|
+
stablecoin: pool.stablecoin,
|
|
279
|
+
ilRisk: pool.ilRisk,
|
|
280
|
+
exposure: pool.exposure,
|
|
281
|
+
underlyingTokens: pool.underlyingTokens,
|
|
282
|
+
url: `https://defillama.com/yields/pool/${pool.pool}`
|
|
283
|
+
}))
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
}
|