Files
munin-bitcoin/server/addresses.js
T
Unheard0840 79d37f5dd3 Update server/addresses.js
Add Fulcrum address data queries
2026-08-29 10:50:03 +00:00

357 lines
6.5 KiB
JavaScript

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,
}