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
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# intent-swap MCP Server
|
|
2
|
+
|
|
3
|
+
An MCP (Model Context Protocol) server that lets AI agents execute DeFi swaps via natural language.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
Connect this to Claude Desktop (or any MCP-compatible agent) and say:
|
|
8
|
+
|
|
9
|
+
> "Swap 1 ETH for USDC"
|
|
10
|
+
> "把0.5个以太换成USDT"
|
|
11
|
+
> "Get me a quote for swapping 100 USDC to DAI"
|
|
12
|
+
|
|
13
|
+
The agent will parse your intent, fetch a real-time Uniswap V3 quote, and build the transaction for your wallet to sign.
|
|
14
|
+
|
|
15
|
+
## Tools
|
|
16
|
+
|
|
17
|
+
| Tool | Description |
|
|
18
|
+
|------|-------------|
|
|
19
|
+
| `parse_swap_intent` | Natural language → structured swap params |
|
|
20
|
+
| `get_token_price` | Real-time USD price via CoinGecko |
|
|
21
|
+
| `get_swap_quote` | Live Uniswap V3 quote (best fee tier auto-selected) |
|
|
22
|
+
| `build_swap_tx` | Generate calldata for wallet signing |
|
|
23
|
+
| `list_supported_tokens` | All supported tokens with prices |
|
|
24
|
+
|
|
25
|
+
## Supported Tokens
|
|
26
|
+
|
|
27
|
+
ETH, WETH, WBTC, USDC, USDT, DAI, ARB, UNI, LINK, MATIC, OP, PEPE, SHIB, AAVE, CRV
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
### Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install -g intent-swap-mcp
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Claude Desktop config
|
|
38
|
+
|
|
39
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"mcpServers": {
|
|
44
|
+
"intent-swap": {
|
|
45
|
+
"command": "intent-swap-mcp",
|
|
46
|
+
"env": {
|
|
47
|
+
"OPENAI_API_KEY": "sk-..."
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`OPENAI_API_KEY` is optional — without it, the server uses rule-based parsing (works for most common intents).
|
|
55
|
+
|
|
56
|
+
### Run locally (dev)
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/Lovelle-Zhang/intent-swap
|
|
60
|
+
cd projects/intent-swap-mcp
|
|
61
|
+
npm install
|
|
62
|
+
npm run dev
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Architecture
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
User (natural language)
|
|
69
|
+
↓
|
|
70
|
+
parse_swap_intent → structured { fromToken, toToken, amount, ... }
|
|
71
|
+
↓
|
|
72
|
+
get_swap_quote → real-time Uniswap V3 quote (QuoterV2 on-chain)
|
|
73
|
+
↓
|
|
74
|
+
build_swap_tx → calldata for SwapRouter02
|
|
75
|
+
↓
|
|
76
|
+
User wallet signs & broadcasts
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
No custody. No API keys for on-chain operations. The server never holds private keys.
|
|
80
|
+
|
|
81
|
+
## Security
|
|
82
|
+
|
|
83
|
+
- The server **never** requests or stores private keys
|
|
84
|
+
- `build_swap_tx` only generates calldata — the user must sign in their own wallet
|
|
85
|
+
- ERC-20 swaps require a separate `approve` transaction (noted in the response)
|
|
86
|
+
|
|
87
|
+
## Roadmap
|
|
88
|
+
|
|
89
|
+
- [ ] ERC-4337 (Account Abstraction) support for smart contract wallets
|
|
90
|
+
- [ ] Multi-hop routing
|
|
91
|
+
- [ ] Conditional orders ("buy ETH when it drops below $3000")
|
|
92
|
+
- [ ] Arbitrum / Base / Optimism support
|
|
93
|
+
- [ ] Publish to npm
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* intent-swap MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Exposes DeFi swap capabilities as MCP tools so AI agents (Claude, GPT, etc.)
|
|
6
|
+
* can execute on-chain swaps via natural language.
|
|
7
|
+
*
|
|
8
|
+
* Tools:
|
|
9
|
+
* - parse_swap_intent : NL → structured swap params
|
|
10
|
+
* - get_token_price : real-time price via CoinGecko
|
|
11
|
+
* - get_swap_quote : Uniswap V3 on-chain quote
|
|
12
|
+
* - build_swap_tx : build calldata for wallet signing
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* intent-swap MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Exposes DeFi swap capabilities as MCP tools so AI agents (Claude, GPT, etc.)
|
|
6
|
+
* can execute on-chain swaps via natural language.
|
|
7
|
+
*
|
|
8
|
+
* Tools:
|
|
9
|
+
* - parse_swap_intent : NL → structured swap params
|
|
10
|
+
* - get_token_price : real-time price via CoinGecko
|
|
11
|
+
* - get_swap_quote : Uniswap V3 on-chain quote
|
|
12
|
+
* - build_swap_tx : build calldata for wallet signing
|
|
13
|
+
*/
|
|
14
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
15
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { parseIntent } from "./intent-parser.js";
|
|
19
|
+
import { getTokenPrice, getSupportedTokens } from "./price-feed.js";
|
|
20
|
+
import { getSwapQuote, buildSwapTx } from "./uniswap.js";
|
|
21
|
+
// ─── Server setup ──────────────────────────────────────────────────────────
|
|
22
|
+
const server = new Server({
|
|
23
|
+
name: "intent-swap",
|
|
24
|
+
version: "0.1.0",
|
|
25
|
+
}, {
|
|
26
|
+
capabilities: {
|
|
27
|
+
tools: {},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
// ─── Tool definitions ──────────────────────────────────────────────────────
|
|
31
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
32
|
+
tools: [
|
|
33
|
+
{
|
|
34
|
+
name: "parse_swap_intent",
|
|
35
|
+
description: "Parse a natural language swap request into structured parameters. " +
|
|
36
|
+
"Supports English and Chinese. Examples: 'swap 1 ETH for USDC', " +
|
|
37
|
+
"'把0.5个以太换成USDT', 'buy WBTC with all my ETH'.",
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
intent: {
|
|
42
|
+
type: "string",
|
|
43
|
+
description: "Natural language swap request from the user",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
required: ["intent"],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "get_token_price",
|
|
51
|
+
description: "Get the current USD price of a token. Supported: ETH, WETH, WBTC, BTC, USDC, USDT, DAI, ARB, UNI, LINK, MATIC, OP.",
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
token: {
|
|
56
|
+
type: "string",
|
|
57
|
+
description: "Token symbol (e.g. ETH, USDC, WBTC)",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
required: ["token"],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "get_swap_quote",
|
|
65
|
+
description: "Get a real-time swap quote from Uniswap V3. Returns expected output amount, price impact, and gas estimate.",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: {
|
|
69
|
+
fromToken: {
|
|
70
|
+
type: "string",
|
|
71
|
+
description: "Source token symbol (e.g. ETH)",
|
|
72
|
+
},
|
|
73
|
+
toToken: {
|
|
74
|
+
type: "string",
|
|
75
|
+
description: "Destination token symbol (e.g. USDC)",
|
|
76
|
+
},
|
|
77
|
+
amount: {
|
|
78
|
+
type: "number",
|
|
79
|
+
description: "Amount of fromToken to swap",
|
|
80
|
+
},
|
|
81
|
+
slippageBps: {
|
|
82
|
+
type: "number",
|
|
83
|
+
description: "Slippage tolerance in basis points (default: 50 = 0.5%)",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
required: ["fromToken", "toToken", "amount"],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "build_swap_tx",
|
|
91
|
+
description: "Build the transaction calldata for a swap. The user must sign and broadcast this transaction themselves via their wallet. " +
|
|
92
|
+
"Returns: to (contract address), data (calldata), value (ETH to send), and a human-readable summary.",
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: "object",
|
|
95
|
+
properties: {
|
|
96
|
+
fromToken: {
|
|
97
|
+
type: "string",
|
|
98
|
+
description: "Source token symbol",
|
|
99
|
+
},
|
|
100
|
+
toToken: {
|
|
101
|
+
type: "string",
|
|
102
|
+
description: "Destination token symbol",
|
|
103
|
+
},
|
|
104
|
+
amount: {
|
|
105
|
+
type: "number",
|
|
106
|
+
description: "Amount of fromToken to swap",
|
|
107
|
+
},
|
|
108
|
+
recipientAddress: {
|
|
109
|
+
type: "string",
|
|
110
|
+
description: "Wallet address to receive the output tokens (0x...)",
|
|
111
|
+
},
|
|
112
|
+
slippageBps: {
|
|
113
|
+
type: "number",
|
|
114
|
+
description: "Slippage tolerance in basis points (default: 50)",
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
required: ["fromToken", "toToken", "amount", "recipientAddress"],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "list_supported_tokens",
|
|
122
|
+
description: "List all supported tokens with their addresses and current prices.",
|
|
123
|
+
inputSchema: {
|
|
124
|
+
type: "object",
|
|
125
|
+
properties: {},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
}));
|
|
130
|
+
// ─── Tool handlers ─────────────────────────────────────────────────────────
|
|
131
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
132
|
+
const { name, arguments: args } = request.params;
|
|
133
|
+
try {
|
|
134
|
+
switch (name) {
|
|
135
|
+
case "parse_swap_intent": {
|
|
136
|
+
const { intent } = z.object({ intent: z.string() }).parse(args);
|
|
137
|
+
const result = await parseIntent(intent);
|
|
138
|
+
return {
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: "text",
|
|
142
|
+
text: JSON.stringify(result, null, 2),
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
case "get_token_price": {
|
|
148
|
+
const { token } = z.object({ token: z.string() }).parse(args);
|
|
149
|
+
const price = await getTokenPrice(token.toUpperCase());
|
|
150
|
+
if (price === null) {
|
|
151
|
+
return {
|
|
152
|
+
content: [
|
|
153
|
+
{
|
|
154
|
+
type: "text",
|
|
155
|
+
text: `Token ${token} is not supported or price unavailable.`,
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
isError: true,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
content: [
|
|
163
|
+
{
|
|
164
|
+
type: "text",
|
|
165
|
+
text: JSON.stringify({ token: token.toUpperCase(), priceUsd: price }, null, 2),
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
case "get_swap_quote": {
|
|
171
|
+
const params = z
|
|
172
|
+
.object({
|
|
173
|
+
fromToken: z.string(),
|
|
174
|
+
toToken: z.string(),
|
|
175
|
+
amount: z.number().positive(),
|
|
176
|
+
slippageBps: z.number().optional().default(50),
|
|
177
|
+
})
|
|
178
|
+
.parse(args);
|
|
179
|
+
const quote = await getSwapQuote(params);
|
|
180
|
+
return {
|
|
181
|
+
content: [
|
|
182
|
+
{
|
|
183
|
+
type: "text",
|
|
184
|
+
text: JSON.stringify(quote, null, 2),
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
case "build_swap_tx": {
|
|
190
|
+
const params = z
|
|
191
|
+
.object({
|
|
192
|
+
fromToken: z.string(),
|
|
193
|
+
toToken: z.string(),
|
|
194
|
+
amount: z.number().positive(),
|
|
195
|
+
recipientAddress: z.string().regex(/^0x[0-9a-fA-F]{40}$/),
|
|
196
|
+
slippageBps: z.number().optional().default(50),
|
|
197
|
+
})
|
|
198
|
+
.parse(args);
|
|
199
|
+
const tx = await buildSwapTx(params);
|
|
200
|
+
return {
|
|
201
|
+
content: [
|
|
202
|
+
{
|
|
203
|
+
type: "text",
|
|
204
|
+
text: JSON.stringify(tx, null, 2),
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
case "list_supported_tokens": {
|
|
210
|
+
const tokens = await getSupportedTokens();
|
|
211
|
+
return {
|
|
212
|
+
content: [
|
|
213
|
+
{
|
|
214
|
+
type: "text",
|
|
215
|
+
text: JSON.stringify(tokens, null, 2),
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
default:
|
|
221
|
+
return {
|
|
222
|
+
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
223
|
+
isError: true,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
229
|
+
return {
|
|
230
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
231
|
+
isError: true,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
// ─── Start ─────────────────────────────────────────────────────────────────
|
|
236
|
+
async function main() {
|
|
237
|
+
const transport = new StdioServerTransport();
|
|
238
|
+
await server.connect(transport);
|
|
239
|
+
console.error("intent-swap MCP server running on stdio");
|
|
240
|
+
}
|
|
241
|
+
main().catch((err) => {
|
|
242
|
+
console.error("Fatal:", err);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent parser — rule-based with optional OpenAI fallback.
|
|
3
|
+
* Kept lightweight so the MCP server works without an API key.
|
|
4
|
+
*/
|
|
5
|
+
export interface ParsedIntent {
|
|
6
|
+
intentType: "swap" | "conditional";
|
|
7
|
+
fromToken: string;
|
|
8
|
+
toToken: string;
|
|
9
|
+
amount: number | null;
|
|
10
|
+
amountType: "exact" | "percentage" | "max" | null;
|
|
11
|
+
slippagePref: "low" | "normal" | "high";
|
|
12
|
+
condition: {
|
|
13
|
+
token: string;
|
|
14
|
+
operator: "above" | "below";
|
|
15
|
+
targetPrice: number;
|
|
16
|
+
} | null;
|
|
17
|
+
summary: string;
|
|
18
|
+
parsedBy: "rules" | "llm";
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Parse a natural language swap intent.
|
|
22
|
+
* Strategy: rule-based first (fast, no API call), LLM fallback for complex/ambiguous inputs.
|
|
23
|
+
* If OPENAI_API_KEY is not set, always uses rules.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseIntent(intent: string): Promise<ParsedIntent>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent parser — rule-based with optional OpenAI fallback.
|
|
3
|
+
* Kept lightweight so the MCP server works without an API key.
|
|
4
|
+
*/
|
|
5
|
+
// ─── Token registry ────────────────────────────────────────────────────────
|
|
6
|
+
const TOKENS = [
|
|
7
|
+
"ETH", "WETH", "WBTC", "BTC", "USDC", "USDT", "DAI", "ARB",
|
|
8
|
+
"UNI", "LINK", "MATIC", "OP", "PEPE", "SHIB", "AAVE", "CRV",
|
|
9
|
+
];
|
|
10
|
+
const TOKEN_ALIASES = {
|
|
11
|
+
"以太": "ETH", "以太坊": "ETH",
|
|
12
|
+
"比特": "WBTC", "比特币": "WBTC",
|
|
13
|
+
"稳定币": "USDC", "美元": "USDC",
|
|
14
|
+
"泰达": "USDT",
|
|
15
|
+
"以太坊包装": "WETH",
|
|
16
|
+
"polygon": "MATIC", "optimism": "OP",
|
|
17
|
+
};
|
|
18
|
+
function normalizeText(text) {
|
|
19
|
+
let result = text.toUpperCase();
|
|
20
|
+
for (const [alias, token] of Object.entries(TOKEN_ALIASES)) {
|
|
21
|
+
result = result.replace(new RegExp(alias, "gi"), token);
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
function extractToken(text, exclude) {
|
|
26
|
+
const normalized = normalizeText(text);
|
|
27
|
+
const exact = TOKENS.find((t) => t !== exclude && new RegExp(`\\b${t}\\b`).test(normalized));
|
|
28
|
+
if (exact)
|
|
29
|
+
return exact;
|
|
30
|
+
return TOKENS.find((t) => t !== exclude && normalized.includes(t)) ?? "USDC";
|
|
31
|
+
}
|
|
32
|
+
function extractAmount(text) {
|
|
33
|
+
if (/half|一半/i.test(text))
|
|
34
|
+
return { amount: 50, amountType: "percentage" };
|
|
35
|
+
if (/all|max|full|全部|所有|最大|everything/i.test(text))
|
|
36
|
+
return { amount: null, amountType: "max" };
|
|
37
|
+
const pct = text.match(/(\d+)\s*[%%]/);
|
|
38
|
+
if (pct)
|
|
39
|
+
return { amount: Number(pct[1]), amountType: "percentage" };
|
|
40
|
+
const num = text.match(/(\d+\.?\d*)/);
|
|
41
|
+
if (num)
|
|
42
|
+
return { amount: Number(num[1]), amountType: "exact" };
|
|
43
|
+
return { amount: null, amountType: null };
|
|
44
|
+
}
|
|
45
|
+
function extractSlippage(text) {
|
|
46
|
+
if (/low\s*slippage|tight|minimal|低滑点|精确/i.test(text))
|
|
47
|
+
return "low";
|
|
48
|
+
if (/high\s*slippage|fast|urgent|高滑点|快速|紧急/i.test(text))
|
|
49
|
+
return "high";
|
|
50
|
+
return "normal";
|
|
51
|
+
}
|
|
52
|
+
function extractCondition(text) {
|
|
53
|
+
// "when ETH drops below 3000" / "if ETH goes above 4000"
|
|
54
|
+
const match = text.match(/(?:when|if|once|当|如果)\s+(\w+)\s+(?:drops?|falls?|goes?|跌|低于|下跌|涨|高于|上涨)\s+(?:below|above|under|over|到|至)?\s*\$?(\d+(?:,\d{3})*(?:\.\d+)?)/i);
|
|
55
|
+
if (!match)
|
|
56
|
+
return null;
|
|
57
|
+
const token = match[1].toUpperCase();
|
|
58
|
+
const price = Number(match[2].replace(/,/g, ""));
|
|
59
|
+
const isBelow = /drops?|falls?|below|under|跌|低于|下跌/i.test(match[0]);
|
|
60
|
+
return {
|
|
61
|
+
token: TOKENS.includes(token) ? token : "ETH",
|
|
62
|
+
operator: isBelow ? "below" : "above",
|
|
63
|
+
targetPrice: price,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// ─── Rule-based parser ─────────────────────────────────────────────────────
|
|
67
|
+
function ruleParse(intent) {
|
|
68
|
+
const fromToken = extractToken(intent);
|
|
69
|
+
const toToken = extractToken(intent, fromToken);
|
|
70
|
+
const { amount, amountType } = extractAmount(intent);
|
|
71
|
+
const slippagePref = extractSlippage(intent);
|
|
72
|
+
const condition = extractCondition(intent);
|
|
73
|
+
const amountStr = amount === null
|
|
74
|
+
? amountType === "max"
|
|
75
|
+
? "all"
|
|
76
|
+
: "some"
|
|
77
|
+
: amountType === "percentage"
|
|
78
|
+
? `${amount}%`
|
|
79
|
+
: `${amount}`;
|
|
80
|
+
return {
|
|
81
|
+
intentType: condition ? "conditional" : "swap",
|
|
82
|
+
fromToken,
|
|
83
|
+
toToken,
|
|
84
|
+
amount,
|
|
85
|
+
amountType,
|
|
86
|
+
slippagePref,
|
|
87
|
+
condition,
|
|
88
|
+
summary: `Swap ${amountStr} ${fromToken} → ${toToken} with ${slippagePref} slippage`,
|
|
89
|
+
parsedBy: "rules",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// ─── LLM parser (requires OPENAI_API_KEY env var) ─────────────────────────
|
|
93
|
+
async function llmParse(intent) {
|
|
94
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
95
|
+
if (!apiKey)
|
|
96
|
+
throw new Error("OPENAI_API_KEY not set");
|
|
97
|
+
const OpenAI = (await import("openai")).default;
|
|
98
|
+
const client = new OpenAI({ apiKey });
|
|
99
|
+
const completion = await client.chat.completions.create({
|
|
100
|
+
model: "gpt-4o-mini",
|
|
101
|
+
temperature: 0,
|
|
102
|
+
response_format: { type: "json_object" },
|
|
103
|
+
messages: [
|
|
104
|
+
{
|
|
105
|
+
role: "system",
|
|
106
|
+
content: `You are a DeFi swap intent parser. Extract structured swap info from natural language.
|
|
107
|
+
Supported tokens: ${TOKENS.join(", ")}
|
|
108
|
+
Return ONLY valid JSON matching this schema:
|
|
109
|
+
{
|
|
110
|
+
"intentType": "swap" | "conditional",
|
|
111
|
+
"fromToken": string,
|
|
112
|
+
"toToken": string,
|
|
113
|
+
"amount": number | null,
|
|
114
|
+
"amountType": "exact" | "percentage" | "max" | null,
|
|
115
|
+
"slippagePref": "low" | "normal" | "high",
|
|
116
|
+
"condition": null | { "token": string, "operator": "above"|"below", "targetPrice": number },
|
|
117
|
+
"summary": string
|
|
118
|
+
}
|
|
119
|
+
Rules:
|
|
120
|
+
- amountType="max" means all tokens, set amount=null
|
|
121
|
+
- Write summary in the same language as the input
|
|
122
|
+
- If tokens are ambiguous, default fromToken="ETH", toToken="USDC"`,
|
|
123
|
+
},
|
|
124
|
+
{ role: "user", content: intent },
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
const raw = completion.choices[0]?.message?.content;
|
|
128
|
+
if (!raw)
|
|
129
|
+
throw new Error("Empty LLM response");
|
|
130
|
+
const parsed = JSON.parse(raw);
|
|
131
|
+
if (!parsed.fromToken || !parsed.toToken || !parsed.intentType) {
|
|
132
|
+
throw new Error("Invalid LLM response structure");
|
|
133
|
+
}
|
|
134
|
+
return { ...parsed, parsedBy: "llm" };
|
|
135
|
+
}
|
|
136
|
+
// ─── Public API ────────────────────────────────────────────────────────────
|
|
137
|
+
/**
|
|
138
|
+
* Parse a natural language swap intent.
|
|
139
|
+
* Strategy: rule-based first (fast, no API call), LLM fallback for complex/ambiguous inputs.
|
|
140
|
+
* If OPENAI_API_KEY is not set, always uses rules.
|
|
141
|
+
*/
|
|
142
|
+
export async function parseIntent(intent) {
|
|
143
|
+
// Rule-based handles the common cases instantly
|
|
144
|
+
const ruleResult = ruleParse(intent);
|
|
145
|
+
// If rules found both tokens and an amount, trust it
|
|
146
|
+
if (ruleResult.fromToken !== ruleResult.toToken && ruleResult.amount !== null) {
|
|
147
|
+
return ruleResult;
|
|
148
|
+
}
|
|
149
|
+
// Ambiguous — try LLM for better understanding
|
|
150
|
+
try {
|
|
151
|
+
return await llmParse(intent);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// No API key or LLM failed — fall back to rule result
|
|
155
|
+
return ruleResult;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Price feed — CoinGecko free API, no key required.
|
|
3
|
+
*/
|
|
4
|
+
export declare function getTokenPrice(symbol: string): Promise<number | null>;
|
|
5
|
+
export declare function getSupportedTokens(): Promise<{
|
|
6
|
+
symbol: string;
|
|
7
|
+
coingeckoId: string;
|
|
8
|
+
priceUsd: number;
|
|
9
|
+
}[]>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Price feed — CoinGecko free API, no key required.
|
|
3
|
+
*/
|
|
4
|
+
const COINGECKO_IDS = {
|
|
5
|
+
ETH: "ethereum",
|
|
6
|
+
WETH: "weth",
|
|
7
|
+
WBTC: "wrapped-bitcoin",
|
|
8
|
+
BTC: "bitcoin",
|
|
9
|
+
USDC: "usd-coin",
|
|
10
|
+
USDT: "tether",
|
|
11
|
+
DAI: "dai",
|
|
12
|
+
ARB: "arbitrum",
|
|
13
|
+
UNI: "uniswap",
|
|
14
|
+
LINK: "chainlink",
|
|
15
|
+
MATIC: "matic-network",
|
|
16
|
+
OP: "optimism",
|
|
17
|
+
PEPE: "pepe",
|
|
18
|
+
SHIB: "shiba-inu",
|
|
19
|
+
AAVE: "aave",
|
|
20
|
+
CRV: "curve-dao-token",
|
|
21
|
+
};
|
|
22
|
+
// Simple in-memory cache (60s TTL)
|
|
23
|
+
const priceCache = new Map();
|
|
24
|
+
const CACHE_TTL = 60_000;
|
|
25
|
+
export async function getTokenPrice(symbol) {
|
|
26
|
+
const id = COINGECKO_IDS[symbol.toUpperCase()];
|
|
27
|
+
if (!id)
|
|
28
|
+
return null;
|
|
29
|
+
const cached = priceCache.get(symbol);
|
|
30
|
+
if (cached && Date.now() - cached.ts < CACHE_TTL)
|
|
31
|
+
return cached.price;
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${id}&vs_currencies=usd`, { signal: AbortSignal.timeout(5000) });
|
|
34
|
+
const data = (await res.json());
|
|
35
|
+
const price = data[id]?.usd ?? null;
|
|
36
|
+
if (price !== null)
|
|
37
|
+
priceCache.set(symbol, { price, ts: Date.now() });
|
|
38
|
+
return price;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function getSupportedTokens() {
|
|
45
|
+
const symbols = Object.keys(COINGECKO_IDS);
|
|
46
|
+
const ids = Object.values(COINGECKO_IDS).join(",");
|
|
47
|
+
let prices = {};
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=usd`, { signal: AbortSignal.timeout(5000) });
|
|
50
|
+
const data = (await res.json());
|
|
51
|
+
for (const [sym, id] of Object.entries(COINGECKO_IDS)) {
|
|
52
|
+
if (data[id]?.usd)
|
|
53
|
+
prices[sym] = data[id].usd;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// return without prices
|
|
58
|
+
}
|
|
59
|
+
return symbols.map((sym) => ({
|
|
60
|
+
symbol: sym,
|
|
61
|
+
coingeckoId: COINGECKO_IDS[sym],
|
|
62
|
+
priceUsd: prices[sym] ?? null,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
import { type Address } from "viem";
|
|
9
|
+
export interface SwapQuote {
|
|
10
|
+
fromToken: string;
|
|
11
|
+
toToken: string;
|
|
12
|
+
amountIn: number;
|
|
13
|
+
amountOut: number;
|
|
14
|
+
feeTier: number;
|
|
15
|
+
priceImpactPct: number | null;
|
|
16
|
+
gasEstimate: number;
|
|
17
|
+
rateFormatted: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function getSwapQuote(params: {
|
|
20
|
+
fromToken: string;
|
|
21
|
+
toToken: string;
|
|
22
|
+
amount: number;
|
|
23
|
+
slippageBps?: number;
|
|
24
|
+
}): Promise<SwapQuote>;
|
|
25
|
+
export interface SwapTx {
|
|
26
|
+
to: Address;
|
|
27
|
+
data: string;
|
|
28
|
+
value: string;
|
|
29
|
+
fromToken: string;
|
|
30
|
+
toToken: string;
|
|
31
|
+
amountIn: number;
|
|
32
|
+
estimatedAmountOut: number;
|
|
33
|
+
feeTier: number;
|
|
34
|
+
slippageBps: number;
|
|
35
|
+
summary: string;
|
|
36
|
+
warning: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function buildSwapTx(params: {
|
|
39
|
+
fromToken: string;
|
|
40
|
+
toToken: string;
|
|
41
|
+
amount: number;
|
|
42
|
+
recipientAddress: string;
|
|
43
|
+
slippageBps?: number;
|
|
44
|
+
}): Promise<SwapTx>;
|