From 6e36b3d16d4f281e4d033bd37dee2ed8f46f8cef Mon Sep 17 00:00:00 2001 From: Unheard0840 Date: Sat, 29 Aug 2026 10:45:13 +0000 Subject: [PATCH] Update server/status.js Add Fulcrum address data queries --- server/status.js | 809 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 605 insertions(+), 204 deletions(-) diff --git a/server/status.js b/server/status.js index f15d516..2111e96 100644 --- a/server/status.js +++ b/server/status.js @@ -1,18 +1,19 @@ -const http = require('http'); -const net = require('net'); +const http = require('http') +const net = require('net') +const crypto = require('crypto') const { checkPassword, createSession, isValidSession, -} = require('./auth'); +} = require('./auth') const { listAddresses, addAddress, removeAddress, -} = require('./addresses'); +} = require('./addresses') -const port = 8080; -const fulcrumTimeoutMs = 3000; +const port = 8080 +const fulcrumTimeoutMs = 3000 function queryFulcrum(fulcrumUrl, method, params = []) { return new Promise((resolve) => { @@ -21,21 +22,21 @@ function queryFulcrum(fulcrumUrl, method, params = []) { ok: false, result: null, error: 'Fulcrum URL is not configured', - }); - return; + }) + return } - let parsed; + let parsed try { - parsed = new URL(fulcrumUrl); + parsed = new URL(fulcrumUrl) } catch { resolve({ ok: false, result: null, error: 'Invalid Fulcrum URL', - }); - return; + }) + return } if (parsed.protocol !== 'tcp:') { @@ -43,149 +44,473 @@ function queryFulcrum(fulcrumUrl, method, params = []) { ok: false, result: null, error: 'Fulcrum URL must use tcp://', - }); - return; + }) + return } - const host = parsed.hostname; - const fulcrumPort = Number(parsed.port); + const host = parsed.hostname + const fulcrumPort = Number(parsed.port) if (!host || !fulcrumPort) { resolve({ ok: false, result: null, error: 'Fulcrum URL is missing host or port', - }); - return; + }) + return } const socket = net.createConnection({ host, port: fulcrumPort, - }); + }) - let response = ''; - let settled = false; + let response = '' + let settled = false const finish = (value) => { - if (settled) return; - settled = true; + if (settled) return - socket.destroy(); - resolve(value); - }; + settled = true + socket.destroy() + resolve(value) + } - socket.setTimeout(fulcrumTimeoutMs); + socket.setTimeout(fulcrumTimeoutMs) socket.on('connect', () => { console.log( `Fulcrum connected for ${method} (${host}:${fulcrumPort})`, - ); + ) - const request = JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method, - params, - }) + '\n'; + const request = + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params, + }) + '\n' - console.log(`Sending Fulcrum request: ${request.trim()}`); + console.log(`Sending Fulcrum request: ${request.trim()}`) - socket.write(request); - }); + socket.write(request) + }) socket.on('data', (data) => { - const chunk = data.toString(); + const chunk = data.toString() - console.log(`Fulcrum response data: ${chunk.trim()}`); + console.log(`Fulcrum response data: ${chunk.trim()}`) - response += chunk; + response += chunk - const lines = response.split('\n'); + const lines = response.split('\n') for (const line of lines) { - if (!line.trim()) continue; + if (!line.trim()) continue - let message; + let message try { - message = JSON.parse(line); + message = JSON.parse(line) } catch { - continue; + continue } if (message.error) { console.error( `Fulcrum returned an error for ${method}:`, JSON.stringify(message.error), - ); + ) finish({ ok: false, result: null, error: message.error, - }); + }) - return; + return } - if (Object.prototype.hasOwnProperty.call(message, 'result')) { + if ( + Object.prototype.hasOwnProperty.call( + message, + 'result', + ) + ) { finish({ ok: true, result: message.result, error: null, - }); + }) - return; + return } } - }); + }) socket.on('timeout', () => { console.error( `Fulcrum request timed out after ${fulcrumTimeoutMs}ms: ${method}`, - ); + ) finish({ ok: false, result: null, error: 'Connection timed out', - }); - }); + }) + }) socket.on('error', (error) => { console.error( `Fulcrum socket error for ${method}:`, error.message, - ); + ) finish({ ok: false, result: null, error: error.message, - }); - }); + }) + }) socket.on('close', (hadError) => { console.log( `Fulcrum socket closed for ${method}; hadError=${hadError}`, - ); + ) - finish({ - ok: false, - result: null, - error: 'Connection closed before a response was received', - }); - }); - }); + if (!settled) { + finish({ + ok: false, + result: null, + error: 'Connection closed before a response was received', + }) + } + }) + }) } -async function getFulcrumStatus(fulcrumUrl) { +const BECH32_CHARSET = + 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' + +function bech32Polymod(values) { + const generators = [ + 0x3b6a57b2, + 0x26508e6d, + 0x1ea119fa, + 0x3d4233dd, + 0x2a1462b3, + ] + + let chk = 1 + + for (const value of values) { + const top = chk >>> 25 + chk = ((chk & 0x1ffffff) << 5) ^ value + + for (let i = 0; i < 5; i++) { + if ((top >>> i) & 1) { + chk ^= generators[i] + } + } + } + + return chk >>> 0 +} + +function bech32HrpExpand(hrp) { + const values = [] + + for (const char of hrp) { + values.push(char.charCodeAt(0) >> 5) + } + + values.push(0) + + for (const char of hrp) { + values.push(char.charCodeAt(0) & 31) + } + + return values +} + +function bech32VerifyChecksum(hrp, data) { + const polymod = bech32Polymod([ + ...bech32HrpExpand(hrp), + ...data, + ]) + + return polymod === 1 || polymod === 0x2bc830a3 +} + +function bech32Decode(address) { + if (typeof address !== 'string') { + throw new Error('Bitcoin address must be a string') + } + + if (address.length < 8 || address.length > 90) { + throw new Error('Invalid Bitcoin address length') + } + + const hasLower = address !== address.toUpperCase() + const hasUpper = address !== address.toLowerCase() + + if (hasLower && hasUpper) { + throw new Error('Bitcoin address must not mix uppercase and lowercase') + } + + const normalized = address.toLowerCase() + const separator = normalized.lastIndexOf('1') + + if (separator < 1 || separator + 7 > normalized.length) { + throw new Error('Invalid Bech32 separator') + } + + const hrp = normalized.slice(0, separator) + const dataPart = normalized.slice(separator + 1) + + const data = [] + + for (const char of dataPart) { + const value = BECH32_CHARSET.indexOf(char) + + if (value === -1) { + throw new Error('Invalid Bech32 character') + } + + data.push(value) + } + + if (!bech32VerifyChecksum(hrp, data)) { + throw new Error('Invalid Bitcoin address checksum') + } + + return { + hrp, + data: data.slice(0, -6), + spec: + bech32Polymod([ + ...bech32HrpExpand(hrp), + ...data, + ]) === 1 + ? 'bech32' + : 'bech32m', + } +} + +function convertBits(data, fromBits, toBits, pad) { + let accumulator = 0 + let bits = 0 + const result = [] + const maxValue = (1 << toBits) - 1 + + for (const value of data) { + if (value < 0 || value >> fromBits !== 0) { + throw new Error('Invalid bit conversion input') + } + + accumulator = + (accumulator << fromBits) | value + bits += fromBits + + while (bits >= toBits) { + bits -= toBits + result.push( + (accumulator >> bits) & maxValue, + ) + } + } + + if (pad) { + if (bits > 0) { + result.push( + (accumulator << (toBits - bits)) & maxValue, + ) + } + } else { + if (bits >= fromBits) { + throw new Error('Invalid padding') + } + + if ( + ((accumulator << (toBits - bits)) & + maxValue) !== + 0 + ) { + throw new Error('Non-zero padding') + } + } + + return result +} + +function addressToScriptPubKey(address) { + const decoded = bech32Decode(address) + + if (decoded.hrp !== 'bc') { + throw new Error( + 'Only mainnet Bitcoin addresses are supported', + ) + } + + if (decoded.data.length < 1) { + throw new Error('Invalid SegWit address') + } + + const witnessVersion = decoded.data[0] + + if (witnessVersion > 16) { + throw new Error('Invalid SegWit witness version') + } + + if ( + witnessVersion === 0 && + decoded.spec !== 'bech32' + ) { + throw new Error( + 'Witness version 0 must use Bech32', + ) + } + + if ( + witnessVersion !== 0 && + decoded.spec !== 'bech32m' + ) { + throw new Error( + 'Witness version 1+ must use Bech32m', + ) + } + + const program = convertBits( + decoded.data.slice(1), + 5, + 8, + false, + ) + + if (program.length < 2 || program.length > 40) { + throw new Error('Invalid witness program length') + } + + if ( + witnessVersion === 0 && + program.length !== 20 && + program.length !== 32 + ) { + throw new Error( + 'Invalid witness version 0 program length', + ) + } + + const versionOpcode = + witnessVersion === 0 + ? 0x00 + : 0x50 + witnessVersion + + const script = Buffer.from([ + versionOpcode, + program.length, + ...program, + ]) + + return script +} + +function scriptPubKeyToScripthash(scriptPubKey) { + const hash = crypto + .createHash('sha256') + .update(scriptPubKey) + .digest() + + return Buffer.from(hash) + .reverse() + .toString('hex') +} + +function addressToScripthash(address) { + const scriptPubKey = + addressToScriptPubKey(address) + + return scriptPubKeyToScripthash(scriptPubKey) +} + +async function getAddressData( + fulcrumUrl, + address, +) { + let scripthash + + try { + scripthash = addressToScripthash(address) + } catch (error) { + return { + address, + balance: null, + utxos: [], + history: [], + error: + error instanceof Error + ? error.message + : 'Invalid Bitcoin address', + } + } + + const balance = await queryFulcrum( + fulcrumUrl, + 'blockchain.scripthash.get_balance', + [scripthash], + ) + + const utxos = await queryFulcrum( + fulcrumUrl, + 'blockchain.scripthash.listunspent', + [scripthash], + ) + + const history = await queryFulcrum( + fulcrumUrl, + 'blockchain.scripthash.get_history', + [scripthash], + ) + + return { + address, + scripthash, + balance: balance.ok + ? balance.result + : null, + utxos: utxos.ok + ? utxos.result + : [], + history: history.ok + ? history.result + : [], + error: + balance.ok && + utxos.ok && + history.ok + ? null + : { + balance: balance.ok + ? null + : balance.error, + utxos: utxos.ok + ? null + : utxos.error, + history: history.ok + ? null + : history.error, + }, + } +} + +async function getFulcrumStatus( + fulcrumUrl, +) { const version = await queryFulcrum( fulcrumUrl, 'server.version', ['Munin Bitcoin', '1.4'], - ); + ) if (!version.ok) { return { @@ -195,14 +520,14 @@ async function getFulcrumStatus(fulcrumUrl) { serverVersion: null, blockchainHeight: null, error: version.error, - }; + } } const headers = await queryFulcrum( fulcrumUrl, 'blockchain.headers.subscribe', [], - ); + ) return { configured: Boolean(fulcrumUrl), @@ -214,12 +539,15 @@ async function getFulcrumStatus(fulcrumUrl) { ? headers.result.height ?? null : null, error: headers.ok ? null : headers.error, - }; + } } async function getStatus() { - const fulcrumUrl = process.env.MUNIN_FULCRUM_URL || null; - const fulcrum = await getFulcrumStatus(fulcrumUrl); + const fulcrumUrl = + process.env.MUNIN_FULCRUM_URL || null + + const fulcrum = + await getFulcrumStatus(fulcrumUrl) return { service: 'running', @@ -228,11 +556,23 @@ async function getStatus() { labels: 0, transactions: 0, lastScan: 'never', - }; + } } -const server = http.createServer(async (req, res) => { - if (req.method === 'POST' && req.url === '/api/login') { +function sendJson( + res, + statusCode, + body, +) { + res.writeHead(statusCode, { + 'Content-Type': 'application/json', + }) + + res.end(JSON.stringify(body)) +} + +function readRequestBody(req) { + return new Promise((resolve, reject) => { let body = '' req.on('data', (chunk) => { @@ -240,14 +580,29 @@ const server = http.createServer(async (req, res) => { }) req.on('end', () => { + resolve(body) + }) + + req.on('error', reject) + }) +} + +const server = http.createServer( + async (req, res) => { + if ( + req.method === 'POST' && + req.url === '/api/login' + ) { try { + const body = + await readRequestBody(req) + const input = JSON.parse(body) if (!checkPassword(input.password)) { - res.writeHead(401, { - 'Content-Type': 'application/json', + sendJson(res, 401, { + error: 'Invalid password', }) - res.end(JSON.stringify({ error: 'Invalid password' })) return } @@ -255,70 +610,130 @@ const server = http.createServer(async (req, res) => { res.writeHead(200, { 'Content-Type': 'application/json', - 'Set-Cookie': 'munin_session=' + encodeURIComponent(token) + + 'Set-Cookie': + 'munin_session=' + + encodeURIComponent(token) + '; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400', }) - res.end(JSON.stringify({ ok: true })) + + res.end( + JSON.stringify({ + ok: true, + }), + ) } catch { - res.writeHead(400, { - 'Content-Type': 'application/json', + sendJson(res, 400, { + error: 'Invalid request', }) - res.end(JSON.stringify({ error: 'Invalid request' })) } - }) - return - } - - if (req.method === 'GET' && req.url === '/api/addresses') { - if (!isValidSession(req)) { - res.writeHead(401, { - 'Content-Type': 'application/json', - }) - res.end(JSON.stringify({ error: 'Authentication required' })) return } - try { - const addresses = await listAddresses() + if ( + req.method === 'GET' && + req.url === '/api/addresses' + ) { + if (!isValidSession(req)) { + sendJson(res, 401, { + error: 'Authentication required', + }) + return + } - res.writeHead(200, { - 'Content-Type': 'application/json', - }) - - res.end(JSON.stringify({ addresses })) - } catch (error) { - console.error('Failed to list addresses:', error) - - res.writeHead(500, { - 'Content-Type': 'application/json', - }) - - res.end(JSON.stringify({ - error: 'Failed to read address watchlist', - })) - } - - return - } - - if (req.method === 'POST' && req.url === '/api/addresses') { - if (!isValidSession(req)) { - res.writeHead(401, { - 'Content-Type': 'application/json', - }) - res.end(JSON.stringify({ error: 'Authentication required' })) - return - } - - let body = '' - - req.on('data', (chunk) => { - body += chunk - }) - - req.on('end', async () => { try { + const addresses = + await listAddresses() + + sendJson(res, 200, { + addresses, + }) + } catch (error) { + console.error( + 'Failed to list addresses:', + error, + ) + + sendJson(res, 500, { + error: + 'Failed to read address watchlist', + }) + } + + return + } + + if ( + req.method === 'GET' && + req.url === '/api/addresses/data' + ) { + if (!isValidSession(req)) { + sendJson(res, 401, { + error: 'Authentication required', + }) + return + } + + try { + const fulcrumUrl = + process.env.MUNIN_FULCRUM_URL || + null + + if (!fulcrumUrl) { + sendJson(res, 503, { + error: + 'Fulcrum URL is not configured', + }) + return + } + + const addresses = + await listAddresses() + + const results = [] + + for (const entry of addresses) { + results.push( + await getAddressData( + fulcrumUrl, + entry.address, + ), + ) + } + + sendJson(res, 200, { + addresses: results, + }) + } catch (error) { + console.error( + 'Failed to get address data:', + error, + ) + + sendJson(res, 500, { + error: + 'Failed to retrieve address data', + }) + } + + return + } + + if ( + req.method === 'POST' && + req.url === '/api/addresses' + ) { + if (!isValidSession(req)) { + sendJson(res, 401, { + error: 'Authentication required', + }) + return + } + + try { + const body = + await readRequestBody(req) + const input = JSON.parse(body) const entry = await addAddress( @@ -326,106 +741,92 @@ const server = http.createServer(async (req, res) => { input.label, ) - res.writeHead(201, { - 'Content-Type': 'application/json', - }) - - res.end(JSON.stringify(entry)) + sendJson(res, 201, entry) } catch (error) { - const message = error instanceof Error - ? error.message - : 'Failed to add address' + const message = + error instanceof Error + ? error.message + : 'Failed to add address' - res.writeHead(400, { - 'Content-Type': 'application/json', + sendJson(res, 400, { + error: message, }) - - res.end(JSON.stringify({ error: message })) } - }) - return - } - - if (req.method === 'DELETE' && req.url === '/api/addresses') { - if (!isValidSession(req)) { - res.writeHead(401, { - 'Content-Type': 'application/json', - }) - res.end(JSON.stringify({ error: 'Authentication required' })) return } - let body = '' + if ( + req.method === 'DELETE' && + req.url === '/api/addresses' + ) { + if (!isValidSession(req)) { + sendJson(res, 401, { + error: 'Authentication required', + }) + return + } - req.on('data', (chunk) => { - body += chunk - }) - - req.on('end', async () => { try { + const body = + await readRequestBody(req) + const input = JSON.parse(body) await removeAddress(input.address) - res.writeHead(200, { - 'Content-Type': 'application/json', + sendJson(res, 200, { + ok: true, }) - - res.end(JSON.stringify({ ok: true })) } catch (error) { - const message = error instanceof Error - ? error.message - : 'Failed to remove address' + const message = + error instanceof Error + ? error.message + : 'Failed to remove address' - res.writeHead(400, { - 'Content-Type': 'application/json', + sendJson(res, 400, { + error: message, }) - - res.end(JSON.stringify({ error: message })) } - }) - return - } - - if (req.url === '/api/status') { - if (!isValidSession(req)) { - res.writeHead(401, { - 'Content-Type': 'application/json', - }) - res.end(JSON.stringify({ error: 'Authentication required' })) return } - try { - const status = await getStatus() + if (req.url === '/api/status') { + if (!isValidSession(req)) { + sendJson(res, 401, { + error: 'Authentication required', + }) + return + } - res.writeHead(200, { - 'Content-Type': 'application/json', - }) + try { + const status = await getStatus() - res.end(JSON.stringify(status)) - } catch (error) { - console.error('Failed to get service status:', error) + sendJson(res, 200, status) + } catch (error) { + console.error( + 'Failed to get service status:', + error, + ) - res.writeHead(500, { - 'Content-Type': 'application/json', - }) + sendJson(res, 500, { + service: 'running', + error: + 'Failed to determine service status', + }) + } - res.end(JSON.stringify({ - service: 'running', - error: 'Failed to determine service status', - })) + return } - return - } - - res.writeHead(404) - res.end('Not found') -}) + res.writeHead(404) + res.end('Not found') + }, +) server.listen(port, () => { - console.log(`Status API listening on port ${port}`); -}); \ No newline at end of file + console.log( + `Status API listening on port ${port}`, + ) +}) \ No newline at end of file