From e56c9fde816599a7d9a3f4f34cbc80546c6a1102 Mon Sep 17 00:00:00 2001 From: Unheard0840 Date: Sat, 29 Aug 2026 15:34:16 +0000 Subject: [PATCH] Update server/status.js Add paginated address transaction API --- server/status.js | 1238 +++++++++++++++++++++++++--------------------- 1 file changed, 663 insertions(+), 575 deletions(-) diff --git a/server/status.js b/server/status.js index 3fcb3bb..021a7ee 100644 --- a/server/status.js +++ b/server/status.js @@ -230,7 +230,9 @@ function queryFulcrum( params, }) + '\n' - console.log(`Sending Fulcrum request: ${request.trim()}`) + console.log( + `Sending Fulcrum request: ${request.trim()}`, + ) socket.write(request) }) @@ -238,7 +240,9 @@ function queryFulcrum( socket.on('data', (data) => { const chunk = data.toString() - console.log(`Fulcrum response data: ${chunk.trim()}`) + console.log( + `Fulcrum response data: ${chunk.trim()}`, + ) response += chunk @@ -321,23 +325,29 @@ function queryFulcrum( finish({ ok: false, result: null, - error: 'Connection closed before a response was received', + error: + 'Connection closed before a response was received', }) } }) }) } -async function getFulcrumStatus(fulcrumUrl) { - const version = await queryFulcrum( - fulcrumUrl, - 'server.version', - ['Munin Bitcoin', '1.4'], - ) +async function getFulcrumStatus( + fulcrumUrl, +) { + const version = + await queryFulcrum( + fulcrumUrl, + 'server.version', + ['Munin Bitcoin', '1.4'], + ) if (!version.ok) { return { - configured: Boolean(fulcrumUrl), + configured: Boolean( + fulcrumUrl, + ), connected: false, url: fulcrumUrl, serverVersion: null, @@ -346,28 +356,43 @@ async function getFulcrumStatus(fulcrumUrl) { } } - const headers = await queryFulcrum( - fulcrumUrl, - 'blockchain.headers.subscribe', - [], - ) + const headers = + await queryFulcrum( + fulcrumUrl, + 'blockchain.headers.subscribe', + [], + ) return { - configured: Boolean(fulcrumUrl), + configured: Boolean( + fulcrumUrl, + ), connected: true, url: fulcrumUrl, - serverVersion: version.result, + serverVersion: + version.result, blockchainHeight: - headers.ok && headers.result - ? headers.result.height ?? null + headers.ok && + headers.result + ? headers.result.height ?? + null : null, - error: headers.ok ? null : headers.error, + error: + headers.ok + ? null + : headers.error, } } async function getStatus() { - const fulcrumUrl = process.env.MUNIN_FULCRUM_URL || null - const fulcrum = await getFulcrumStatus(fulcrumUrl) + const fulcrumUrl = + process.env.MUNIN_FULCRUM_URL || + null + + const fulcrum = + await getFulcrumStatus( + fulcrumUrl, + ) return { service: 'running', @@ -384,7 +409,8 @@ async function getAddressDataFromFulcrum( scripthash, ) { const fulcrumUrl = - process.env.MUNIN_FULCRUM_URL || null + process.env.MUNIN_FULCRUM_URL || + null if (!fulcrumUrl) { throw new Error( @@ -392,11 +418,12 @@ async function getAddressDataFromFulcrum( ) } - const balance = await queryFulcrum( - fulcrumUrl, - 'blockchain.scripthash.get_balance', - [scripthash], - ) + const balance = + await queryFulcrum( + fulcrumUrl, + 'blockchain.scripthash.get_balance', + [scripthash], + ) if (!balance.ok) { throw new Error( @@ -404,11 +431,12 @@ async function getAddressDataFromFulcrum( ) } - const utxos = await queryFulcrum( - fulcrumUrl, - 'blockchain.scripthash.listunspent', - [scripthash], - ) + const utxos = + await queryFulcrum( + fulcrumUrl, + 'blockchain.scripthash.listunspent', + [scripthash], + ) if (!utxos.ok) { throw new Error( @@ -416,11 +444,12 @@ async function getAddressDataFromFulcrum( ) } - const history = await queryFulcrum( - fulcrumUrl, - 'blockchain.scripthash.get_history', - [scripthash], - ) + const history = + await queryFulcrum( + fulcrumUrl, + 'blockchain.scripthash.get_history', + [scripthash], + ) if (!history.ok) { throw new Error( @@ -432,15 +461,25 @@ async function getAddressDataFromFulcrum( address, scripthash, confirmedBalance: - Number(balance.result?.confirmed || 0), + Number( + balance.result?.confirmed || + 0, + ), unconfirmedBalance: - Number(balance.result?.unconfirmed || 0), + Number( + balance.result?.unconfirmed || + 0, + ), utxos: - Array.isArray(utxos.result) + Array.isArray( + utxos.result, + ) ? utxos.result : [], history: - Array.isArray(history.result) + Array.isArray( + history.result, + ) ? history.result : [], lastSynced: @@ -448,12 +487,16 @@ async function getAddressDataFromFulcrum( } } -async function getAddressData(address) { +async function getAddressData( + address, +) { let scripthash try { scripthash = - addressToScripthash(address) + addressToScripthash( + address, + ) } catch (error) { throw new Error( error instanceof Error @@ -463,7 +506,9 @@ async function getAddressData(address) { } const cached = - readAddressCache(scripthash) + readAddressCache( + scripthash, + ) if (cached) { if ( @@ -498,7 +543,8 @@ async function getAddressData(address) { function getAddressTransactionPage( history, offset = 0, - limit = addressTransactionPageSize, + limit = + addressTransactionPageSize, ) { const safeOffset = Math.max( @@ -523,7 +569,9 @@ function getAddressTransactionPage( } async function getAddressesWithData() { - const addresses = await listAddresses() + const addresses = + await listAddresses() + const results = [] for (const entry of addresses) { @@ -534,7 +582,9 @@ async function getAddressesWithData() { ) const history = - Array.isArray(data.history) + Array.isArray( + data.history, + ) ? data.history : [] @@ -558,7 +608,8 @@ async function getAddressesWithData() { transactionLimit: addressTransactionPageSize, lastSynced: - data.lastSynced || null, + data.lastSynced || + null, cacheAgeMs: Number( data.cacheAgeMs || 0, @@ -568,10 +619,8 @@ async function getAddressesWithData() { } catch (error) { results.push({ ...entry, - confirmedBalance: - null, - unconfirmedBalance: - null, + confirmedBalance: null, + unconfirmedBalance: null, utxos: [], history: [], transactionCount: 0, @@ -591,29 +640,65 @@ async function getAddressesWithData() { return results } -const server = http.createServer( - async (req, res) => { - if ( - req.method === 'POST' && - req.url === '/api/login' - ) { - let body = '' +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('data', (chunk) => { + body += chunk + }) - req.on('end', () => { - try { - const input = - JSON.parse(body) + req.on('end', () => { + try { + const input = + JSON.parse(body) - if ( - !checkPassword( - input.password, + 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, + }), ) - ) { - res.writeHead(401, { + } catch { + res.writeHead(400, { 'Content-Type': 'application/json', }) @@ -621,31 +706,316 @@ const server = http.createServer( res.end( JSON.stringify({ error: - 'Invalid password', + 'Invalid request', }), ) - - return } + }) - const token = - createSession() + return + } - res.writeHead(200, { + if ( + req.url === + '/api/status' + ) { + if ( + !isValidSession(req) + ) { + res.writeHead(401, { 'Content-Type': 'application/json', - 'Set-Cookie': - 'munin_session=' + - encodeURIComponent(token) + - '; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400', }) res.end( JSON.stringify({ - ok: true, + error: + 'Authentication required', }), ) - } catch { + + 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 === 'GET' && + req.url.startsWith( + '/api/addresses/transactions', + ) + ) { + if ( + !isValidSession(req) + ) { + res.writeHead(401, { + 'Content-Type': + 'application/json', + }) + + res.end( + JSON.stringify({ + error: + 'Authentication required', + }), + ) + + return + } + + try { + const url = + new URL( + req.url, + `http://${ + req.headers.host || + 'localhost' + }`, + ) + + const address = + url.searchParams.get( + 'address', + ) + + if (!address) { + throw new Error( + 'Address is required', + ) + } + + const offset = + Math.max( + 0, + Number( + url.searchParams.get( + 'offset', + ), + ) || 0, + ) + + const limit = + Math.min( + addressTransactionPageSize, + Math.max( + 1, + Number( + url.searchParams.get( + 'limit', + ), + ) || + addressTransactionPageSize, + ), + ) + + const data = + await getAddressData( + address, + ) + + const history = + Array.isArray( + data.history, + ) + ? data.history + : [] + + const transactions = + getAddressTransactionPage( + history, + offset, + limit, + ) + + res.writeHead(200, { + 'Content-Type': + 'application/json', + }) + + res.end( + JSON.stringify({ + address, + scripthash: + data.scripthash, + transactions, + total: + history.length, + offset, + limit, + hasMore: + offset + + transactions.length < + history.length, + lastSynced: + data.lastSynced || + null, + cacheAgeMs: + Number( + data.cacheAgeMs || + 0, + ), + }), + ) + } catch (error) { + console.error( + 'Failed to get address transactions:', + error, + ) + res.writeHead(400, { 'Content-Type': 'application/json', @@ -654,519 +1024,237 @@ const server = http.createServer( res.end( JSON.stringify({ error: - 'Invalid request', + error instanceof Error + ? error.message + : 'Failed to get address transactions', }), ) } - }) - - 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() + if ( + req.method === 'POST' && + req.url === + '/api/addresses' + ) { + if ( + !isValidSession(req) + ) { + res.writeHead(401, { + 'Content-Type': + 'application/json', + }) - 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 === 'GET' && - req.url.startsWith( - '/api/addresses/transactions', - ) - ) { - if (!isValidSession(req)) { - res.writeHead(401, { - 'Content-Type': - 'application/json', - }) - - res.end( - JSON.stringify({ - error: - 'Authentication required', - }), - ) - - return - } - - try { - const url = - new URL( - req.url, - `http://${req.headers.host || 'localhost'}`, + res.end( + JSON.stringify({ + error: + 'Authentication required', + }), ) - const address = - url.searchParams.get( - 'address', - ) - - if (!address) { - throw new Error( - 'Address is required', - ) + return } - const offset = - Math.max( - 0, - Number( - url.searchParams.get( - 'offset', - ), - ) || 0, + 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', + }), ) - const limit = - Math.min( - addressTransactionPageSize, - Math.max( - 1, - Number( - url.searchParams.get( - 'limit', - ), - ) || - addressTransactionPageSize, + 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, ), ) - const data = - await getAddressData( - address, + 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', + }) - const history = - Array.isArray( - data.history, + res.end( + JSON.stringify({ + error: + error instanceof Error + ? error.message + : 'Failed to get transaction', + }), ) - ? data.history - : [] - - const transactions = - getAddressTransactionPage( - history, - offset, - limit, - ) - - res.writeHead(200, { - 'Content-Type': - 'application/json', - }) - - res.end( - JSON.stringify({ - address, - scripthash: - data.scripthash, - transactions, - total: - history.length, - offset, - limit, - hasMore: - offset + - transactions.length < - history.length, - lastSynced: - data.lastSynced || - null, - cacheAgeMs: - Number( - data.cacheAgeMs || 0, - ), - }), - ) - } catch (error) { - console.error( - 'Failed to get address transactions:', - error, - ) - - res.writeHead(400, { - 'Content-Type': - 'application/json', - }) - - res.end( - JSON.stringify({ - error: - error instanceof Error - ? error.message - : 'Failed to get address transactions', - }), - ) - } - - 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}`, + res.writeHead(404) + res.end('Not found') + }, ) -}) \ No newline at end of file + +server.listen( + port, + () => { + console.log( + `Status API listening on port ${port}`, + ) + }, +) \ No newline at end of file