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.
Files changed (113) hide show
  1. package/CHANGELOG.md +115 -2
  2. package/README.md +96 -60
  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/chains.d.ts +609 -65
  7. package/dist/chains.d.ts.map +1 -1
  8. package/dist/chains.js +89 -28
  9. package/dist/chains.js.map +1 -1
  10. package/dist/clear-signing.d.ts.map +1 -1
  11. package/dist/clear-signing.js +17 -6
  12. package/dist/clear-signing.js.map +1 -1
  13. package/dist/client.d.ts.map +1 -1
  14. package/dist/client.js +2 -4
  15. package/dist/client.js.map +1 -1
  16. package/dist/http-server.d.ts +1 -1
  17. package/dist/http-server.d.ts.map +1 -1
  18. package/dist/http-server.js +12 -5
  19. package/dist/http-server.js.map +1 -1
  20. package/dist/index.js +5 -6
  21. package/dist/index.js.map +1 -1
  22. package/dist/kv-storage.d.ts +2 -1
  23. package/dist/kv-storage.d.ts.map +1 -1
  24. package/dist/kv-storage.js +5 -2
  25. package/dist/kv-storage.js.map +1 -1
  26. package/dist/oauth.d.ts +18 -4
  27. package/dist/oauth.d.ts.map +1 -1
  28. package/dist/oauth.js +62 -26
  29. package/dist/oauth.js.map +1 -1
  30. package/dist/preview.d.ts.map +1 -1
  31. package/dist/preview.js +7 -5
  32. package/dist/preview.js.map +1 -1
  33. package/dist/tools/index.d.ts.map +1 -1
  34. package/dist/tools/index.js +16 -1
  35. package/dist/tools/index.js.map +1 -1
  36. package/dist/tools/read/advanced.d.ts +25 -20
  37. package/dist/tools/read/advanced.d.ts.map +1 -1
  38. package/dist/tools/read/advanced.js +45 -33
  39. package/dist/tools/read/advanced.js.map +1 -1
  40. package/dist/tools/read/balance.d.ts +6 -6
  41. package/dist/tools/read/balance.d.ts.map +1 -1
  42. package/dist/tools/read/balance.js +17 -32
  43. package/dist/tools/read/balance.js.map +1 -1
  44. package/dist/tools/read/contract-info.d.ts +14 -12
  45. package/dist/tools/read/contract-info.d.ts.map +1 -1
  46. package/dist/tools/read/contract-info.js +53 -122
  47. package/dist/tools/read/contract-info.js.map +1 -1
  48. package/dist/tools/read/contract.d.ts +10 -10
  49. package/dist/tools/read/contract.d.ts.map +1 -1
  50. package/dist/tools/read/contract.js +2 -1
  51. package/dist/tools/read/contract.js.map +1 -1
  52. package/dist/tools/read/defi.d.ts +86 -0
  53. package/dist/tools/read/defi.d.ts.map +1 -0
  54. package/dist/tools/read/defi.js +210 -0
  55. package/dist/tools/read/defi.js.map +1 -0
  56. package/dist/tools/read/ens.d.ts +20 -40
  57. package/dist/tools/read/ens.d.ts.map +1 -1
  58. package/dist/tools/read/ens.js +25 -33
  59. package/dist/tools/read/ens.js.map +1 -1
  60. package/dist/tools/read/gas.d.ts +16 -16
  61. package/dist/tools/read/gas.d.ts.map +1 -1
  62. package/dist/tools/read/gas.js +13 -48
  63. package/dist/tools/read/gas.js.map +1 -1
  64. package/dist/tools/read/logs.d.ts +65 -4
  65. package/dist/tools/read/logs.d.ts.map +1 -1
  66. package/dist/tools/read/logs.js +96 -115
  67. package/dist/tools/read/logs.js.map +1 -1
  68. package/dist/tools/read/signatures.d.ts.map +1 -1
  69. package/dist/tools/read/signatures.js +4 -3
  70. package/dist/tools/read/signatures.js.map +1 -1
  71. package/dist/tools/read/signing.d.ts.map +1 -1
  72. package/dist/tools/read/signing.js +8 -2
  73. package/dist/tools/read/signing.js.map +1 -1
  74. package/dist/tools/sign/transactions.d.ts +16 -16
  75. package/dist/tools/sign/transactions.d.ts.map +1 -1
  76. package/dist/tools/sign/transactions.js +54 -9
  77. package/dist/tools/sign/transactions.js.map +1 -1
  78. package/dist/tools/sign/wallet.d.ts +4 -4
  79. package/dist/types.d.ts +0 -1
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/utils.d.ts +18 -0
  82. package/dist/utils.d.ts.map +1 -1
  83. package/dist/utils.js +43 -2
  84. package/dist/utils.js.map +1 -1
  85. package/dist/wallet-client.d.ts +1 -0
  86. package/dist/wallet-client.d.ts.map +1 -1
  87. package/dist/wallet-client.js +18 -5
  88. package/dist/wallet-client.js.map +1 -1
  89. package/package.json +4 -7
  90. package/src/chain-meta.ts +16 -7
  91. package/src/chains.ts +101 -31
  92. package/src/clear-signing.ts +15 -9
  93. package/src/client.ts +2 -3
  94. package/src/http-server.ts +11 -6
  95. package/src/index.ts +5 -6
  96. package/src/kv-storage.ts +6 -3
  97. package/src/oauth.ts +93 -34
  98. package/src/preview.ts +7 -5
  99. package/src/tools/index.ts +14 -1
  100. package/src/tools/read/advanced.ts +63 -34
  101. package/src/tools/read/balance.ts +18 -33
  102. package/src/tools/read/contract-info.ts +64 -144
  103. package/src/tools/read/contract.ts +2 -1
  104. package/src/tools/read/defi.ts +287 -0
  105. package/src/tools/read/ens.ts +26 -33
  106. package/src/tools/read/gas.ts +13 -50
  107. package/src/tools/read/logs.ts +131 -143
  108. package/src/tools/read/signatures.ts +4 -4
  109. package/src/tools/read/signing.ts +8 -2
  110. package/src/tools/sign/transactions.ts +60 -9
  111. package/src/types.ts +0 -1
  112. package/src/utils.ts +47 -2
  113. 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 // Cache the raw Etherscan response
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
- const contractCache = new Map<string, CachedContract>()
39
- // No TTL - contracts are immutable, cache never expires
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
- // Shared helper to fetch contract info from Etherscan (with caching)
47
- async function fetchContractInfo(chainId: number, address: string, etherscanApiKey: string): Promise<EtherscanContractInfo> {
48
- // Check cache first
49
- const cacheKey = getCacheKey(chainId, address)
50
- const cached = contractCache.get(cacheKey)
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
- if (cached?.rawInfo) {
53
- return cached.rawInfo
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
- // Fetch from Etherscan API
57
- const sourceUrl = `https://api.etherscan.io/v2/api?chainid=${chainId}&module=contract&action=getsourcecode&address=${address}&apikey=${etherscanApiKey}`
58
-
59
- const sourceResponse = await fetch(sourceUrl)
60
- const sourceData = (await sourceResponse.json()) as {
61
- status: string
62
- message?: string
63
- result: Array<EtherscanContractInfo>
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
- if (sourceData.status !== '1' || !sourceData.result || sourceData.result.length === 0) {
67
- throw new Error(`Etherscan API error: ${sourceData.message || 'No contract source found'}`)
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
- const contractInfo = sourceData.result[0]
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 = (clientManager as any).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 creationUrl = `https://api.etherscan.io/v2/api?chainid=${chainId}&module=contract&action=getcontractcreation&contractaddresses=${args.address}&apikey=${config.etherscanApiKey}`
193
- const creationResponse = await fetch(creationUrl)
194
- const creationData = (await creationResponse.json()) as {
195
- status: string
196
- result?: Array<{ contractCreator: string; txHash: string }>
197
- }
198
- if (creationData.status === '1' && creationData.result && creationData.result.length > 0) {
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
- // Creation info is optional
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 = (clientManager as any).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
- // Parse source code
320
- let sourceFiles: Record<string, string> = {}
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: fileStats.reduce((sum, stat) => sum + stat.lines, 0),
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 = fileStats
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
- // Parse implementation source code
391
- let implSourceFiles: Record<string, string> = {}
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
+ }