@morebetterclaw/forge-swap 0.1.1
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/.env.example +21 -0
- package/.env.railway +24 -0
- package/.mcpregistry_github_token +1 -0
- package/.mcpregistry_registry_token +1 -0
- package/BUILD_BRIEF_V1.md +216 -0
- package/README.md +189 -0
- package/SKILL.md +93 -0
- package/deploy/README.md +156 -0
- package/package.json +40 -0
- package/public/mcp.json +10 -0
- package/railway.json +13 -0
- package/server.json +21 -0
- package/setup-check.js +80 -0
- package/src/formatter.js +56 -0
- package/src/health.js +27 -0
- package/src/index.js +72 -0
- package/src/mcp.js +200 -0
- package/src/parser.js +90 -0
- package/src/server.js +177 -0
- package/src/swapkit.js +135 -0
- package/src/telegram.js +239 -0
- package/src/test.js +109 -0
- package/src/wallet.js +157 -0
package/src/parser.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Natural language parser for swap commands
|
|
3
|
+
* Converts e.g. "swap quote 0.1 BTC to ETH" into structured params
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Asset normalisation map ā common names ā SwapKit format
|
|
7
|
+
const ASSET_MAP = {
|
|
8
|
+
'BTC': 'BTC.BTC',
|
|
9
|
+
'ETH': 'ETH.ETH',
|
|
10
|
+
'RUNE': 'THOR.RUNE',
|
|
11
|
+
'USDC': 'ETH.USDC-0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
|
|
12
|
+
'USDT': 'ETH.USDT-0xdAC17F958D2ee523a2206206994597C13D831ec7',
|
|
13
|
+
'SOL': 'SOL.SOL',
|
|
14
|
+
'AVAX': 'AVAX.AVAX',
|
|
15
|
+
'BNB': 'BSC.BNB',
|
|
16
|
+
'DOT': 'DOT.DOT',
|
|
17
|
+
'ATOM': 'GAIA.ATOM',
|
|
18
|
+
'DOGE': 'DOGE.DOGE',
|
|
19
|
+
'LTC': 'LTC.LTC',
|
|
20
|
+
'BCH': 'BCH.BCH',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function normaliseAsset(raw) {
|
|
24
|
+
const upper = raw.toUpperCase();
|
|
25
|
+
return ASSET_MAP[upper] || upper;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse a swap command string into a structured action object
|
|
30
|
+
* Supported patterns:
|
|
31
|
+
* swap quote <amount> <from> to <to>
|
|
32
|
+
* swap execute <amount> <from> to <to> address <addr>
|
|
33
|
+
* swap status <txhash>
|
|
34
|
+
* swap assets
|
|
35
|
+
*/
|
|
36
|
+
export function parseSwapCommand(command) {
|
|
37
|
+
const lower = command.toLowerCase().trim();
|
|
38
|
+
|
|
39
|
+
// Remove leading "swap " if present
|
|
40
|
+
const clean = lower.startsWith('swap ') ? command.slice(5).trim() : command.trim();
|
|
41
|
+
const parts = clean.split(/\s+/);
|
|
42
|
+
|
|
43
|
+
const action = parts[0]?.toLowerCase();
|
|
44
|
+
|
|
45
|
+
switch (action) {
|
|
46
|
+
case 'quote': {
|
|
47
|
+
// quote <amount> <from> to <to>
|
|
48
|
+
const amount = parseFloat(parts[1]);
|
|
49
|
+
const fromRaw = parts[2];
|
|
50
|
+
// find "to" keyword
|
|
51
|
+
const toIdx = parts.findIndex((p, i) => i > 2 && p.toLowerCase() === 'to');
|
|
52
|
+
const toRaw = toIdx > 0 ? parts[toIdx + 1] : parts[4];
|
|
53
|
+
return {
|
|
54
|
+
action: 'quote',
|
|
55
|
+
amount,
|
|
56
|
+
fromAsset: normaliseAsset(fromRaw),
|
|
57
|
+
toAsset: normaliseAsset(toRaw)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
case 'execute': {
|
|
62
|
+
// execute <amount> <from> to <to> address <addr>
|
|
63
|
+
const amount = parseFloat(parts[1]);
|
|
64
|
+
const fromRaw = parts[2];
|
|
65
|
+
const toIdx = parts.findIndex((p, i) => i > 2 && p.toLowerCase() === 'to');
|
|
66
|
+
const toRaw = toIdx > 0 ? parts[toIdx + 1] : parts[4];
|
|
67
|
+
const addrIdx = parts.findIndex(p => p.toLowerCase() === 'address');
|
|
68
|
+
const destAddress = addrIdx > 0 ? parts[addrIdx + 1] : null;
|
|
69
|
+
return {
|
|
70
|
+
action: 'execute',
|
|
71
|
+
amount,
|
|
72
|
+
fromAsset: normaliseAsset(fromRaw),
|
|
73
|
+
toAsset: normaliseAsset(toRaw),
|
|
74
|
+
destAddress
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case 'status': {
|
|
79
|
+
return { action: 'status', txHash: parts[1] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case 'assets':
|
|
83
|
+
case 'list': {
|
|
84
|
+
return { action: 'assets' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
default:
|
|
88
|
+
return { action: 'unknown', raw: command };
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server.js ā HTTP server for Crypto Swap Agent
|
|
3
|
+
* Exposes swap agent as a REST API for agent-to-agent and web clients.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import 'dotenv/config';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import rateLimit from 'express-rate-limit';
|
|
9
|
+
import { readFileSync } from 'fs';
|
|
10
|
+
import { join, dirname } from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { SwapKitApi } from './swapkit.js';
|
|
13
|
+
import { createMcpRouter } from './mcp.js';
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
const app = express();
|
|
18
|
+
const PORT = process.env.PORT || 3000;
|
|
19
|
+
const VERSION = process.env.npm_package_version || '0.1.0';
|
|
20
|
+
|
|
21
|
+
const FEE_BPS = parseInt(process.env.SWAP_FEE_BPS || '50');
|
|
22
|
+
const FEE_ADDRESS = process.env.FEE_RECIPIENT_ADDRESS || '';
|
|
23
|
+
|
|
24
|
+
const api = new SwapKitApi({ feeBps: FEE_BPS, feeAddress: FEE_ADDRESS });
|
|
25
|
+
|
|
26
|
+
// āā Middleware āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
27
|
+
|
|
28
|
+
app.use(express.json());
|
|
29
|
+
|
|
30
|
+
// CORS
|
|
31
|
+
app.use((req, res, next) => {
|
|
32
|
+
const origins = process.env.ALLOWED_ORIGINS || '*';
|
|
33
|
+
res.header('Access-Control-Allow-Origin', origins);
|
|
34
|
+
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
|
|
35
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
36
|
+
if (req.method === 'OPTIONS') return res.sendStatus(200);
|
|
37
|
+
next();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Rate limiting
|
|
41
|
+
const limiter = rateLimit({
|
|
42
|
+
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000'),
|
|
43
|
+
max: parseInt(process.env.RATE_LIMIT_MAX || '100'),
|
|
44
|
+
standardHeaders: true,
|
|
45
|
+
legacyHeaders: false,
|
|
46
|
+
});
|
|
47
|
+
app.use(limiter);
|
|
48
|
+
|
|
49
|
+
// Request logging
|
|
50
|
+
app.use((req, res, next) => {
|
|
51
|
+
const start = Date.now();
|
|
52
|
+
res.on('finish', () => {
|
|
53
|
+
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path} ${res.statusCode} ${Date.now() - start}ms`);
|
|
54
|
+
});
|
|
55
|
+
next();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// āā Routes āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
59
|
+
|
|
60
|
+
// GET /health
|
|
61
|
+
app.get('/health', (req, res) => {
|
|
62
|
+
res.json({
|
|
63
|
+
status: 'ok',
|
|
64
|
+
version: VERSION,
|
|
65
|
+
timestamp: new Date().toISOString(),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// POST /swap/quote
|
|
70
|
+
app.post('/swap/quote', async (req, res, next) => {
|
|
71
|
+
try {
|
|
72
|
+
const { fromAsset, toAsset, amount } = req.body;
|
|
73
|
+
if (!fromAsset || !toAsset || !amount) {
|
|
74
|
+
return res.status(400).json({ error: 'fromAsset, toAsset, and amount are required' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const quote = await api.getQuote(fromAsset, toAsset, parseFloat(amount));
|
|
78
|
+
const best = quote.routes?.[0];
|
|
79
|
+
|
|
80
|
+
if (!best) {
|
|
81
|
+
return res.status(422).json({ error: 'No route found for this pair' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
res.json({
|
|
85
|
+
fromAsset,
|
|
86
|
+
toAsset,
|
|
87
|
+
amount,
|
|
88
|
+
expectedOutput: best.expectedOutput,
|
|
89
|
+
expectedOutputMinusFees: best.expectedOutputMaxSlippage,
|
|
90
|
+
affiliateFee: `${FEE_BPS / 100}%`,
|
|
91
|
+
route: best.providers || ['THORChain'],
|
|
92
|
+
slippage: `${((quote.raw?.slippage_bps || 0) / 100).toFixed(2)}%`,
|
|
93
|
+
expiresAt: new Date(Date.now() + 30000).toISOString(),
|
|
94
|
+
});
|
|
95
|
+
} catch (err) {
|
|
96
|
+
next(err);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// POST /swap/execute
|
|
101
|
+
app.post('/swap/execute', async (req, res, next) => {
|
|
102
|
+
try {
|
|
103
|
+
const { fromAsset, toAsset, amount, destinationAddress } = req.body;
|
|
104
|
+
if (!fromAsset || !toAsset || !amount || !destinationAddress) {
|
|
105
|
+
return res.status(400).json({
|
|
106
|
+
error: 'fromAsset, toAsset, amount, and destinationAddress are required',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const swapData = await api.executeSwap(
|
|
111
|
+
fromAsset, toAsset, parseFloat(amount), destinationAddress
|
|
112
|
+
);
|
|
113
|
+
res.json(swapData);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
next(err);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// GET /swap/status?txHash=<hash>
|
|
120
|
+
app.get('/swap/status', async (req, res, next) => {
|
|
121
|
+
try {
|
|
122
|
+
const { txHash } = req.query;
|
|
123
|
+
if (!txHash) {
|
|
124
|
+
return res.status(400).json({ error: 'txHash query parameter required' });
|
|
125
|
+
}
|
|
126
|
+
const status = await api.getStatus(txHash);
|
|
127
|
+
res.json(status);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
next(err);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// GET /swap/assets
|
|
134
|
+
app.get('/swap/assets', async (req, res, next) => {
|
|
135
|
+
try {
|
|
136
|
+
const assets = await api.getSupportedAssets();
|
|
137
|
+
res.json(assets);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
next(err);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// āā MCP server āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
144
|
+
|
|
145
|
+
app.use('/mcp', createMcpRouter(api));
|
|
146
|
+
|
|
147
|
+
app.get('/.well-known/mcp.json', (req, res) => {
|
|
148
|
+
res.json(JSON.parse(readFileSync(join(__dirname, '../public/mcp.json'), 'utf8')));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// āā Error handler āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
152
|
+
|
|
153
|
+
app.use((err, req, res, next) => {
|
|
154
|
+
console.error(`[error] ${err.message}`);
|
|
155
|
+
const status = err.status || 500;
|
|
156
|
+
res.status(status).json({ error: err.message || 'Internal server error' });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// āā Server start āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
160
|
+
|
|
161
|
+
export function startServer(port = PORT) {
|
|
162
|
+
const server = app.listen(port, () => {
|
|
163
|
+
console.log(`[server] Crypto Swap Agent running on port ${port}`);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
process.on('SIGTERM', () => {
|
|
167
|
+
console.log('[server] SIGTERM received, shutting down...');
|
|
168
|
+
server.close(() => {
|
|
169
|
+
console.log('[server] Server closed');
|
|
170
|
+
process.exit(0);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return server;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export default app;
|
package/src/swapkit.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SwapKit API wrapper ā MoreBetter Crypto Swap Agent
|
|
3
|
+
* Direct THORNode/Midgard integration with MoreBetter fee injection
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const THORNODE_BASE = 'https://thornode.ninerealms.com';
|
|
7
|
+
const MIDGARD_BASE = 'https://midgard.ninerealms.com';
|
|
8
|
+
|
|
9
|
+
export class SwapKitApi {
|
|
10
|
+
constructor({ feeBps, feeAddress }) {
|
|
11
|
+
this.feeBps = feeBps;
|
|
12
|
+
this.feeAddress = feeAddress;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get a swap quote with MoreBetter affiliate fee injected
|
|
17
|
+
* @param {string} fromAsset - e.g. "ETH.ETH"
|
|
18
|
+
* @param {string} toAsset - e.g. "BTC.BTC"
|
|
19
|
+
* @param {number} amount - amount in human-readable units
|
|
20
|
+
* @param {string} [destAddress] - optional destination address
|
|
21
|
+
* @returns {object} quote with routes, expected output, fee breakdown
|
|
22
|
+
*/
|
|
23
|
+
async getQuote(fromAsset, toAsset, amount, destAddress = '') {
|
|
24
|
+
const amountBaseUnits = Math.round(parseFloat(amount) * 1e8);
|
|
25
|
+
const params = new URLSearchParams({
|
|
26
|
+
from_asset: fromAsset,
|
|
27
|
+
to_asset: toAsset,
|
|
28
|
+
amount: amountBaseUnits.toString(),
|
|
29
|
+
affiliate: this.feeAddress || '',
|
|
30
|
+
affiliate_bps: this.feeBps.toString(),
|
|
31
|
+
});
|
|
32
|
+
if (destAddress) params.set('destination', destAddress);
|
|
33
|
+
|
|
34
|
+
const res = await fetch(
|
|
35
|
+
`${THORNODE_BASE}/thorchain/quote/swap?${params}`
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const err = await res.text();
|
|
40
|
+
throw new Error(`THORNode quote failed: ${res.status} ${err}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const data = await res.json();
|
|
44
|
+
|
|
45
|
+
// Normalize to routes format for internal compatibility
|
|
46
|
+
const expectedOut = parseInt(data.expected_amount_out || '0') / 1e8;
|
|
47
|
+
const slippageBps = data.slippage_bps || 0;
|
|
48
|
+
const expectedOutMinusSlip = expectedOut * (1 - slippageBps / 10000);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
routes: [{
|
|
52
|
+
inboundAddress: data.inbound_address,
|
|
53
|
+
expectedOutput: expectedOut.toString(),
|
|
54
|
+
expectedOutputMaxSlippage: expectedOutMinusSlip.toFixed(8),
|
|
55
|
+
providers: ['THORChain'],
|
|
56
|
+
streamingSwap: false,
|
|
57
|
+
memo: data.memo,
|
|
58
|
+
}],
|
|
59
|
+
raw: data,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build swap transaction memo + return unsigned tx for signing
|
|
65
|
+
* For agent use: returns the memo + tx data, not the signed tx
|
|
66
|
+
*/
|
|
67
|
+
async executeSwap(fromAsset, toAsset, amount, destAddress) {
|
|
68
|
+
const quote = await this.getQuote(fromAsset, toAsset, amount, destAddress);
|
|
69
|
+
const bestRoute = quote.routes?.[0];
|
|
70
|
+
|
|
71
|
+
if (!bestRoute) {
|
|
72
|
+
throw new Error('No route found for this swap pair');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Use memo from THORNode quote if available, otherwise build it
|
|
76
|
+
const memo = bestRoute.memo || this._buildMemo(toAsset, destAddress, bestRoute.streamingSwap);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
status: 'pending_signature',
|
|
80
|
+
memo,
|
|
81
|
+
depositAsset: fromAsset,
|
|
82
|
+
depositAmount: amount,
|
|
83
|
+
depositAddress: bestRoute.inboundAddress,
|
|
84
|
+
expectedOutput: bestRoute.expectedOutputMaxSlippage,
|
|
85
|
+
route: bestRoute.providers,
|
|
86
|
+
affiliateFee: `${this.feeBps / 100}%`,
|
|
87
|
+
warning: 'Wallet signing required ā call wallet.sign(swapData) to execute'
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check swap status by tx hash
|
|
93
|
+
*/
|
|
94
|
+
async getStatus(txHash) {
|
|
95
|
+
const res = await fetch(
|
|
96
|
+
`${MIDGARD_BASE}/v2/actions?txid=${txHash}`
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (!res.ok) throw new Error(`Status check failed: ${res.status}`);
|
|
100
|
+
return await res.json();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get list of all supported assets (from Midgard pools)
|
|
105
|
+
*/
|
|
106
|
+
async getSupportedAssets() {
|
|
107
|
+
const res = await fetch(`${MIDGARD_BASE}/v2/pools`);
|
|
108
|
+
|
|
109
|
+
if (!res.ok) throw new Error(`Assets fetch failed: ${res.status}`);
|
|
110
|
+
const pools = await res.json();
|
|
111
|
+
|
|
112
|
+
return Array.isArray(pools) ? pools.map(p => ({
|
|
113
|
+
identifier: p.asset,
|
|
114
|
+
asset: p.asset,
|
|
115
|
+
chain: p.asset.split('.')[0],
|
|
116
|
+
ticker: p.asset.split('.')[1]?.split('-')[0],
|
|
117
|
+
symbol: p.asset.split('.')[1],
|
|
118
|
+
})) : [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get inbound addresses for all chains
|
|
123
|
+
*/
|
|
124
|
+
async getInboundAddresses() {
|
|
125
|
+
const res = await fetch(`${THORNODE_BASE}/thorchain/inbound_addresses`);
|
|
126
|
+
if (!res.ok) throw new Error(`Inbound addresses fetch failed: ${res.status}`);
|
|
127
|
+
return await res.json();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Build THORChain swap memo with affiliate fee
|
|
131
|
+
_buildMemo(toAsset, destAddress, streamingSwap) {
|
|
132
|
+
const affiliateShort = this.feeAddress ? this.feeAddress.slice(0, 8) : 'mb';
|
|
133
|
+
return `=:${toAsset}:${destAddress}:0/3/0:${affiliateShort}:${this.feeBps}`;
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/telegram.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Bot ā MoreBetter Crypto Swap Agent
|
|
3
|
+
* Commands: /start /quote /assets /help /about
|
|
4
|
+
* Uses: node-telegram-bot-api + SwapKit REST API
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import 'dotenv/config';
|
|
8
|
+
import TelegramBot from 'node-telegram-bot-api';
|
|
9
|
+
import { SwapKitApi } from './swapkit.js';
|
|
10
|
+
import { formatQuote, formatAssetList } from './formatter.js';
|
|
11
|
+
|
|
12
|
+
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
|
13
|
+
if (!BOT_TOKEN) {
|
|
14
|
+
console.error('[Telegram] TELEGRAM_BOT_TOKEN not set in environment. Get one from @BotFather.');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const swapkit = new SwapKitApi({
|
|
19
|
+
apiKey: process.env.SWAPKIT_API_KEY || '',
|
|
20
|
+
feeBps: parseInt(process.env.SWAP_FEE_BPS || '50', 10),
|
|
21
|
+
feeAddress: process.env.SWAP_FEE_ADDRESS || 'thor1morebetter'
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const bot = new TelegramBot(BOT_TOKEN, { polling: true });
|
|
25
|
+
|
|
26
|
+
console.log('[Telegram] Bot starting in polling mode...');
|
|
27
|
+
|
|
28
|
+
// āā /start āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
29
|
+
bot.onText(/\/start/, (msg) => {
|
|
30
|
+
const name = msg.from?.first_name || 'there';
|
|
31
|
+
bot.sendMessage(msg.chat.id, [
|
|
32
|
+
`š Hey ${name}! I'm the **MoreBetter Crypto Swap Agent**.`,
|
|
33
|
+
'',
|
|
34
|
+
'I find the best cross-chain swap routes via THORChain ā covering BTC, ETH, SOL, AVAX, BNB and more.',
|
|
35
|
+
'',
|
|
36
|
+
'**Commands:**',
|
|
37
|
+
'`/quote BTC ETH 0.1` ā Get a swap quote',
|
|
38
|
+
'`/assets` ā List supported assets',
|
|
39
|
+
'`/help` ā Usage guide & examples',
|
|
40
|
+
'`/about` ā About this service',
|
|
41
|
+
'',
|
|
42
|
+
'Try: `/quote BTC ETH 0.1`'
|
|
43
|
+
].join('\n'), { parse_mode: 'Markdown' });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// āā /quote [from] [to] [amount] āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
47
|
+
bot.onText(/\/quote(?:\s+(.+))?/, async (msg, match) => {
|
|
48
|
+
const chatId = msg.chat.id;
|
|
49
|
+
const args = match?.[1]?.trim().split(/\s+/) || [];
|
|
50
|
+
|
|
51
|
+
if (args.length < 3) {
|
|
52
|
+
return bot.sendMessage(chatId, [
|
|
53
|
+
'ā ļø Usage: `/quote [FROM] [TO] [AMOUNT]`',
|
|
54
|
+
'',
|
|
55
|
+
'Examples:',
|
|
56
|
+
'`/quote BTC ETH 0.1`',
|
|
57
|
+
'`/quote ETH BTC 1.5`',
|
|
58
|
+
'`/quote RUNE BTC 500`'
|
|
59
|
+
].join('\n'), { parse_mode: 'Markdown' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const [fromSymbol, toSymbol, amountStr] = args;
|
|
63
|
+
const amount = parseFloat(amountStr);
|
|
64
|
+
|
|
65
|
+
if (isNaN(amount) || amount <= 0) {
|
|
66
|
+
return bot.sendMessage(chatId, 'ā ļø Amount must be a positive number. Example: `/quote BTC ETH 0.1`', { parse_mode: 'Markdown' });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Normalise to chain.SYMBOL format (basic mapping)
|
|
70
|
+
const fromAsset = normaliseAsset(fromSymbol.toUpperCase());
|
|
71
|
+
const toAsset = normaliseAsset(toSymbol.toUpperCase());
|
|
72
|
+
|
|
73
|
+
const thinking = await bot.sendMessage(chatId, `š Getting quote for ${amount} ${fromSymbol.toUpperCase()} ā ${toSymbol.toUpperCase()}...`);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const quoteData = await swapkit.getQuote(fromAsset, toAsset, amount);
|
|
77
|
+
const parsed = { fromAsset, toAsset, amount };
|
|
78
|
+
const result = formatQuote(quoteData, parsed);
|
|
79
|
+
|
|
80
|
+
if (!result.success) {
|
|
81
|
+
await bot.editMessageText(`ā No route found for this pair.\n\nTry: \`/assets\` to see supported tokens.`, {
|
|
82
|
+
chat_id: chatId,
|
|
83
|
+
message_id: thinking.message_id,
|
|
84
|
+
parse_mode: 'Markdown'
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const text = [
|
|
90
|
+
`š± **Swap Quote**`,
|
|
91
|
+
`**${amount} ${fromSymbol.toUpperCase()} ā ${toSymbol.toUpperCase()}**`,
|
|
92
|
+
'',
|
|
93
|
+
`š¤ You send: \`${amount} ${fromSymbol.toUpperCase()}\``,
|
|
94
|
+
`š„ You receive: \`${result.expectedOutput || 'calculating...'}\``,
|
|
95
|
+
`š£ļø Route: ${result.route}`,
|
|
96
|
+
`ā±ļø Est. time: ${result.estimatedTimeSeconds ? Math.ceil(result.estimatedTimeSeconds / 60) + ' mins' : 'varies'}`,
|
|
97
|
+
`š Price impact: ${result.priceImpact || 'n/a'}%`,
|
|
98
|
+
`š° Service fee: ${result.affiliateFee}`,
|
|
99
|
+
result.warning ? `\nā ļø ${result.warning}` : '',
|
|
100
|
+
'',
|
|
101
|
+
`_Quote valid for ~60 seconds_`
|
|
102
|
+
].filter(Boolean).join('\n');
|
|
103
|
+
|
|
104
|
+
await bot.editMessageText(text, {
|
|
105
|
+
chat_id: chatId,
|
|
106
|
+
message_id: thinking.message_id,
|
|
107
|
+
parse_mode: 'Markdown'
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error('[Quote Error]', err.message);
|
|
112
|
+
await bot.editMessageText(
|
|
113
|
+
`ā Failed to get quote: ${sanitiseError(err.message)}\n\nPlease try again or check \`/assets\` for supported tokens.`,
|
|
114
|
+
{ chat_id: chatId, message_id: thinking.message_id, parse_mode: 'Markdown' }
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// āā /assets āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
120
|
+
bot.onText(/\/assets/, async (msg) => {
|
|
121
|
+
const chatId = msg.chat.id;
|
|
122
|
+
const thinking = await bot.sendMessage(chatId, 'š Fetching supported assets...');
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const assets = await swapkit.getSupportedAssets();
|
|
126
|
+
const result = formatAssetList(assets);
|
|
127
|
+
|
|
128
|
+
// Group by chain for readability
|
|
129
|
+
const byChain = {};
|
|
130
|
+
result.assets.forEach(a => {
|
|
131
|
+
const chain = a.chain || a.identifier?.split('.')[0] || 'OTHER';
|
|
132
|
+
if (!byChain[chain]) byChain[chain] = [];
|
|
133
|
+
byChain[chain].push(a.symbol || a.identifier);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const lines = [`ā
**${result.count} assets supported** (showing top 50)`, ''];
|
|
137
|
+
for (const [chain, symbols] of Object.entries(byChain).slice(0, 8)) {
|
|
138
|
+
lines.push(`**${chain}:** ${symbols.slice(0, 5).join(', ')}${symbols.length > 5 ? '...' : ''}`);
|
|
139
|
+
}
|
|
140
|
+
lines.push('', '_Full list at api.swapkit.dev/tokens_');
|
|
141
|
+
|
|
142
|
+
await bot.editMessageText(lines.join('\n'), {
|
|
143
|
+
chat_id: chatId,
|
|
144
|
+
message_id: thinking.message_id,
|
|
145
|
+
parse_mode: 'Markdown'
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error('[Assets Error]', err.message);
|
|
150
|
+
await bot.editMessageText(
|
|
151
|
+
`ā Could not fetch asset list: ${sanitiseError(err.message)}`,
|
|
152
|
+
{ chat_id: chatId, message_id: thinking.message_id }
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// āā /help āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
158
|
+
bot.onText(/\/help/, (msg) => {
|
|
159
|
+
bot.sendMessage(msg.chat.id, [
|
|
160
|
+
'š **MoreBetter Swap Agent ā Help**',
|
|
161
|
+
'',
|
|
162
|
+
'**Get a quote:**',
|
|
163
|
+
'`/quote [FROM] [TO] [AMOUNT]`',
|
|
164
|
+
'Example: `/quote BTC ETH 0.5`',
|
|
165
|
+
'Example: `/quote RUNE BTC 100`',
|
|
166
|
+
'Example: `/quote ETH SOL 2`',
|
|
167
|
+
'',
|
|
168
|
+
'**List assets:**',
|
|
169
|
+
'`/assets` ā shows all supported tokens',
|
|
170
|
+
'',
|
|
171
|
+
'**Asset format:**',
|
|
172
|
+
'Use the token symbol (BTC, ETH, SOL, RUNE, AVAX, BNB, etc.).',
|
|
173
|
+
'The bot auto-resolves to the native chain.',
|
|
174
|
+
'',
|
|
175
|
+
'**Fees:**',
|
|
176
|
+
'0.5% service fee on all swaps, embedded in the route.',
|
|
177
|
+
'No hidden charges. You always see the output before confirming.',
|
|
178
|
+
'',
|
|
179
|
+
'**Questions?** DM @MoreBetterStudios on X.'
|
|
180
|
+
].join('\n'), { parse_mode: 'Markdown' });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// āā /about āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
184
|
+
bot.onText(/\/about/, (msg) => {
|
|
185
|
+
bot.sendMessage(msg.chat.id, [
|
|
186
|
+
'ā” **MoreBetter Crypto Swap Agent**',
|
|
187
|
+
'',
|
|
188
|
+
'Built by **MoreBetter Studios** ā autonomous AI agents generating value 24/7.',
|
|
189
|
+
'',
|
|
190
|
+
'Powered by **THORChain + SwapKit SDK**: the leading non-custodial cross-chain DEX.',
|
|
191
|
+
'⢠9 blockchains: BTC, ETH, SOL, AVAX, BNB, RUNE, BASE, DOGE, GAIA',
|
|
192
|
+
'⢠Best prices via aggregated routing',
|
|
193
|
+
'⢠No wrapping, no bridges, no custodial risk',
|
|
194
|
+
'⢠Streaming swaps for large volumes',
|
|
195
|
+
'',
|
|
196
|
+
'**Version:** 0.2.0 (Week 2)',
|
|
197
|
+
'**Website:** https://morebetter.studio',
|
|
198
|
+
'**X:** @MoreBetterStudios'
|
|
199
|
+
].join('\n'), { parse_mode: 'Markdown' });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// āā Error handling āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
203
|
+
bot.on('polling_error', (error) => {
|
|
204
|
+
console.error('[Polling Error]', error.code, error.message);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
bot.on('error', (error) => {
|
|
208
|
+
console.error('[Bot Error]', error.message);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
console.log('[Telegram] Bot ready. Listening for commands...');
|
|
212
|
+
|
|
213
|
+
// āā Helpers āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
214
|
+
|
|
215
|
+
/** Normalise symbol (BTC) to chain.SYMBOL format (BTC.BTC) */
|
|
216
|
+
function normaliseAsset(symbol) {
|
|
217
|
+
const CHAIN_MAP = {
|
|
218
|
+
BTC: 'BTC.BTC',
|
|
219
|
+
ETH: 'ETH.ETH',
|
|
220
|
+
SOL: 'SOL.SOL',
|
|
221
|
+
RUNE: 'THOR.RUNE',
|
|
222
|
+
AVAX: 'AVAX.AVAX',
|
|
223
|
+
BNB: 'BSC.BNB',
|
|
224
|
+
DOGE: 'DOGE.DOGE',
|
|
225
|
+
ATOM: 'GAIA.ATOM',
|
|
226
|
+
USDC: 'ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48',
|
|
227
|
+
USDT: 'ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7'
|
|
228
|
+
};
|
|
229
|
+
// If already in X.Y format, return as-is
|
|
230
|
+
if (symbol.includes('.')) return symbol;
|
|
231
|
+
return CHAIN_MAP[symbol] || `${symbol}.${symbol}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Strip internal details from error messages before showing to users */
|
|
235
|
+
function sanitiseError(msg) {
|
|
236
|
+
if (!msg) return 'unknown error';
|
|
237
|
+
// Don't expose API keys, URLs, stack traces
|
|
238
|
+
return msg.replace(/https?:\/\/\S+/g, '[API]').slice(0, 100);
|
|
239
|
+
}
|