Files
nerbus-wallet/server/wallet-api.js
T

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');