From f702b4f36789f2e2dc3c677514c089a10be1841f Mon Sep 17 00:00:00 2001 From: Unheard0840 Date: Sat, 29 Aug 2026 11:24:25 +0000 Subject: [PATCH] Update server/status.js Use shared Bitcoin address module --- server/status.js | 854 +++++++++++++++++++---------------------------- 1 file changed, 348 insertions(+), 506 deletions(-) diff --git a/server/status.js b/server/status.js index 2111e96..d3a28de 100644 --- a/server/status.js +++ b/server/status.js @@ -1,6 +1,5 @@ const http = require('http') const net = require('net') -const crypto = require('crypto') const { checkPassword, createSession, @@ -11,6 +10,11 @@ const { addAddress, removeAddress, } = require('./addresses') +const { + addressToScriptPubKey, + scriptPubKeyToScripthash, + addressToScripthash, +} = require('./bitcoin-address') const port = 8080 const fulcrumTimeoutMs = 3000 @@ -189,323 +193,7 @@ function queryFulcrum(fulcrumUrl, method, params = []) { }) } -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, -) { +async function getFulcrumStatus(fulcrumUrl) { const version = await queryFulcrum( fulcrumUrl, 'server.version', @@ -543,11 +231,8 @@ async function getFulcrumStatus( } 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', @@ -559,20 +244,114 @@ async function getStatus() { } } -function sendJson( - res, - statusCode, - body, -) { - res.writeHead(statusCode, { - 'Content-Type': 'application/json', - }) +async function getAddressData(address) { + let scripthash - res.end(JSON.stringify(body)) + try { + scripthash = addressToScripthash(address) + } catch (error) { + throw new Error( + error instanceof Error + ? error.message + : 'Invalid Bitcoin address', + ) + } + + const fulcrumUrl = process.env.MUNIN_FULCRUM_URL || null + + if (!fulcrumUrl) { + throw new Error('Fulcrum URL is not configured') + } + + const balance = await queryFulcrum( + fulcrumUrl, + 'blockchain.scripthash.get_balance', + [scripthash], + ) + + if (!balance.ok) { + throw new Error( + `Failed to query address balance: ${JSON.stringify(balance.error)}`, + ) + } + + const utxos = await queryFulcrum( + fulcrumUrl, + 'blockchain.scripthash.listunspent', + [scripthash], + ) + + if (!utxos.ok) { + throw new Error( + `Failed to query address UTXOs: ${JSON.stringify(utxos.error)}`, + ) + } + + const history = await queryFulcrum( + fulcrumUrl, + 'blockchain.scripthash.get_history', + [scripthash], + ) + + if (!history.ok) { + throw new Error( + `Failed to query address history: ${JSON.stringify(history.error)}`, + ) + } + + return { + address, + scripthash, + confirmedBalance: + Number(balance.result?.confirmed || 0), + unconfirmedBalance: + Number(balance.result?.unconfirmed || 0), + utxos: Array.isArray(utxos.result) + ? utxos.result + : [], + history: Array.isArray(history.result) + ? history.result + : [], + } } -function readRequestBody(req) { - return new Promise((resolve, reject) => { +async function getAddressesWithData() { + const addresses = await listAddresses() + const results = [] + + for (const entry of addresses) { + try { + const data = await getAddressData(entry.address) + + results.push({ + ...entry, + scripthash: data.scripthash, + confirmedBalance: data.confirmedBalance, + unconfirmedBalance: data.unconfirmedBalance, + utxos: data.utxos, + history: data.history, + error: null, + }) + } catch (error) { + results.push({ + ...entry, + confirmedBalance: null, + unconfirmedBalance: null, + utxos: [], + history: [], + error: + error instanceof Error + ? error.message + : 'Failed to query address', + }) + } + } + + return results +} + +const server = http.createServer(async (req, res) => { + if (req.method === 'POST' && req.url === '/api/login') { let body = '' req.on('data', (chunk) => { @@ -580,29 +359,20 @@ function readRequestBody(req) { }) 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)) { - sendJson(res, 401, { - error: 'Invalid password', + res.writeHead(401, { + 'Content-Type': 'application/json', }) + + res.end( + JSON.stringify({ + error: 'Invalid password', + }), + ) + return } @@ -622,118 +392,188 @@ const server = http.createServer( }), ) } catch { - sendJson(res, 400, { - error: 'Invalid request', + res.writeHead(400, { + 'Content-Type': 'application/json', }) + + res.end( + JSON.stringify({ + error: 'Invalid request', + }), + ) } + }) + + return + } + + if (req.url === '/api/status') { + if (!isValidSession(req)) { + res.writeHead(401, { + 'Content-Type': 'application/json', + }) + + res.end( + JSON.stringify({ + error: 'Authentication required', + }), + ) return } - if ( - req.method === 'GET' && - req.url === '/api/addresses' - ) { - if (!isValidSession(req)) { - sendJson(res, 401, { + try { + const status = await getStatus() + + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + + res.end(JSON.stringify(status)) + } catch (error) { + console.error( + 'Failed to get service status:', + error, + ) + + res.writeHead(500, { + 'Content-Type': 'application/json', + }) + + res.end( + JSON.stringify({ + service: 'running', + error: 'Failed to determine service status', + }), + ) + } + + 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() + return + } - sendJson(res, 200, { + try { + const addresses = await listAddresses() + + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + + res.end( + JSON.stringify({ addresses, - }) - } catch (error) { - console.error( - 'Failed to list addresses:', - error, - ) + }), + ) + } catch (error) { + console.error( + 'Failed to list addresses:', + error, + ) - sendJson(res, 500, { - error: - 'Failed to read address watchlist', - }) - } + res.writeHead(500, { + 'Content-Type': 'application/json', + }) + + res.end( + JSON.stringify({ + error: 'Failed to list addresses', + }), + ) + } + + return + } + + if ( + req.method === 'GET' && + req.url === '/api/addresses/data' + ) { + if (!isValidSession(req)) { + res.writeHead(401, { + 'Content-Type': 'application/json', + }) + + res.end( + JSON.stringify({ + error: 'Authentication required', + }), + ) return } - if ( - req.method === 'GET' && - req.url === '/api/addresses/data' - ) { - if (!isValidSession(req)) { - sendJson(res, 401, { + try { + const addresses = await getAddressesWithData() + + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + + res.end( + JSON.stringify({ + addresses, + }), + ) + } catch (error) { + console.error( + 'Failed to get address data:', + error, + ) + + res.writeHead(500, { + 'Content-Type': 'application/json', + }) + + res.end( + JSON.stringify({ + error: 'Failed to get address data', + }), + ) + } + + 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 - } - - 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 - } + let body = '' + req.on('data', (chunk) => { + body += chunk + }) + + req.on('end', async () => { try { - const body = - await readRequestBody(req) - const input = JSON.parse(body) const entry = await addAddress( @@ -741,89 +581,91 @@ const server = http.createServer( input.label, ) - sendJson(res, 201, entry) - } catch (error) { - const message = - error instanceof Error - ? error.message - : 'Failed to add address' - - sendJson(res, 400, { - error: message, + res.writeHead(201, { + 'Content-Type': 'application/json', }) + + res.end(JSON.stringify(entry)) + } catch (error) { + res.writeHead(400, { + 'Content-Type': 'application/json', + }) + + res.end( + JSON.stringify({ + error: + error instanceof Error + ? error.message + : 'Failed to add address', + }), + ) } + }) + + 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 } - if ( - req.method === 'DELETE' && - req.url === '/api/addresses' - ) { - if (!isValidSession(req)) { - sendJson(res, 401, { - error: 'Authentication required', - }) - return - } + let body = '' + 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) - sendJson(res, 200, { - ok: true, + res.writeHead(200, { + 'Content-Type': 'application/json', }) - } catch (error) { - const message = - error instanceof Error - ? error.message - : 'Failed to remove address' - sendJson(res, 400, { - error: message, - }) - } - - return - } - - if (req.url === '/api/status') { - if (!isValidSession(req)) { - sendJson(res, 401, { - error: 'Authentication required', - }) - return - } - - try { - const status = await getStatus() - - sendJson(res, 200, status) - } catch (error) { - console.error( - 'Failed to get service status:', - error, + res.end( + JSON.stringify({ + ok: true, + }), ) - - sendJson(res, 500, { - service: 'running', - error: - 'Failed to determine service status', + } catch (error) { + res.writeHead(400, { + 'Content-Type': 'application/json', }) + + res.end( + JSON.stringify({ + error: + error instanceof Error + ? error.message + : 'Failed to remove address', + }), + ) } + }) - return - } + return + } - res.writeHead(404) - res.end('Not found') - }, -) + res.writeHead(404) + res.end('Not found') +}) server.listen(port, () => { console.log(