@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/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
+ }
@@ -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
+ }