263 lines
9.9 KiB
JavaScript
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)`);
|
|
});
|