web3-tools-mcp 3.1.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 +29 -0
- package/README.md +23 -11
- 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/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 +3 -2
- package/dist/http-server.js.map +1 -1
- package/dist/index.js +0 -5
- package/dist/index.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/read/advanced.d.ts +16 -16
- package/dist/tools/read/balance.d.ts +6 -6
- package/dist/tools/read/contract-info.d.ts +12 -12
- package/dist/tools/read/contract.d.ts +10 -10
- 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/gas.d.ts +16 -16
- package/dist/tools/read/logs.d.ts +4 -4
- package/dist/tools/sign/transactions.d.ts +12 -12
- 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.map +1 -1
- package/dist/utils.js +1 -2
- package/dist/utils.js.map +1 -1
- package/dist/wallet-client.d.ts.map +1 -1
- package/dist/wallet-client.js +2 -2
- package/dist/wallet-client.js.map +1 -1
- package/package.json +2 -2
- package/src/chains.ts +101 -31
- package/src/client.ts +2 -3
- package/src/http-server.ts +3 -3
- package/src/index.ts +0 -5
- package/src/tools/index.ts +2 -0
- package/src/tools/read/defi.ts +287 -0
- package/src/types.ts +0 -1
- package/src/utils.ts +1 -2
- package/src/wallet-client.ts +9 -2
package/src/chains.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import type { Chain } from 'viem'
|
|
2
2
|
import {
|
|
3
3
|
arbitrum,
|
|
4
|
+
arc,
|
|
4
5
|
avalanche,
|
|
5
6
|
base,
|
|
6
7
|
bsc,
|
|
8
|
+
celo,
|
|
7
9
|
gnosis,
|
|
10
|
+
hyperEvm,
|
|
11
|
+
ink,
|
|
8
12
|
linea,
|
|
9
13
|
localhost,
|
|
10
14
|
mainnet,
|
|
15
|
+
mantle,
|
|
16
|
+
monad,
|
|
11
17
|
optimism,
|
|
18
|
+
plasma,
|
|
12
19
|
polygon,
|
|
13
|
-
|
|
20
|
+
robinhood,
|
|
14
21
|
unichain,
|
|
15
22
|
zksync
|
|
16
23
|
} from 'viem/chains'
|
|
@@ -25,39 +32,41 @@ interface ChainInfo {
|
|
|
25
32
|
chain: Chain
|
|
26
33
|
/** Subdomain under g.alchemy.com; absent where Alchemy has no endpoint. */
|
|
27
34
|
alchemy?: string
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
/** Used when no
|
|
35
|
+
/** False where Alchemy serves the RPC but not its token API, which `get_portfolio` needs. */
|
|
36
|
+
alchemyTokenApi?: false
|
|
37
|
+
/** Used when no Alchemy key is configured, or Alchemy has no endpoint. */
|
|
31
38
|
fallback: string
|
|
32
39
|
/** Block explorer domain, for links. Absent where there is nothing to link to. */
|
|
33
40
|
explorer?: string
|
|
34
41
|
hypersync?: string
|
|
42
|
+
/** DefiLlama's two names for the chain: the prefix of a coin id, and the `chain` of a yield pool. */
|
|
43
|
+
llama?: { coins: string; yields: string }
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
const TABLE = {
|
|
38
47
|
mainnet: {
|
|
39
48
|
chain: mainnet,
|
|
40
49
|
alchemy: 'eth-mainnet',
|
|
41
|
-
infura: 'mainnet',
|
|
42
50
|
fallback: 'https://ethereum-rpc.publicnode.com',
|
|
43
51
|
explorer: 'etherscan.io',
|
|
44
|
-
hypersync: 'https://eth.hypersync.xyz'
|
|
52
|
+
hypersync: 'https://eth.hypersync.xyz',
|
|
53
|
+
llama: { coins: 'ethereum', yields: 'Ethereum' }
|
|
45
54
|
},
|
|
46
55
|
arbitrum: {
|
|
47
56
|
chain: arbitrum,
|
|
48
57
|
alchemy: 'arb-mainnet',
|
|
49
|
-
infura: 'arbitrum-mainnet',
|
|
50
58
|
fallback: 'https://arb1.arbitrum.io/rpc',
|
|
51
59
|
explorer: 'arbiscan.io',
|
|
52
|
-
hypersync: 'https://arbitrum.hypersync.xyz'
|
|
60
|
+
hypersync: 'https://arbitrum.hypersync.xyz',
|
|
61
|
+
llama: { coins: 'arbitrum', yields: 'Arbitrum' }
|
|
53
62
|
},
|
|
54
63
|
avalanche: {
|
|
55
64
|
chain: avalanche,
|
|
56
65
|
alchemy: 'avax-mainnet',
|
|
57
|
-
infura: 'avalanche-mainnet',
|
|
58
66
|
fallback: 'https://api.avax.network/ext/bc/C/rpc',
|
|
59
67
|
explorer: 'snowtrace.io',
|
|
60
|
-
hypersync: 'https://avalanche.hypersync.xyz'
|
|
68
|
+
hypersync: 'https://avalanche.hypersync.xyz',
|
|
69
|
+
llama: { coins: 'avax', yields: 'Avalanche' }
|
|
61
70
|
},
|
|
62
71
|
base: {
|
|
63
72
|
chain: base,
|
|
@@ -65,70 +74,131 @@ const TABLE = {
|
|
|
65
74
|
// publicnode refuses receipts and anything else it calls an archive request
|
|
66
75
|
fallback: 'https://mainnet.base.org',
|
|
67
76
|
explorer: 'basescan.org',
|
|
68
|
-
hypersync: 'https://base.hypersync.xyz'
|
|
77
|
+
hypersync: 'https://base.hypersync.xyz',
|
|
78
|
+
llama: { coins: 'base', yields: 'Base' }
|
|
69
79
|
},
|
|
70
80
|
bnb: {
|
|
71
81
|
chain: bsc,
|
|
72
82
|
alchemy: 'bnb-mainnet',
|
|
73
|
-
infura: 'bsc-mainnet',
|
|
74
83
|
fallback: 'https://bsc-dataseed.bnbchain.org',
|
|
75
84
|
explorer: 'bscscan.com',
|
|
76
|
-
hypersync: 'https://bsc.hypersync.xyz'
|
|
85
|
+
hypersync: 'https://bsc.hypersync.xyz',
|
|
86
|
+
llama: { coins: 'bsc', yields: 'BSC' }
|
|
77
87
|
},
|
|
78
88
|
gnosis: {
|
|
79
89
|
chain: gnosis,
|
|
80
90
|
alchemy: 'gnosis-mainnet',
|
|
81
91
|
fallback: 'https://rpc.gnosischain.com',
|
|
82
92
|
explorer: 'gnosisscan.io',
|
|
83
|
-
hypersync: 'https://gnosis.hypersync.xyz'
|
|
84
|
-
|
|
85
|
-
sonic: {
|
|
86
|
-
chain: sonic,
|
|
87
|
-
alchemy: 'sonic-mainnet',
|
|
88
|
-
fallback: 'https://rpc.soniclabs.com',
|
|
89
|
-
explorer: 'sonicscan.org',
|
|
90
|
-
hypersync: 'https://sonic.hypersync.xyz'
|
|
93
|
+
hypersync: 'https://gnosis.hypersync.xyz',
|
|
94
|
+
llama: { coins: 'xdai', yields: 'Gnosis' }
|
|
91
95
|
},
|
|
92
96
|
optimism: {
|
|
93
97
|
chain: optimism,
|
|
94
98
|
alchemy: 'opt-mainnet',
|
|
95
|
-
infura: 'optimism-mainnet',
|
|
96
99
|
fallback: 'https://mainnet.optimism.io',
|
|
97
100
|
explorer: 'optimistic.etherscan.io',
|
|
98
|
-
hypersync: 'https://optimism.hypersync.xyz'
|
|
101
|
+
hypersync: 'https://optimism.hypersync.xyz',
|
|
102
|
+
llama: { coins: 'optimism', yields: 'OP Mainnet' }
|
|
99
103
|
},
|
|
100
104
|
polygon: {
|
|
101
105
|
chain: polygon,
|
|
102
106
|
alchemy: 'polygon-mainnet',
|
|
103
|
-
infura: 'polygon-mainnet',
|
|
104
107
|
// polygon-rpc.com now answers 401 "API key disabled" to anonymous callers
|
|
105
108
|
fallback: 'https://polygon-bor-rpc.publicnode.com',
|
|
106
109
|
explorer: 'polygonscan.com',
|
|
107
|
-
hypersync: 'https://polygon.hypersync.xyz'
|
|
110
|
+
hypersync: 'https://polygon.hypersync.xyz',
|
|
111
|
+
llama: { coins: 'polygon', yields: 'Polygon' }
|
|
108
112
|
},
|
|
109
113
|
zksync: {
|
|
110
114
|
chain: zksync,
|
|
111
115
|
alchemy: 'zksync-mainnet',
|
|
112
|
-
infura: 'zksync-mainnet',
|
|
113
116
|
fallback: 'https://mainnet.era.zksync.io',
|
|
114
117
|
explorer: 'era.zksync.network',
|
|
115
|
-
hypersync: 'https://zksync.hypersync.xyz'
|
|
118
|
+
hypersync: 'https://zksync.hypersync.xyz',
|
|
119
|
+
llama: { coins: 'era', yields: 'ZKsync Era' }
|
|
116
120
|
},
|
|
117
121
|
linea: {
|
|
118
122
|
chain: linea,
|
|
119
123
|
alchemy: 'linea-mainnet',
|
|
120
|
-
infura: 'linea-mainnet',
|
|
121
124
|
fallback: 'https://rpc.linea.build',
|
|
122
125
|
explorer: 'lineascan.build',
|
|
123
|
-
hypersync: 'https://linea.hypersync.xyz'
|
|
126
|
+
hypersync: 'https://linea.hypersync.xyz',
|
|
127
|
+
llama: { coins: 'linea', yields: 'Linea' }
|
|
124
128
|
},
|
|
125
129
|
unichain: {
|
|
126
130
|
chain: unichain,
|
|
127
131
|
alchemy: 'unichain-mainnet',
|
|
128
|
-
infura: 'unichain-mainnet',
|
|
129
132
|
fallback: 'https://mainnet.unichain.org',
|
|
130
133
|
explorer: 'uniscan.xyz',
|
|
131
|
-
hypersync: 'https://unichain.hypersync.xyz'
|
|
134
|
+
hypersync: 'https://unichain.hypersync.xyz',
|
|
135
|
+
llama: { coins: 'unichain', yields: 'Unichain' }
|
|
136
|
+
},
|
|
137
|
+
monad: {
|
|
138
|
+
chain: monad,
|
|
139
|
+
alchemy: 'monad-mainnet',
|
|
140
|
+
fallback: 'https://rpc.monad.xyz',
|
|
141
|
+
explorer: 'monadscan.com',
|
|
142
|
+
hypersync: 'https://monad.hypersync.xyz',
|
|
143
|
+
llama: { coins: 'monad', yields: 'Monad' }
|
|
144
|
+
},
|
|
145
|
+
robinhood: {
|
|
146
|
+
chain: robinhood,
|
|
147
|
+
alchemy: 'robinhood-mainnet',
|
|
148
|
+
fallback: 'https://rpc.mainnet.chain.robinhood.com',
|
|
149
|
+
explorer: 'robinhoodchain.blockscout.com',
|
|
150
|
+
hypersync: 'https://robinhood.hypersync.xyz',
|
|
151
|
+
llama: { coins: 'robinhood', yields: 'Robinhood Chain' }
|
|
152
|
+
},
|
|
153
|
+
arc: {
|
|
154
|
+
chain: arc,
|
|
155
|
+
alchemy: 'arc-mainnet',
|
|
156
|
+
fallback: 'https://rpc.mainnet.arc.io',
|
|
157
|
+
explorer: 'explorer.arc.io',
|
|
158
|
+
hypersync: 'https://arc.hypersync.xyz',
|
|
159
|
+
llama: { coins: 'arc', yields: 'Arc' }
|
|
160
|
+
},
|
|
161
|
+
plasma: {
|
|
162
|
+
chain: plasma,
|
|
163
|
+
alchemy: 'plasma-mainnet',
|
|
164
|
+
alchemyTokenApi: false,
|
|
165
|
+
fallback: 'https://rpc.plasma.to',
|
|
166
|
+
explorer: 'plasmascan.to',
|
|
167
|
+
hypersync: 'https://plasma.hypersync.xyz',
|
|
168
|
+
llama: { coins: 'plasma', yields: 'Plasma' }
|
|
169
|
+
},
|
|
170
|
+
ink: {
|
|
171
|
+
chain: ink,
|
|
172
|
+
alchemy: 'ink-mainnet',
|
|
173
|
+
fallback: 'https://rpc-gel.inkonchain.com',
|
|
174
|
+
explorer: 'explorer.inkonchain.com',
|
|
175
|
+
hypersync: 'https://ink.hypersync.xyz',
|
|
176
|
+
llama: { coins: 'ink', yields: 'Ink' }
|
|
177
|
+
},
|
|
178
|
+
mantle: {
|
|
179
|
+
chain: mantle,
|
|
180
|
+
alchemy: 'mantle-mainnet',
|
|
181
|
+
alchemyTokenApi: false,
|
|
182
|
+
fallback: 'https://rpc.mantle.xyz',
|
|
183
|
+
explorer: 'mantlescan.xyz',
|
|
184
|
+
hypersync: 'https://mantle.hypersync.xyz',
|
|
185
|
+
llama: { coins: 'mantle', yields: 'Mantle' }
|
|
186
|
+
},
|
|
187
|
+
celo: {
|
|
188
|
+
chain: celo,
|
|
189
|
+
alchemy: 'celo-mainnet',
|
|
190
|
+
fallback: 'https://forno.celo.org',
|
|
191
|
+
explorer: 'celoscan.io',
|
|
192
|
+
hypersync: 'https://celo.hypersync.xyz',
|
|
193
|
+
llama: { coins: 'celo', yields: 'Celo' }
|
|
194
|
+
},
|
|
195
|
+
hyperevm: {
|
|
196
|
+
chain: hyperEvm,
|
|
197
|
+
alchemy: 'hyperliquid-mainnet',
|
|
198
|
+
fallback: 'https://rpc.hyperliquid.xyz/evm',
|
|
199
|
+
explorer: 'hyperevmscan.io',
|
|
200
|
+
hypersync: 'https://hyperliquid.hypersync.xyz',
|
|
201
|
+
llama: { coins: 'hyperliquid', yields: 'Hyperliquid L1' }
|
|
132
202
|
},
|
|
133
203
|
localhost: {
|
|
134
204
|
chain: localhost,
|
package/src/client.ts
CHANGED
|
@@ -4,14 +4,13 @@ import type { Config } from './types.js'
|
|
|
4
4
|
|
|
5
5
|
export { SUPPORTED_CHAINS } from './chains.js'
|
|
6
6
|
|
|
7
|
-
/** Custom URL first, then
|
|
7
|
+
/** Custom URL first, then Alchemy when a key is configured, then the public endpoint. */
|
|
8
8
|
function getRpcUrl(chainName: ChainName, config: Config): string {
|
|
9
9
|
const custom = config.customRpcUrls?.[chainName]
|
|
10
10
|
if (custom) return custom
|
|
11
11
|
|
|
12
|
-
const { alchemy,
|
|
12
|
+
const { alchemy, fallback } = CHAINS[chainName]
|
|
13
13
|
if (alchemy && config.alchemyApiKey) return `https://${alchemy}.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
14
|
-
if (infura && config.infuraApiKey) return `https://${infura}.infura.io/v3/${config.infuraApiKey}`
|
|
15
14
|
return fallback
|
|
16
15
|
}
|
|
17
16
|
|
package/src/http-server.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from '@modelconte
|
|
|
4
4
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
5
5
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
6
6
|
import express, { type Express } from 'express'
|
|
7
|
-
import type
|
|
7
|
+
import { SIGN_PATH, type WalletRelay } from 'web3-wallet-relay'
|
|
8
8
|
import { HOSTED } from './hosted.js'
|
|
9
9
|
import { SingleUserOAuthProvider } from './oauth.js'
|
|
10
10
|
|
|
@@ -136,11 +136,11 @@ export async function startHttpServer(options: HttpServerOptions): Promise<{ url
|
|
|
136
136
|
// The signing page rides on this same server, so a hosted deployment can offer browser
|
|
137
137
|
// signing without a second service: one platform port serves both /mcp and the page.
|
|
138
138
|
// Mounted last, so the routes above keep their paths — the relay only claims what is
|
|
139
|
-
// left, which is the static page at
|
|
139
|
+
// left, which is the static page at /sign/.
|
|
140
140
|
let walletUrl: string | undefined
|
|
141
141
|
if (options.walletRelay) {
|
|
142
142
|
options.walletRelay.attach(server, (relayApp) => app.use(relayApp))
|
|
143
|
-
walletUrl = `${publicUrl.replace(/\/$/, '')}
|
|
143
|
+
walletUrl = `${publicUrl.replace(/\/$/, '')}${SIGN_PATH}#t=${options.walletRelay.signerToken}`
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
return { url: `${publicUrl.replace(/\/$/, '')}/mcp`, port: (server.address() as { port: number }).port, walletUrl }
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,6 @@ OPTIONS:
|
|
|
27
27
|
--help, -h Show this help message
|
|
28
28
|
--etherscan-api-key <key> Etherscan API key (for contract ABI retrieval)
|
|
29
29
|
--alchemy-api-key <key> Alchemy API key (for enhanced RPC)
|
|
30
|
-
--infura-api-key <key> Infura API key (for alternative RPC)
|
|
31
30
|
--hypersync-api-key <key> Hypersync API key (for fast event queries)
|
|
32
31
|
--walletconnect-project-id <id>
|
|
33
32
|
WalletConnect project id, to sign from a phone
|
|
@@ -37,7 +36,6 @@ OPTIONS:
|
|
|
37
36
|
ENVIRONMENT VARIABLES:
|
|
38
37
|
ETHERSCAN_API_KEY Alternative to --etherscan-api-key
|
|
39
38
|
ALCHEMY_API_KEY Alternative to --alchemy-api-key
|
|
40
|
-
INFURA_API_KEY Alternative to --infura-api-key
|
|
41
39
|
HYPERSYNC_API_KEY Alternative to --hypersync-api-key
|
|
42
40
|
WALLETCONNECT_PROJECT_ID Alternative to --walletconnect-project-id
|
|
43
41
|
CUSTOM_RPC Alternative to --custom-rpc
|
|
@@ -84,9 +82,6 @@ if (config.etherscanApiKey) {
|
|
|
84
82
|
if (config.alchemyApiKey) {
|
|
85
83
|
console.error('[MCP] Alchemy API key configured')
|
|
86
84
|
}
|
|
87
|
-
if (config.infuraApiKey) {
|
|
88
|
-
console.error('[MCP] Infura API key configured')
|
|
89
|
-
}
|
|
90
85
|
if (config.walletConnectProjectId) {
|
|
91
86
|
console.error('[MCP] WalletConnect project id configured — phone signing available')
|
|
92
87
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import advancedTools from './read/advanced.js'
|
|
|
9
9
|
import balanceTools from './read/balance.js'
|
|
10
10
|
import contractTools from './read/contract.js'
|
|
11
11
|
import contractInfoTools from './read/contract-info.js'
|
|
12
|
+
import defiTools from './read/defi.js'
|
|
12
13
|
import ensTools from './read/ens.js'
|
|
13
14
|
import gasTools from './read/gas.js'
|
|
14
15
|
import logTools from './read/logs.js'
|
|
@@ -36,6 +37,7 @@ const readTools = {
|
|
|
36
37
|
...advancedTools,
|
|
37
38
|
...ensTools,
|
|
38
39
|
...gasTools,
|
|
40
|
+
...defiTools,
|
|
39
41
|
// A signature already asked for and paid for; collecting it is not a second request.
|
|
40
42
|
...signingRequestTools
|
|
41
43
|
} as const
|
|
@@ -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
|
+
}
|
package/src/types.ts
CHANGED
package/src/utils.ts
CHANGED
|
@@ -211,7 +211,7 @@ export function rpcReason(error: unknown): string {
|
|
|
211
211
|
|
|
212
212
|
/** Every configured key blanked out of `text`: RPC URLs carry them, and errors quote the URL. */
|
|
213
213
|
export function redactSecrets(text: string, config: Config): string {
|
|
214
|
-
return [config.alchemyApiKey, config.
|
|
214
|
+
return [config.alchemyApiKey, config.etherscanApiKey, config.hypersyncApiKey].reduce<string>(
|
|
215
215
|
(out, secret) => (secret ? out.replaceAll(secret, '[redacted]') : out),
|
|
216
216
|
text
|
|
217
217
|
)
|
|
@@ -277,7 +277,6 @@ export function parseCommandLineArgs(): Config & { showHelp?: boolean } {
|
|
|
277
277
|
// Parse environment variables and command line arguments
|
|
278
278
|
config.etherscanApiKey = process.env.ETHERSCAN_API_KEY || getArgValue('--etherscan-api-key')
|
|
279
279
|
config.alchemyApiKey = process.env.ALCHEMY_API_KEY || getArgValue('--alchemy-api-key')
|
|
280
|
-
config.infuraApiKey = process.env.INFURA_API_KEY || getArgValue('--infura-api-key')
|
|
281
280
|
config.hypersyncApiKey = process.env.HYPERSYNC_API_KEY || getArgValue('--hypersync-api-key')
|
|
282
281
|
config.walletConnectProjectId = process.env.WALLETCONNECT_PROJECT_ID || getArgValue('--walletconnect-project-id')
|
|
283
282
|
|
package/src/wallet-client.ts
CHANGED
|
@@ -3,7 +3,14 @@ import { randomBytes } from 'node:crypto'
|
|
|
3
3
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
LOCAL_PORT_ATTEMPTS,
|
|
8
|
+
roomToken,
|
|
9
|
+
SIGN_PATH,
|
|
10
|
+
type TransactionRequest,
|
|
11
|
+
type TransactionResponse,
|
|
12
|
+
WalletRelay
|
|
13
|
+
} from 'web3-wallet-relay'
|
|
7
14
|
import { WebSocket } from 'ws'
|
|
8
15
|
import { HOSTED } from './hosted.js'
|
|
9
16
|
|
|
@@ -26,7 +33,7 @@ const IDLE_TIMEOUT = 10 * 60_000
|
|
|
26
33
|
/** Always the signer token: this URL goes to a browser, which may not act as a requester. */
|
|
27
34
|
function pairingUrlFor(port: number, secret: string, identity: string): string {
|
|
28
35
|
const base = HOSTED ? (process.env.MCP_PUBLIC_URL ?? `http://localhost:${port}`) : `http://127.0.0.1:${port}`
|
|
29
|
-
return `${base.replace(/\/$/, '')}
|
|
36
|
+
return `${base.replace(/\/$/, '')}${SIGN_PATH}#t=${roomToken(secret, identity, 'signer')}`
|
|
30
37
|
}
|
|
31
38
|
const SIGNER_WAIT_TIMEOUT = 30_000
|
|
32
39
|
const CONNECT_ATTEMPTS = 8
|