intent-swap-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +244 -0
- package/dist/intent-parser.d.ts +25 -0
- package/dist/intent-parser.js +157 -0
- package/dist/price-feed.d.ts +9 -0
- package/dist/price-feed.js +64 -0
- package/dist/uniswap.d.ts +44 -0
- package/dist/uniswap.js +196 -0
- package/package.json +34 -0
- package/src/index.ts +269 -0
- package/src/intent-parser.ts +194 -0
- package/src/price-feed.ts +72 -0
- package/src/uniswap.ts +270 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Price feed — CoinGecko free API, no key required.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const COINGECKO_IDS: Record<string, string> = {
|
|
6
|
+
ETH: "ethereum",
|
|
7
|
+
WETH: "weth",
|
|
8
|
+
WBTC: "wrapped-bitcoin",
|
|
9
|
+
BTC: "bitcoin",
|
|
10
|
+
USDC: "usd-coin",
|
|
11
|
+
USDT: "tether",
|
|
12
|
+
DAI: "dai",
|
|
13
|
+
ARB: "arbitrum",
|
|
14
|
+
UNI: "uniswap",
|
|
15
|
+
LINK: "chainlink",
|
|
16
|
+
MATIC: "matic-network",
|
|
17
|
+
OP: "optimism",
|
|
18
|
+
PEPE: "pepe",
|
|
19
|
+
SHIB: "shiba-inu",
|
|
20
|
+
AAVE: "aave",
|
|
21
|
+
CRV: "curve-dao-token",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Simple in-memory cache (60s TTL)
|
|
25
|
+
const priceCache: Map<string, { price: number; ts: number }> = new Map();
|
|
26
|
+
const CACHE_TTL = 60_000;
|
|
27
|
+
|
|
28
|
+
export async function getTokenPrice(symbol: string): Promise<number | null> {
|
|
29
|
+
const id = COINGECKO_IDS[symbol.toUpperCase()];
|
|
30
|
+
if (!id) return null;
|
|
31
|
+
|
|
32
|
+
const cached = priceCache.get(symbol);
|
|
33
|
+
if (cached && Date.now() - cached.ts < CACHE_TTL) return cached.price;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(
|
|
37
|
+
`https://api.coingecko.com/api/v3/simple/price?ids=${id}&vs_currencies=usd`,
|
|
38
|
+
{ signal: AbortSignal.timeout(5000) }
|
|
39
|
+
);
|
|
40
|
+
const data = (await res.json()) as Record<string, { usd: number }>;
|
|
41
|
+
const price = data[id]?.usd ?? null;
|
|
42
|
+
if (price !== null) priceCache.set(symbol, { price, ts: Date.now() });
|
|
43
|
+
return price;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function getSupportedTokens() {
|
|
50
|
+
const symbols = Object.keys(COINGECKO_IDS);
|
|
51
|
+
const ids = Object.values(COINGECKO_IDS).join(",");
|
|
52
|
+
|
|
53
|
+
let prices: Record<string, number> = {};
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(
|
|
56
|
+
`https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=usd`,
|
|
57
|
+
{ signal: AbortSignal.timeout(5000) }
|
|
58
|
+
);
|
|
59
|
+
const data = (await res.json()) as Record<string, { usd: number }>;
|
|
60
|
+
for (const [sym, id] of Object.entries(COINGECKO_IDS)) {
|
|
61
|
+
if (data[id]?.usd) prices[sym] = data[id].usd;
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// return without prices
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return symbols.map((sym) => ({
|
|
68
|
+
symbol: sym,
|
|
69
|
+
coingeckoId: COINGECKO_IDS[sym],
|
|
70
|
+
priceUsd: prices[sym] ?? null,
|
|
71
|
+
}));
|
|
72
|
+
}
|
package/src/uniswap.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uniswap V3 quote + tx builder.
|
|
3
|
+
* Uses public RPC — no API key required.
|
|
4
|
+
*
|
|
5
|
+
* Quote: calls QuoterV2 on-chain
|
|
6
|
+
* Build: generates SwapRouter02 calldata via viem
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createPublicClient, http, parseUnits, formatUnits, encodeFunctionData, type Address } from "viem";
|
|
10
|
+
import { mainnet, arbitrum } from "viem/chains";
|
|
11
|
+
|
|
12
|
+
// ─── Chain config ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const CHAINS = {
|
|
15
|
+
mainnet: {
|
|
16
|
+
chain: mainnet,
|
|
17
|
+
rpc: "https://ethereum.publicnode.com",
|
|
18
|
+
quoterV2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e" as Address,
|
|
19
|
+
swapRouter: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45" as Address,
|
|
20
|
+
},
|
|
21
|
+
arbitrum: {
|
|
22
|
+
chain: arbitrum,
|
|
23
|
+
rpc: "https://arbitrum-one.publicnode.com",
|
|
24
|
+
quoterV2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e" as Address,
|
|
25
|
+
swapRouter: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45" as Address,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ─── Token registry ────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
interface TokenInfo {
|
|
32
|
+
address: Address;
|
|
33
|
+
decimals: number;
|
|
34
|
+
isNative?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TOKENS_MAINNET: Record<string, TokenInfo> = {
|
|
38
|
+
ETH: { address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", decimals: 18, isNative: true },
|
|
39
|
+
WETH: { address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", decimals: 18 },
|
|
40
|
+
USDC: { address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", decimals: 6 },
|
|
41
|
+
USDT: { address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", decimals: 6 },
|
|
42
|
+
DAI: { address: "0x6B175474E89094C44Da98b954EedeAC495271d0F", decimals: 18 },
|
|
43
|
+
WBTC: { address: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", decimals: 8 },
|
|
44
|
+
UNI: { address: "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", decimals: 18 },
|
|
45
|
+
LINK: { address: "0x514910771AF9Ca656af840dff83E8264EcF986CA", decimals: 18 },
|
|
46
|
+
ARB: { address: "0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1", decimals: 18 },
|
|
47
|
+
AAVE: { address: "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9", decimals: 18 },
|
|
48
|
+
CRV: { address: "0xD533a949740bb3306d119CC777fa900bA034cd52", decimals: 18 },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ─── ABIs (minimal) ────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const QUOTER_V2_ABI = [
|
|
54
|
+
{
|
|
55
|
+
name: "quoteExactInputSingle",
|
|
56
|
+
type: "function",
|
|
57
|
+
stateMutability: "nonpayable",
|
|
58
|
+
inputs: [
|
|
59
|
+
{
|
|
60
|
+
name: "params",
|
|
61
|
+
type: "tuple",
|
|
62
|
+
components: [
|
|
63
|
+
{ name: "tokenIn", type: "address" },
|
|
64
|
+
{ name: "tokenOut", type: "address" },
|
|
65
|
+
{ name: "amountIn", type: "uint256" },
|
|
66
|
+
{ name: "fee", type: "uint24" },
|
|
67
|
+
{ name: "sqrtPriceLimitX96", type: "uint160" },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
outputs: [
|
|
72
|
+
{ name: "amountOut", type: "uint256" },
|
|
73
|
+
{ name: "sqrtPriceX96After", type: "uint160" },
|
|
74
|
+
{ name: "initializedTicksCrossed", type: "uint32" },
|
|
75
|
+
{ name: "gasEstimate", type: "uint256" },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
] as const;
|
|
79
|
+
|
|
80
|
+
const SWAP_ROUTER_ABI = [
|
|
81
|
+
{
|
|
82
|
+
name: "exactInputSingle",
|
|
83
|
+
type: "function",
|
|
84
|
+
stateMutability: "payable",
|
|
85
|
+
inputs: [
|
|
86
|
+
{
|
|
87
|
+
name: "params",
|
|
88
|
+
type: "tuple",
|
|
89
|
+
components: [
|
|
90
|
+
{ name: "tokenIn", type: "address" },
|
|
91
|
+
{ name: "tokenOut", type: "address" },
|
|
92
|
+
{ name: "fee", type: "uint24" },
|
|
93
|
+
{ name: "recipient", type: "address" },
|
|
94
|
+
{ name: "amountIn", type: "uint256" },
|
|
95
|
+
{ name: "amountOutMinimum", type: "uint256" },
|
|
96
|
+
{ name: "sqrtPriceLimitX96", type: "uint160" },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
outputs: [{ name: "amountOut", type: "uint256" }],
|
|
101
|
+
},
|
|
102
|
+
] as const;
|
|
103
|
+
|
|
104
|
+
// ─── Fee tiers to try ──────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
const FEE_TIERS = [500, 3000, 10000] as const; // 0.05%, 0.3%, 1%
|
|
107
|
+
|
|
108
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function getTokenInfo(symbol: string): TokenInfo {
|
|
111
|
+
const info = TOKENS_MAINNET[symbol.toUpperCase()];
|
|
112
|
+
if (!info) throw new Error(`Unsupported token: ${symbol}`);
|
|
113
|
+
return info;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function slippageBpsToMultiplier(bps: number): bigint {
|
|
117
|
+
// amountOutMinimum = amountOut * (10000 - bps) / 10000
|
|
118
|
+
return BigInt(10000 - bps);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Quote ─────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
export interface SwapQuote {
|
|
124
|
+
fromToken: string;
|
|
125
|
+
toToken: string;
|
|
126
|
+
amountIn: number;
|
|
127
|
+
amountOut: number;
|
|
128
|
+
feeTier: number;
|
|
129
|
+
priceImpactPct: number | null;
|
|
130
|
+
gasEstimate: number;
|
|
131
|
+
rateFormatted: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function getSwapQuote(params: {
|
|
135
|
+
fromToken: string;
|
|
136
|
+
toToken: string;
|
|
137
|
+
amount: number;
|
|
138
|
+
slippageBps?: number;
|
|
139
|
+
}): Promise<SwapQuote> {
|
|
140
|
+
const { fromToken, toToken, amount } = params;
|
|
141
|
+
const tokenIn = getTokenInfo(fromToken);
|
|
142
|
+
const tokenOut = getTokenInfo(toToken);
|
|
143
|
+
|
|
144
|
+
const client = createPublicClient({
|
|
145
|
+
chain: CHAINS.mainnet.chain,
|
|
146
|
+
transport: http(CHAINS.mainnet.rpc),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const amountIn = parseUnits(amount.toString(), tokenIn.decimals);
|
|
150
|
+
|
|
151
|
+
// Try fee tiers, pick best output
|
|
152
|
+
let bestOut: bigint | null = null;
|
|
153
|
+
let bestFee = 3000;
|
|
154
|
+
let bestGas = 0;
|
|
155
|
+
|
|
156
|
+
for (const fee of FEE_TIERS) {
|
|
157
|
+
try {
|
|
158
|
+
const result = await client.readContract({
|
|
159
|
+
address: CHAINS.mainnet.quoterV2,
|
|
160
|
+
abi: QUOTER_V2_ABI,
|
|
161
|
+
functionName: "quoteExactInputSingle",
|
|
162
|
+
args: [
|
|
163
|
+
{
|
|
164
|
+
tokenIn: tokenIn.address,
|
|
165
|
+
tokenOut: tokenOut.address,
|
|
166
|
+
amountIn,
|
|
167
|
+
fee,
|
|
168
|
+
sqrtPriceLimitX96: 0n,
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
const [amountOut, , , gasEstimate] = result as [bigint, bigint, number, bigint];
|
|
173
|
+
if (bestOut === null || amountOut > bestOut) {
|
|
174
|
+
bestOut = amountOut;
|
|
175
|
+
bestFee = fee;
|
|
176
|
+
bestGas = Number(gasEstimate);
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// fee tier not available, try next
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (bestOut === null) {
|
|
184
|
+
throw new Error(`No liquidity found for ${fromToken}→${toToken}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const amountOut = Number(formatUnits(bestOut, tokenOut.decimals));
|
|
188
|
+
const rate = amountOut / amount;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
fromToken: fromToken.toUpperCase(),
|
|
192
|
+
toToken: toToken.toUpperCase(),
|
|
193
|
+
amountIn: amount,
|
|
194
|
+
amountOut,
|
|
195
|
+
feeTier: bestFee,
|
|
196
|
+
priceImpactPct: null, // would need pool state to calculate precisely
|
|
197
|
+
gasEstimate: bestGas,
|
|
198
|
+
rateFormatted: `1 ${fromToken.toUpperCase()} = ${rate.toFixed(6)} ${toToken.toUpperCase()}`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Build TX ──────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
export interface SwapTx {
|
|
205
|
+
to: Address;
|
|
206
|
+
data: string;
|
|
207
|
+
value: string; // hex, ETH value in wei
|
|
208
|
+
fromToken: string;
|
|
209
|
+
toToken: string;
|
|
210
|
+
amountIn: number;
|
|
211
|
+
estimatedAmountOut: number;
|
|
212
|
+
feeTier: number;
|
|
213
|
+
slippageBps: number;
|
|
214
|
+
summary: string;
|
|
215
|
+
warning: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function buildSwapTx(params: {
|
|
219
|
+
fromToken: string;
|
|
220
|
+
toToken: string;
|
|
221
|
+
amount: number;
|
|
222
|
+
recipientAddress: string;
|
|
223
|
+
slippageBps?: number;
|
|
224
|
+
}): Promise<SwapTx> {
|
|
225
|
+
const { fromToken, toToken, amount, recipientAddress, slippageBps = 50 } = params;
|
|
226
|
+
|
|
227
|
+
// Get quote first
|
|
228
|
+
const quote = await getSwapQuote({ fromToken, toToken, amount, slippageBps });
|
|
229
|
+
|
|
230
|
+
const tokenIn = getTokenInfo(fromToken);
|
|
231
|
+
const tokenOut = getTokenInfo(toToken);
|
|
232
|
+
|
|
233
|
+
const amountIn = parseUnits(amount.toString(), tokenIn.decimals);
|
|
234
|
+
const amountOutRaw = parseUnits(quote.amountOut.toString(), tokenOut.decimals);
|
|
235
|
+
const amountOutMinimum = (amountOutRaw * slippageBpsToMultiplier(slippageBps)) / 10000n;
|
|
236
|
+
|
|
237
|
+
const calldata = encodeFunctionData({
|
|
238
|
+
abi: SWAP_ROUTER_ABI,
|
|
239
|
+
functionName: "exactInputSingle",
|
|
240
|
+
args: [
|
|
241
|
+
{
|
|
242
|
+
tokenIn: tokenIn.address,
|
|
243
|
+
tokenOut: tokenOut.address,
|
|
244
|
+
fee: quote.feeTier,
|
|
245
|
+
recipient: recipientAddress as Address,
|
|
246
|
+
amountIn,
|
|
247
|
+
amountOutMinimum,
|
|
248
|
+
sqrtPriceLimitX96: 0n,
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const isNativeIn = tokenIn.isNative === true;
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
to: CHAINS.mainnet.swapRouter,
|
|
257
|
+
data: calldata,
|
|
258
|
+
value: isNativeIn ? `0x${amountIn.toString(16)}` : "0x0",
|
|
259
|
+
fromToken: fromToken.toUpperCase(),
|
|
260
|
+
toToken: toToken.toUpperCase(),
|
|
261
|
+
amountIn: amount,
|
|
262
|
+
estimatedAmountOut: quote.amountOut,
|
|
263
|
+
feeTier: quote.feeTier,
|
|
264
|
+
slippageBps,
|
|
265
|
+
summary: `Swap ${amount} ${fromToken.toUpperCase()} → ~${quote.amountOut.toFixed(4)} ${toToken.toUpperCase()} (slippage: ${slippageBps / 100}%)`,
|
|
266
|
+
warning:
|
|
267
|
+
"This transaction must be signed and broadcast by the user's wallet. " +
|
|
268
|
+
"If fromToken is an ERC-20 (not ETH), the user must first approve the SwapRouter to spend their tokens.",
|
|
269
|
+
};
|
|
270
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"],
|
|
14
|
+
"exclude": ["node_modules", "dist"]
|
|
15
|
+
}
|