const crypto = require('crypto') const fs = require('fs/promises') const net = require('net') const storePath = '/data/store.json' const fulcrumTimeoutMs = 3000 async function readStore() { try { const data = await fs.readFile(storePath, 'utf8') return JSON.parse(data) } catch (error) { if (error.code === 'ENOENT') { return {} } throw error } } async function writeStore(store) { const temporaryPath = `${storePath}.${crypto.randomBytes(8).toString('hex')}.tmp` await fs.writeFile( temporaryPath, JSON.stringify(store, null, 2) + '\n', { mode: 0o600, }, ) await fs.rename(temporaryPath, storePath) } function normalizeAddress(address) { if (typeof address !== 'string') { return null } const normalized = address.trim() if (!normalized) { return null } return normalized } function normalizeLabel(label) { if (typeof label !== 'string') { return undefined } const normalized = label.trim() return normalized || undefined } async function listAddresses() { const store = await readStore() return Array.isArray(store.addresses) ? store.addresses : [] } async function addAddress(address, label) { const normalizedAddress = normalizeAddress(address) if (!normalizedAddress) { throw new Error('Bitcoin address is required') } const addresses = await listAddresses() if (addresses.some((item) => item.address === normalizedAddress)) { throw new Error('Bitcoin address is already being watched') } const entry = { address: normalizedAddress, } const normalizedLabel = normalizeLabel(label) if (normalizedLabel) { entry.label = normalizedLabel } const store = await readStore() store.addresses = [...addresses, entry] await writeStore(store) return entry } async function removeAddress(address) { const normalizedAddress = normalizeAddress(address) if (!normalizedAddress) { throw new Error('Bitcoin address is required') } const store = await readStore() const addresses = Array.isArray(store.addresses) ? store.addresses : [] const filtered = addresses.filter( (item) => item.address !== normalizedAddress, ) if (filtered.length === addresses.length) { throw new Error('Bitcoin address is not being watched') } store.addresses = filtered await writeStore(store) return true } 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 port = Number(parsed.port) if (!host || !port) { resolve({ ok: false, result: null, error: 'Fulcrum URL is missing host or port', }) return } const socket = net.createConnection({ host, port, }) let response = '' let settled = false const finish = (value) => { if (settled) { return } settled = true socket.destroy() resolve(value) } socket.setTimeout(fulcrumTimeoutMs) socket.on('connect', () => { const request = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params, }) + '\n' socket.write(request) }) socket.on('data', (data) => { response += data.toString() 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) { 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', () => { finish({ ok: false, result: null, error: 'Connection timed out', }) }) socket.on('error', (error) => { finish({ ok: false, result: null, error: error.message, }) }) socket.on('close', () => { finish({ ok: false, result: null, error: 'Connection closed before a response was received', }) }) }) } async function getAddressData(address) { const fulcrumUrl = process.env.MUNIN_FULCRUM_URL || null if (!fulcrumUrl) { throw new Error('Fulcrum URL is not configured') } const history = await queryFulcrum( fulcrumUrl, 'blockchain.address.get_history', [address], ) if (!history.ok) { throw new Error( `Failed to query address history: ${JSON.stringify(history.error)}`, ) } const utxos = await queryFulcrum( fulcrumUrl, 'blockchain.address.listunspent', [address], ) if (!utxos.ok) { throw new Error( `Failed to query address UTXOs: ${JSON.stringify(utxos.error)}`, ) } const confirmedBalance = utxos.result.reduce( (total, item) => total + Number(item.value || 0), 0, ) return { address, confirmedBalance, unconfirmedBalance: 0, utxos: utxos.result, history: 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, 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.message, }) } } return results } module.exports = { listAddresses, addAddress, removeAddress, getAddressData, getAddressesWithData, }