@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/test.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test — Crypto Swap Agent HTTP server
|
|
3
|
+
* Run: node src/test.js
|
|
4
|
+
* Exit 0 = all pass, Exit 1 = one or more failures
|
|
5
|
+
*
|
|
6
|
+
* Starts the server on port 3001, runs checks, stops it.
|
|
7
|
+
* No external test framework — plain Node with assert.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import 'dotenv/config';
|
|
11
|
+
import assert from 'assert';
|
|
12
|
+
import { startServer } from './server.js';
|
|
13
|
+
|
|
14
|
+
const PORT = 3001;
|
|
15
|
+
const BASE = `http://localhost:${PORT}`;
|
|
16
|
+
|
|
17
|
+
let passed = 0;
|
|
18
|
+
let failed = 0;
|
|
19
|
+
|
|
20
|
+
async function check(name, fn) {
|
|
21
|
+
try {
|
|
22
|
+
await fn();
|
|
23
|
+
console.log(` PASS ${name}`);
|
|
24
|
+
passed++;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.log(` FAIL ${name}`);
|
|
27
|
+
console.log(` -> ${err.message}`);
|
|
28
|
+
failed++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log('\nCrypto Swap Agent — Smoke Tests');
|
|
33
|
+
console.log('='.repeat(50));
|
|
34
|
+
|
|
35
|
+
// Start server
|
|
36
|
+
const server = startServer(PORT);
|
|
37
|
+
|
|
38
|
+
// Wait for server to be ready
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
40
|
+
|
|
41
|
+
// ── Test 1: GET /health ───────────────────────────────────────────────────────
|
|
42
|
+
await check('GET /health returns 200 + { status: "ok" }', async () => {
|
|
43
|
+
const res = await fetch(`${BASE}/health`);
|
|
44
|
+
assert.strictEqual(res.status, 200, `Expected 200, got ${res.status}`);
|
|
45
|
+
const data = await res.json();
|
|
46
|
+
assert.strictEqual(data.status, 'ok', `Expected status "ok", got "${data.status}"`);
|
|
47
|
+
assert.ok(data.version, 'Missing version field');
|
|
48
|
+
assert.ok(data.timestamp, 'Missing timestamp field');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ── Test 2: GET /swap/assets ──────────────────────────────────────────────────
|
|
52
|
+
await check('GET /swap/assets returns array', async () => {
|
|
53
|
+
const res = await fetch(`${BASE}/swap/assets`);
|
|
54
|
+
assert.strictEqual(res.status, 200, `Expected 200, got ${res.status}`);
|
|
55
|
+
const data = await res.json();
|
|
56
|
+
assert.ok(Array.isArray(data), `Expected array, got ${typeof data}`);
|
|
57
|
+
assert.ok(data.length > 0, 'Asset list is empty');
|
|
58
|
+
console.log(` -> ${data.length} assets returned`);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── Test 3: POST /swap/quote ──────────────────────────────────────────────────
|
|
62
|
+
await check('POST /swap/quote returns expectedOutput', async () => {
|
|
63
|
+
const res = await fetch(`${BASE}/swap/quote`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'Content-Type': 'application/json' },
|
|
66
|
+
body: JSON.stringify({ fromAsset: 'ETH.ETH', toAsset: 'BTC.BTC', amount: '0.1' }),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// 422 means no route found (pair temporarily unavailable) — acceptable
|
|
70
|
+
assert.ok(
|
|
71
|
+
[200, 422].includes(res.status),
|
|
72
|
+
`Unexpected status ${res.status}`
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const data = await res.json();
|
|
76
|
+
if (res.status === 200) {
|
|
77
|
+
assert.ok('expectedOutput' in data, 'Missing expectedOutput field');
|
|
78
|
+
assert.ok('affiliateFee' in data, 'Missing affiliateFee field');
|
|
79
|
+
console.log(` -> expectedOutput: ${data.expectedOutput}`);
|
|
80
|
+
} else {
|
|
81
|
+
console.log(` -> No route available (pair temporarily unavailable — OK)`);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── Test 4: POST /swap/quote validation ──────────────────────────────────────
|
|
86
|
+
await check('POST /swap/quote returns 400 on missing fields', async () => {
|
|
87
|
+
const res = await fetch(`${BASE}/swap/quote`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
body: JSON.stringify({ fromAsset: 'ETH.ETH' }),
|
|
91
|
+
});
|
|
92
|
+
assert.strictEqual(res.status, 400, `Expected 400, got ${res.status}`);
|
|
93
|
+
const data = await res.json();
|
|
94
|
+
assert.ok(data.error, 'Missing error field in 400 response');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── Stop server ───────────────────────────────────────────────────────────────
|
|
98
|
+
server.close();
|
|
99
|
+
|
|
100
|
+
console.log('\n' + '='.repeat(50));
|
|
101
|
+
console.log(` Results: ${passed} passed, ${failed} failed`);
|
|
102
|
+
|
|
103
|
+
if (failed > 0) {
|
|
104
|
+
console.log(`\n FAIL — Fix issues before deploying.\n`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
} else {
|
|
107
|
+
console.log(`\n PASS — All smoke tests passed.\n`);
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
package/src/wallet.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wallet.js — Wallet Signing Layer for Crypto Swap Agent
|
|
3
|
+
* Week 3: Connects SwapKit executeSwap with user wallet signing.
|
|
4
|
+
*
|
|
5
|
+
* Supports two modes:
|
|
6
|
+
* 1. Agent wallet (env: WALLET_PRIVATE_KEY) — fully autonomous
|
|
7
|
+
* 2. External wallet (MetaMask/WalletConnect) — returns unsigned tx for user to sign
|
|
8
|
+
*
|
|
9
|
+
* For THORChain swaps: builds the inbound deposit transaction (sends asset
|
|
10
|
+
* to THORChain vault address with memo in OP_RETURN / tx.memo).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ethers } from 'ethers';
|
|
14
|
+
|
|
15
|
+
const EVM_RPC = process.env.EVM_RPC_URL || 'https://mainnet.infura.io/v3/YOUR_INFURA_KEY';
|
|
16
|
+
const AGENT_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY || null;
|
|
17
|
+
|
|
18
|
+
// ─── Wallet Factory ──────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create an agent wallet from private key in env.
|
|
22
|
+
* For production: load key from a secrets manager, not .env.
|
|
23
|
+
*
|
|
24
|
+
* @returns {ethers.Wallet|null} funded wallet instance, or null if not configured
|
|
25
|
+
*/
|
|
26
|
+
export function getAgentWallet() {
|
|
27
|
+
if (!AGENT_PRIVATE_KEY) return null;
|
|
28
|
+
const provider = new ethers.JsonRpcProvider(EVM_RPC);
|
|
29
|
+
return new ethers.Wallet(AGENT_PRIVATE_KEY, provider);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── ETH/EVM Signing ─────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Sign and broadcast an EVM-based THORChain inbound deposit.
|
|
36
|
+
* For ETH → anything swaps via THORChain.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} swapData output from SwapKitApi.executeSwap()
|
|
39
|
+
* @param {string} [privateKey] optional key override (for testing)
|
|
40
|
+
* @returns {Promise<{txHash: string, explorerUrl: string}>}
|
|
41
|
+
*/
|
|
42
|
+
export async function signAndSendEVM(swapData, privateKey = AGENT_PRIVATE_KEY) {
|
|
43
|
+
if (!privateKey) {
|
|
44
|
+
throw new Error('No wallet configured. Set WALLET_PRIVATE_KEY in env or pass a key.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const provider = new ethers.JsonRpcProvider(EVM_RPC);
|
|
48
|
+
const wallet = new ethers.Wallet(privateKey, provider);
|
|
49
|
+
|
|
50
|
+
// THORChain deposits: send ETH to inbound vault with memo as tx data
|
|
51
|
+
const memoHex = ethers.hexlify(ethers.toUtf8Bytes(swapData.memo));
|
|
52
|
+
|
|
53
|
+
// Parse deposit amount (SwapKit returns amount as string like "1.5")
|
|
54
|
+
const amountWei = ethers.parseEther(String(swapData.depositAmount));
|
|
55
|
+
|
|
56
|
+
const tx = {
|
|
57
|
+
to: swapData.depositAddress,
|
|
58
|
+
value: amountWei,
|
|
59
|
+
data: memoHex,
|
|
60
|
+
gasLimit: 80_000n, // THORChain deposits are simple ETH sends
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
console.log(`[wallet] Broadcasting EVM swap tx — vault: ${swapData.depositAddress}, memo: ${swapData.memo}`);
|
|
64
|
+
const sent = await wallet.sendTransaction(tx);
|
|
65
|
+
const receipt = await sent.wait(1);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
txHash: receipt.hash,
|
|
69
|
+
explorerUrl: `https://etherscan.io/tx/${receipt.hash}`,
|
|
70
|
+
status: 'broadcast',
|
|
71
|
+
memo: swapData.memo,
|
|
72
|
+
depositAmount: swapData.depositAmount,
|
|
73
|
+
expectedOutput: swapData.expectedOutput,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build an unsigned EVM transaction for external wallet signing (MetaMask etc.)
|
|
79
|
+
* Returns a JSON object the frontend passes to wallet_sendTransaction.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} swapData output from SwapKitApi.executeSwap()
|
|
82
|
+
* @returns {object} unsigned tx payload
|
|
83
|
+
*/
|
|
84
|
+
export function buildUnsignedEVMTx(swapData) {
|
|
85
|
+
const memoHex = ethers.hexlify(ethers.toUtf8Bytes(swapData.memo));
|
|
86
|
+
const amountHex = ethers.toBeHex(ethers.parseEther(String(swapData.depositAmount)));
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
method: 'eth_sendTransaction',
|
|
90
|
+
params: [{
|
|
91
|
+
to: swapData.depositAddress,
|
|
92
|
+
value: amountHex,
|
|
93
|
+
data: memoHex,
|
|
94
|
+
gas: '0x13880', // 80000 gas
|
|
95
|
+
}],
|
|
96
|
+
// For wagmi/viem integration:
|
|
97
|
+
request: {
|
|
98
|
+
address: swapData.depositAddress,
|
|
99
|
+
value: amountHex,
|
|
100
|
+
data: memoHex,
|
|
101
|
+
},
|
|
102
|
+
memo: swapData.memo,
|
|
103
|
+
expectedOutput: swapData.expectedOutput,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── BTC Signing (read-only guide) ───────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* For BTC deposits to THORChain: user must sign with their BTC wallet.
|
|
111
|
+
* We can't sign BTC natively here — return the deposit instructions.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} swapData
|
|
114
|
+
* @returns {object} BTC deposit instructions
|
|
115
|
+
*/
|
|
116
|
+
export function getBTCDepositInstructions(swapData) {
|
|
117
|
+
return {
|
|
118
|
+
action: 'send_bitcoin',
|
|
119
|
+
to: swapData.depositAddress,
|
|
120
|
+
amount: swapData.depositAmount,
|
|
121
|
+
memo: swapData.memo,
|
|
122
|
+
instructions: [
|
|
123
|
+
`Send exactly ${swapData.depositAmount} BTC to: ${swapData.depositAddress}`,
|
|
124
|
+
`Include memo: ${swapData.memo}`,
|
|
125
|
+
'Use a wallet that supports OP_RETURN memos (Electrum, Xverse, etc.)',
|
|
126
|
+
'Do NOT use exchange withdrawals — they strip the memo',
|
|
127
|
+
],
|
|
128
|
+
warning: 'Missing or incorrect memo will result in lost funds.',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Swap Executor (full flow) ────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Execute a complete swap end-to-end for agent mode.
|
|
136
|
+
* Agent wallet required (WALLET_PRIVATE_KEY).
|
|
137
|
+
*
|
|
138
|
+
* @param {object} swapData output from SwapKitApi.executeSwap()
|
|
139
|
+
* @returns {Promise<object>} broadcast result or instructions
|
|
140
|
+
*/
|
|
141
|
+
export async function executeSwap(swapData) {
|
|
142
|
+
if (swapData.status !== 'pending_signature') {
|
|
143
|
+
throw new Error(`Unexpected swapData.status: ${swapData.status}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const asset = swapData.depositAsset.split('.')[1]?.toUpperCase() || '';
|
|
147
|
+
|
|
148
|
+
if (['ETH', 'USDC', 'USDT', 'WBTC'].includes(asset)) {
|
|
149
|
+
return await signAndSendEVM(swapData);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (asset === 'BTC') {
|
|
153
|
+
return getBTCDepositInstructions(swapData);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
throw new Error(`Unsupported deposit asset for auto-signing: ${swapData.depositAsset}`);
|
|
157
|
+
}
|