161 lines
8.2 KiB
JavaScript
161 lines
8.2 KiB
JavaScript
'use strict';
|
|
// A narrow public bridge. It never receives, stores, or handles private keys.
|
|
const http = require('node:http');
|
|
const fs = require('node:fs');
|
|
const settings = JSON.parse(fs.readFileSync('/opt/nerbus-explorer/settings.json', 'utf8'));
|
|
const GENESIS = '0177ee4010f9617feb8e2956aa4dfb7a8df18b6e4f6cc3359bdb8cfef9d10bfd';
|
|
const MAX = 20900000000000000n;
|
|
const COIN = 100000000n;
|
|
let active = 0;
|
|
function failure(message, status = 400) { const e = Error(message); e.status = status; return e; }
|
|
function exactJson(text) {
|
|
// Preserve decimal coin amounts and integer atomic units without JS floating point.
|
|
return JSON.parse(text.replace(/("(?:\\.|[^"\\])*")|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
|
(whole, quoted, number) => quoted || JSON.stringify(number)));
|
|
}
|
|
function integer(value) {
|
|
if (!/^-?\d+$/.test(String(value))) throw failure('Invalid network amount.', 502);
|
|
const n = BigInt(value);
|
|
if (n < -MAX || n > MAX) throw failure('Network amount is outside the NRB range.', 502);
|
|
return n;
|
|
}
|
|
function coins(value) {
|
|
const s = String(value);
|
|
if (!/^\d+(\.\d{1,8})?$/.test(s)) throw failure('Invalid output value.', 502);
|
|
const [a, b = ''] = s.split('.'); return BigInt(a) * COIN + BigInt(b.padEnd(8, '0'));
|
|
}
|
|
function rpc(method, params = []) {
|
|
return new Promise((resolve, reject) => {
|
|
const data = Buffer.from(JSON.stringify({jsonrpc:'1.0', id:'nrb-wallet', method, params}));
|
|
const request = http.request({hostname:settings.wallet.host, port:settings.wallet.port, method:'POST',
|
|
auth:settings.wallet.username + ':' + settings.wallet.password,
|
|
headers:{'Content-Type':'application/json', 'Content-Length':data.length}}, response => {
|
|
const parts = []; let size = 0;
|
|
response.on('data', part => {
|
|
size += part.length;
|
|
if (size > 16 * 1024 * 1024) response.destroy(); else parts.push(part);
|
|
});
|
|
response.on('error', () => reject(failure('NRB network service is unavailable.', 503)));
|
|
response.on('end', () => {
|
|
try {
|
|
const result = exactJson(Buffer.concat(parts).toString('utf8'));
|
|
if (result.error) {
|
|
const e = failure('The NRB node could not complete this request.', 503);
|
|
e.rpcCode = Number(result.error.code);
|
|
e.rpcMessage = String(result.error.message || '');
|
|
reject(e);
|
|
} else resolve(result.result);
|
|
} catch (_) { reject(failure('Invalid NRB network response.', 502)); }
|
|
});
|
|
});
|
|
request.setTimeout(12000, () => request.destroy());
|
|
request.on('error', () => reject(failure('NRB network is unavailable. Receiving addresses can still be created locally.', 503)));
|
|
request.end(data);
|
|
});
|
|
}
|
|
function addresses(value) {
|
|
if (!Array.isArray(value) || value.length < 1 || value.length > 1000 ||
|
|
value.some(a => typeof a !== 'string' || !/^[1-9A-HJ-NP-Za-km-z]{26,36}$/.test(a)))
|
|
throw failure('Invalid address list.');
|
|
return [...new Set(value)];
|
|
}
|
|
async function chainStatus() {
|
|
const hash = await rpc('getblockhash', [0]);
|
|
if (hash !== GENESIS) throw failure('Network identity does not match NRB.', 503);
|
|
const [height, peers] = await Promise.all([rpc('getblockcount'), rpc('getconnectioncount')]);
|
|
let ready = false;
|
|
if (Number(height) > 0 && Number(peers) > 0) {
|
|
const tipHash = await rpc('getblockhash', [Number(height)]);
|
|
const tip = await rpc('getblock', [tipHash]);
|
|
ready = Math.abs(Date.now() / 1000 - Number(tip.time)) < 1800;
|
|
}
|
|
return {genesis:GENESIS, height:Number(height), ready};
|
|
}
|
|
async function account(request, state) {
|
|
const owned = addresses(request.addresses);
|
|
let balance, transactions;
|
|
try {
|
|
[balance, transactions] = await Promise.all([
|
|
rpc('getaddressbalance', [{addresses:owned}]), rpc('getaddresstxids', [{addresses:owned}])]);
|
|
} catch (e) {
|
|
if (/addressindex|No information available/.test(e.rpcMessage || ''))
|
|
throw failure('Address history is not available yet. The network index must finish syncing.', 503);
|
|
throw e;
|
|
}
|
|
return {...state, balance:integer(balance.balance).toString(), transactions:transactions.slice(-30).reverse()};
|
|
}
|
|
async function inputs(request, state) {
|
|
if (!state.ready) throw failure('The NRB network is not ready for payments.', 503);
|
|
const owned = addresses(request.addresses);
|
|
const target = integer(request.target);
|
|
if (target <= 0n) throw failure('Invalid payment amount.');
|
|
const outputs = await rpc('getaddressutxos', [{addresses:owned}]);
|
|
if (!Array.isArray(outputs)) throw failure('Unexpected network output list.', 502);
|
|
const candidates = outputs.filter(o => owned.includes(o.address) && Number(o.height) > 0 &&
|
|
typeof o.script === 'string' && /^76a914[0-9a-f]{40}88ac$/.test(o.script) && integer(o.satoshis) > 0n)
|
|
.sort((a, b) => integer(a.satoshis) > integer(b.satoshis) ? -1 : 1);
|
|
const selected = []; let total = 0n; let inspected = 0;
|
|
for (const output of candidates) {
|
|
if (selected.length >= 100 || inspected++ >= 200) break;
|
|
const unspent = await rpc('gettxout', [output.txid, Number(output.outputIndex), true]);
|
|
if (!unspent || Number(unspent.confirmations) < 1 ||
|
|
(unspent.coinbase && Number(unspent.confirmations) < 150)) continue;
|
|
const value = integer(output.satoshis);
|
|
if (coins(unspent.value) !== value || unspent.scriptPubKey.hex !== output.script) continue;
|
|
const raw = await rpc('getrawtransaction', [output.txid, 0]);
|
|
selected.push({txid:output.txid, vout:Number(output.outputIndex), satoshis:value.toString(), raw});
|
|
total += value;
|
|
if (total >= target) break;
|
|
}
|
|
if (total < target) throw failure('Insufficient spendable funds. Unconfirmed or immature outputs cannot be spent.');
|
|
return {...state, inputs:selected};
|
|
}
|
|
async function handle(request) {
|
|
if (!request || typeof request !== 'object' || Array.isArray(request)) throw failure('Invalid request.');
|
|
if (!['status', 'account', 'inputs', 'broadcast'].includes(request.action)) throw failure('Unknown operation.');
|
|
// Reject any accidental secret-bearing field instead of forwarding arbitrary RPC.
|
|
const fields = {status:['action'], account:['action','addresses'], inputs:['action','addresses','target'], broadcast:['action','hex']};
|
|
if (Object.keys(request).some(k => !fields[request.action].includes(k))) throw failure('Unexpected request field.');
|
|
const state = await chainStatus();
|
|
if (request.action === 'status') return state;
|
|
if (request.action === 'account') return account(request, state);
|
|
if (request.action === 'inputs') return inputs(request, state);
|
|
if (!state.ready) throw failure('The NRB network is not ready for payments.', 503);
|
|
if (typeof request.hex !== 'string' || request.hex.length < 20 || request.hex.length > 400000 ||
|
|
request.hex.length % 2 || !/^[a-f0-9]+$/i.test(request.hex)) throw failure('Invalid signed transaction.');
|
|
try { return {...state, txid:await rpc('sendrawtransaction', [request.hex, false])}; }
|
|
catch (e) {
|
|
if (e.rpcCode === -26 || e.rpcCode === -25 || e.rpcCode === -22)
|
|
throw failure('The network rejected the transaction. Check its status before sending again.');
|
|
throw e;
|
|
}
|
|
}
|
|
const server = http.createServer(async (req, res) => {
|
|
res.setHeader('Content-Type', 'application/json'); res.setHeader('Cache-Control', 'no-store');
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
if (req.method !== 'POST' || req.url !== '/wallet/v1') {
|
|
res.writeHead(404); res.end(JSON.stringify({error:'Not found'})); return;
|
|
}
|
|
if (active >= 8) { res.writeHead(429); res.end(JSON.stringify({error:'Please try again shortly.'})); return; }
|
|
active++;
|
|
try {
|
|
const parts = []; let size = 0;
|
|
for await (const chunk of req) {
|
|
size += chunk.length;
|
|
if (size > 512 * 1024) throw failure('Request is too large.', 413);
|
|
parts.push(chunk);
|
|
}
|
|
let body;
|
|
try { body = JSON.parse(Buffer.concat(parts).toString('utf8')); }
|
|
catch (_) { throw failure('Invalid request JSON.'); }
|
|
const result = await handle(body);
|
|
res.end(JSON.stringify(result));
|
|
} catch (e) {
|
|
res.writeHead(e.status || 500);
|
|
res.end(JSON.stringify({genesis:GENESIS, error:e.status ? e.message : 'Wallet network service failed.'}));
|
|
} finally { active--; }
|
|
});
|
|
server.requestTimeout = 30000;
|
|
server.headersTimeout = 15000;
|
|
server.listen(3002, '127.0.0.1');
|