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