const http = require('http') const net = require('net') const { checkPassword, createSession, isValidSession, } = require('./auth') const { listAddresses, addAddress, removeAddress, } = require('./addresses') const { addressToScriptPubKey, scriptPubKeyToScripthash, addressToScripthash, } = require('./bitcoin-address') const { getTransaction, } = require('./transactions') 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', }) } }) }) } 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', } } async function getAddressData(address) { let scripthash 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 : [], } } 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) => { body += chunk }) req.on('end', () => { try { const input = JSON.parse(body) if (!checkPassword(input.password)) { res.writeHead(401, { 'Content-Type': 'application/json', }) res.end( JSON.stringify({ 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 { 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 } 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() 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 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 } 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 } let body = '' req.on('data', (chunk) => { body += chunk }) req.on('end', async () => { try { const input = JSON.parse(body) const entry = await addAddress( input.address, input.label, ) 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 } let body = '' req.on('data', (chunk) => { body += chunk }) req.on('end', async () => { try { const input = JSON.parse(body) await removeAddress(input.address) res.writeHead(200, { 'Content-Type': 'application/json', }) res.end( JSON.stringify({ ok: true, }), ) } catch (error) { res.writeHead(400, { 'Content-Type': 'application/json', }) res.end( JSON.stringify({ error: error instanceof Error ? error.message : 'Failed to remove address', }), ) } }) return } if ( req.method === 'GET' && req.url.startsWith('/api/transactions/') ) { if (!isValidSession(req)) { res.writeHead(401, { 'Content-Type': 'application/json', }) res.end( JSON.stringify({ error: 'Authentication required', }), ) return } const txid = decodeURIComponent( req.url.slice('/api/transactions/'.length), ) try { const transaction = await getTransaction(txid) res.writeHead(200, { 'Content-Type': 'application/json', }) res.end(JSON.stringify(transaction)) } catch (error) { res.writeHead(400, { 'Content-Type': 'application/json', }) res.end( JSON.stringify({ error: error instanceof Error ? error.message : 'Failed to get transaction', }), ) } return } res.writeHead(404) res.end('Not found') }) server.listen(port, () => { console.log( `Status API listening on port ${port}`, ) })