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 const fs = require('fs') const path = require('path') const addressCacheDir = '/data/cache/addresses' const addressCacheMaxAgeMs = 60000 const addressRefreshes = new Map() function ensureAddressCacheDir() { fs.mkdirSync(addressCacheDir, { recursive: true, }) } function getAddressCachePath(scripthash) { return path.join( addressCacheDir, `${scripthash}.json`, ) } function readAddressCache(scripthash) { try { const cachePath = getAddressCachePath(scripthash) const stat = fs.statSync(cachePath) const data = JSON.parse( fs.readFileSync( cachePath, 'utf8', ), ) if ( !data || typeof data !== 'object' || !data.address ) { return null } return { data, ageMs: Math.max( 0, Date.now() - stat.mtimeMs, ), } } catch { return null } } function writeAddressCache(data) { try { ensureAddressCacheDir() const cachePath = getAddressCachePath( data.scripthash, ) const tempPath = `${cachePath}.${process.pid}.tmp` fs.writeFileSync( tempPath, JSON.stringify(data), 'utf8', ) fs.renameSync( tempPath, cachePath, ) } catch (error) { console.error( 'Failed to write address cache:', error, ) } } function refreshAddressCache( address, scripthash, ) { const existing = addressRefreshes.get(scripthash) if (existing) { return existing } const refresh = getAddressDataFromFulcrum( address, scripthash, ) .then((data) => { writeAddressCache(data) return data }) .catch((error) => { console.error( `Failed to refresh address ${address}:`, error, ) throw error }) .finally(() => { addressRefreshes.delete( scripthash, ) }) addressRefreshes.set( scripthash, refresh, ) return refresh } 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 getAddressDataFromFulcrum( address, scripthash, ) { 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 : [], lastSynced: new Date().toISOString(), } } async function getAddressData( address, ) { let scripthash try { scripthash = addressToScripthash( address, ) } catch (error) { throw new Error( error instanceof Error ? error.message : 'Invalid Bitcoin address', ) } const cached = readAddressCache( scripthash, ) if (cached) { if ( cached.ageMs > addressCacheMaxAgeMs ) { refreshAddressCache( address, scripthash, ).catch(() => {}) } return { ...cached.data, cacheAgeMs: cached.ageMs, } } const data = await refreshAddressCache( address, scripthash, ) return { ...data, cacheAgeMs: 0, } } 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, lastSynced: data.lastSynced || null, cacheAgeMs: Number( data.cacheAgeMs || 0, ), error: null, }) } catch (error) { results.push({ ...entry, confirmedBalance: null, unconfirmedBalance: null, utxos: [], history: [], lastSynced: null, cacheAgeMs: null, 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}`, ) }, )