swap-service/server.js
Claude e1b4d9fcbb
feat(swap-service): initial push
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:42:12 +01:00

263 lines
9.9 KiB
JavaScript

#!/usr/bin/env node
/**
* Lethean SWAP Service
*
* Receives SWAP claims from legacy chain holders, validates them,
* and queues distribution of new LTHN tokens.
*
* Flow:
* 1. User submits SWAP claim (legacy tx proof + new chain address)
* 2. Service records the claim in a local DB
* 3. Admin (Snider) reviews and approves claims
* 4. Approved claims get LTHN dispatched from the SWAP pool
*
* The SWAP pool is the bare outputs from the first 10,000 blocks
* (pre-HF2 fast blocks). Each output = 1 LTHN. Ratio: 100 old = 1 new.
*
* Endpoints:
* GET / — SWAP info page
* POST /api/claim — submit a SWAP claim
* GET /api/claims — list all claims (admin)
* POST /api/approve/:id — approve a claim (admin)
* GET /api/pool — SWAP pool status
* GET /api/status — service status
*/
const http = require('http');
const fs = require('fs');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const PORT = process.env.PORT || 8102;
const WALLET_URL = process.env.WALLET_URL || 'http://127.0.0.1:46944';
const DAEMON_URL = process.env.DAEMON_URL || 'http://127.0.0.1:46941';
const ADMIN_KEY = process.env.ADMIN_KEY || 'swap-admin-testnet';
const DB_FILE = process.env.DB_FILE || '/tmp/lthn-swap-claims.json';
const SWAP_RATIO = 100; // 100 old LTHN = 1 new LTHN
// Simple file-based claim storage
function loadClaims() {
try { return JSON.parse(fs.readFileSync(DB_FILE, 'utf8')); }
catch { return []; }
}
function saveClaims(claims) {
fs.writeFileSync(DB_FILE, JSON.stringify(claims, null, 2));
}
function jsonResponse(res, data, status = 200) {
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.end(JSON.stringify(data, null, 2));
}
function getBody(req) {
return new Promise(resolve => {
let body = '';
req.on('data', chunk => body += chunk.slice(0, 10000)); // limit 10KB
req.on('end', () => { try { resolve(JSON.parse(body)); } catch { resolve({}); } });
});
}
async function walletRpc(method, params = {}) {
try {
const resp = await fetch(`${WALLET_URL}/json_rpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: '0', method, params }),
});
const data = await resp.json();
return data.result || null;
} catch { return null; }
}
async function daemonRpc(method, params = {}) {
try {
const resp = await fetch(`${DAEMON_URL}/json_rpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: '0', method, params }),
});
const data = await resp.json();
return data.result || null;
} catch { return null; }
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
if (req.method === 'OPTIONS') {
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST', 'Access-Control-Allow-Headers': 'Content-Type,Authorization' });
return res.end();
}
// === Info page ===
if (url.pathname === '/' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/html' });
return res.end(`<!DOCTYPE html><html><head><title>Lethean SWAP</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>body{font-family:monospace;background:#0a0a0a;color:#e0e0e0;max-width:700px;margin:40px auto;padding:20px}
h1{color:#00d4aa}h2{color:#888}a{color:#00d4aa}pre{background:#111;padding:15px;border-radius:6px}
table{width:100%;border-collapse:collapse}td,th{padding:8px;text-align:left;border-bottom:1px solid #1a1a1a}
.warn{background:#2a1a00;border-left:3px solid #f0a030;padding:15px;border-radius:4px;margin:15px 0}</style></head>
<body><h1>Lethean Token SWAP</h1>
<p>Exchange old-chain LTHN (IntenseCoin) for new-chain LTHN at 100:1 ratio.</p>
<div class="warn"><strong>How it works:</strong><br>
1. On the legacy chain, send your LTHN to the SWAP address (burns them)<br>
2. Submit the transaction proof below with your NEW chain address (iTHN...)<br>
3. After verification, new LTHN tokens are dispatched to your address<br>
<br>Ratio: <strong>100 old LTHN = 1 new LTHN</strong></div>
<h2>Submit SWAP Claim</h2>
<pre>curl -X POST https://swap.lthn.io/api/claim \\
-H 'Content-Type: application/json' \\
-d '{
"legacy_tx_hash": "YOUR_LEGACY_TX_HASH",
"legacy_block_height": 123456,
"legacy_amount": 10000,
"new_address": "iTHNyour_new_address_here"
}'</pre>
<h2>Check Pool</h2>
<p><a href="/api/pool">SWAP Pool Status</a> · <a href="/api/status">Service Status</a></p>
<table>
<tr><th>Detail</th><th>Value</th></tr>
<tr><td>Ratio</td><td>100 old LTHN = 1 new LTHN</td></tr>
<tr><td>Pool</td><td>~10,000 LTHN (from pre-HF2 bare outputs)</td></tr>
<tr><td>Verification</td><td>Manual review by Lethean CIC</td></tr>
<tr><td>Processing</td><td>Within 24 hours of submission</td></tr>
</table>
</body></html>`);
}
// === Submit claim ===
if (url.pathname === '/api/claim' && req.method === 'POST') {
const body = await getBody(req);
// Validate required fields
if (!body.legacy_tx_hash || !body.new_address) {
return jsonResponse(res, { error: 'Missing required fields: legacy_tx_hash, new_address' }, 400);
}
if (!body.new_address.startsWith('iTHN')) {
return jsonResponse(res, { error: 'new_address must be a valid Lethean address (starts with iTHN)' }, 400);
}
if (!body.legacy_amount || body.legacy_amount < 100) {
return jsonResponse(res, { error: 'legacy_amount must be at least 100 (minimum 1 new LTHN after 100:1 ratio)' }, 400);
}
const claims = loadClaims();
// Check for duplicate
if (claims.find(c => c.legacy_tx_hash === body.legacy_tx_hash)) {
return jsonResponse(res, { error: 'This transaction has already been claimed' }, 409);
}
const claim = {
id: crypto.randomBytes(8).toString('hex'),
legacy_tx_hash: body.legacy_tx_hash,
legacy_block_height: body.legacy_block_height || null,
legacy_amount: body.legacy_amount,
new_amount: Math.floor(body.legacy_amount / SWAP_RATIO),
new_address: body.new_address,
status: 'pending',
submitted: new Date().toISOString(),
reviewed: null,
dispatched_tx: null,
};
claims.push(claim);
saveClaims(claims);
return jsonResponse(res, {
id: claim.id,
status: 'pending',
legacy_amount: claim.legacy_amount,
new_amount: claim.new_amount,
message: `Claim submitted. ${claim.legacy_amount} old LTHN → ${claim.new_amount} new LTHN. Pending review.`,
}, 201);
}
// === List claims (admin) ===
if (url.pathname === '/api/claims' && req.method === 'GET') {
if (req.headers.authorization !== `Bearer ${ADMIN_KEY}`) {
return jsonResponse(res, { error: 'Unauthorized' }, 401);
}
return jsonResponse(res, { claims: loadClaims() });
}
// === Approve claim (admin) ===
const approveMatch = url.pathname.match(/^\/api\/approve\/([a-f0-9]+)$/);
if (approveMatch && req.method === 'POST') {
if (req.headers.authorization !== `Bearer ${ADMIN_KEY}`) {
return jsonResponse(res, { error: 'Unauthorized' }, 401);
}
const claims = loadClaims();
const claim = claims.find(c => c.id === approveMatch[1]);
if (!claim) return jsonResponse(res, { error: 'Claim not found' }, 404);
if (claim.status !== 'pending') return jsonResponse(res, { error: `Claim already ${claim.status}` }, 400);
// Dispatch tokens via wallet RPC
const amount = claim.new_amount * 1000000000000; // atomic units
const result = await walletRpc('transfer', {
destinations: [{ address: claim.new_address, amount }],
mixin: 10,
fee: 10000000000,
});
if (result && result.tx_hash) {
claim.status = 'dispatched';
claim.reviewed = new Date().toISOString();
claim.dispatched_tx = result.tx_hash;
saveClaims(claims);
return jsonResponse(res, { status: 'dispatched', tx_hash: result.tx_hash, amount: claim.new_amount });
} else {
claim.status = 'failed';
claim.reviewed = new Date().toISOString();
saveClaims(claims);
return jsonResponse(res, { error: 'Transfer failed — may need HF4 or insufficient balance' }, 500);
}
}
// === Pool status ===
if (url.pathname === '/api/pool' && req.method === 'GET') {
const balance = await walletRpc('getbalance');
const bareOuts = await walletRpc('get_bare_outs_stats');
const claims = loadClaims();
const pending = claims.filter(c => c.status === 'pending').length;
const dispatched = claims.filter(c => c.status === 'dispatched').reduce((sum, c) => sum + c.new_amount, 0);
return jsonResponse(res, {
pool_balance: balance ? balance.balance / 1e12 : null,
pool_unlocked: balance ? balance.unlocked_balance / 1e12 : null,
bare_outputs: bareOuts ? bareOuts.total_bare_outs : null,
total_claims: claims.length,
pending_claims: pending,
dispatched_lthn: dispatched,
remaining_pool: balance ? (balance.balance / 1e12) - dispatched : null,
swap_ratio: `${SWAP_RATIO}:1`,
});
}
// === Service status ===
if (url.pathname === '/api/status' && req.method === 'GET') {
const chain = await daemonRpc('getinfo');
return jsonResponse(res, {
service: 'swap',
chain_height: chain?.height,
hf4_active: chain?.is_hardfok_active?.[4] || false,
swap_ratio: SWAP_RATIO,
transfers_enabled: chain?.is_hardfok_active?.[4] || false,
});
}
jsonResponse(res, { error: 'Not found' }, 404);
});
server.listen(PORT, () => {
console.log(`Lethean SWAP Service on :${PORT}`);
console.log(` POST /api/claim — submit SWAP claim`);
console.log(` GET /api/pool — pool status`);
console.log(` GET /api/claims — list claims (admin)`);
console.log(` POST /api/approve — approve claim (admin)`);
});