const http = require('http') const net = require('net') const crypto = require('crypto') const { checkPassword, createSession, isValidSession, } = require('./auth') const { listAddresses, addAddress, removeAddress, } = require('./addresses') const port = 8080 const fulcrumTimeoutMs = 3000 function queryFulcrum(fulcrumUrl, method, params = []) { return new Promise((resolve) => { if (!fulcrumUrl) { resolve({ ok: false, result: null, error: 'Fulcrum URL is not configured', }) return } let parsed try { parsed = new URL(fulcrumUrl) } catch { resolve({ ok: false, result: null, error: 'Invalid Fulcrum URL', }) return } if (parsed.protocol !== 'tcp:') { resolve({ ok: false, result: null, error: 'Fulcrum URL must use tcp://', }) return } 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 } const socket = net.createConnection({ host, port: fulcrumPort, }) let response = '' let settled = false const finish = (value) => { if (settled) return settled = true socket.destroy() resolve(value) } 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' console.log(`Sending Fulcrum request: ${request.trim()}`) socket.write(request) }) socket.on('data', (data) => { const chunk = data.toString() console.log(`Fulcrum response data: ${chunk.trim()}`) response += chunk const lines = response.split('\n') for (const line of lines) { if (!line.trim()) continue let message try { message = JSON.parse(line) } catch { 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 } if ( Object.prototype.hasOwnProperty.call( message, 'result', ) ) { finish({ ok: true, result: message.result, error: null, }) 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}`, ) if (!settled) { finish({ ok: false, result: null, error: 'Connection closed before a response was received', }) } }) }) } 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 { configured: Boolean(fulcrumUrl), connected: false, url: fulcrumUrl, serverVersion: null, blockchainHeight: null, error: version.error, } } const headers = await queryFulcrum( fulcrumUrl, 'blockchain.headers.subscribe', [], ) return { configured: Boolean(fulcrumUrl), connected: true, url: fulcrumUrl, serverVersion: version.result, blockchainHeight: headers.ok && headers.result ? 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) return { service: 'running', fulcrum, wallet: 'not configured', labels: 0, transactions: 0, lastScan: 'never', } } 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) => { body += chunk }) 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', }) return } const token = createSession() res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': 'munin_session=' + encodeURIComponent(token) + '; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400', }) res.end( JSON.stringify({ ok: true, }), ) } catch { sendJson(res, 400, { error: 'Invalid request', }) } return } if ( req.method === 'GET' && req.url === '/api/addresses' ) { if (!isValidSession(req)) { sendJson(res, 401, { error: 'Authentication required', }) return } 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( input.address, input.label, ) sendJson(res, 201, entry) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to add address' sendJson(res, 400, { error: message, }) } return } if ( req.method === 'DELETE' && 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) await removeAddress(input.address) sendJson(res, 200, { ok: true, }) } 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, ) sendJson(res, 500, { service: 'running', error: 'Failed to determine service status', }) } return } res.writeHead(404) res.end('Not found') }, ) server.listen(port, () => { console.log( `Status API listening on port ${port}`, ) })