From e563a784255d11e81cab3f25660006dae0b022d3 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Wed, 11 Feb 2026 04:47:17 +0300 Subject: [PATCH 1/3] feat: add GET /auth/finance/ebitda endpoint Add EBITDA API endpoint that aggregates pool transactions, power/hashrate data, BTC prices, production costs, and block data to calculate selling and HODL EBITDA metrics with period-based aggregation. Includes shared infrastructure: period.utils, constants, and utility functions required for finance endpoints. --- tests/unit/handlers/finance.handlers.test.js | 203 +++++++++++ tests/unit/routes/finance.routes.test.js | 57 +++ workers/lib/constants.js | 56 ++- workers/lib/period.utils.js | 176 ++++++++++ .../lib/server/handlers/finance.handlers.js | 324 ++++++++++++++++++ workers/lib/server/index.js | 4 +- workers/lib/server/routes/finance.routes.js | 35 ++ workers/lib/server/schemas/finance.schemas.js | 18 + workers/lib/utils.js | 19 +- 9 files changed, 888 insertions(+), 4 deletions(-) create mode 100644 tests/unit/handlers/finance.handlers.test.js create mode 100644 tests/unit/routes/finance.routes.test.js create mode 100644 workers/lib/period.utils.js create mode 100644 workers/lib/server/handlers/finance.handlers.js create mode 100644 workers/lib/server/routes/finance.routes.js create mode 100644 workers/lib/server/schemas/finance.schemas.js diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js new file mode 100644 index 0000000..463c866 --- /dev/null +++ b/tests/unit/handlers/finance.handlers.test.js @@ -0,0 +1,203 @@ +'use strict' + +const test = require('brittle') +const { + getEbitda, + processTailLogData, + processTransactionData, + processPriceData, + extractCurrentPrice, + processCostsData, + processBlockData, + calculateEbitdaSummary +} = require('../../../workers/lib/server/handlers/finance.handlers') + +test('getEbitda - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'tailLogCustomRangeAggr') { + return [{ data: { 1700006400000: { site_power_w: 5000, hashrate_mhs_5m_sum_aggr: 100000 } } }] + } + if (method === 'getWrkExtData') { + if (payload.query && payload.query.key === 'transactions') { + return { data: [{ transactions: [{ ts: 1700006400000, changed_balance: 50000000 }] }] } + } + if (payload.query && payload.query.key === 'prices') { + return { data: [{ prices: [{ ts: 1700006400000, price: 40000 }] }] } + } + if (payload.query && payload.query.key === 'current_price') { + return { data: { USD: 40000 } } + } + if (payload.query && payload.query.key === 'blocks') { + return { data: [] } + } + } + return {} + } + }, + globalDataLib: { + getGlobalData: async () => [] + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getEbitda(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.ok(result.summary.currentBtcPrice !== undefined, 'summary should have currentBtcPrice') + t.pass() +}) + +test('getEbitda - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + try { + await getEbitda(mockCtx, { query: { end: 1700100000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getEbitda - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + try { + await getEbitda(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getEbitda - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + const result = await getEbitda(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty') + t.pass() +}) + +test('processTailLogData - processes power and hashrate', (t) => { + const results = [ + [{ data: { 1700006400000: { site_power_w: 5000, hashrate_mhs_5m_sum_aggr: 100000 } } }] + ] + + const daily = processTailLogData(results) + t.ok(typeof daily === 'object', 'should return object') + t.pass() +}) + +test('processTailLogData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processTailLogData(results) + t.is(Object.keys(daily).length, 0, 'should be empty for errors') + t.pass() +}) + +test('processTransactionData - processes valid data', (t) => { + const results = [ + [{ transactions: [{ ts: 1700006400000, changed_balance: 100000000 }] }] + ] + const daily = processTransactionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.pass() +}) + +test('processPriceData - processes valid data', (t) => { + const results = [ + [{ prices: [{ ts: 1700006400000, price: 40000 }] }] + ] + const daily = processPriceData(results) + t.ok(typeof daily === 'object', 'should return object') + t.pass() +}) + +test('extractCurrentPrice - extracts numeric price', (t) => { + const results = [{ data: 42000 }] + t.is(extractCurrentPrice(results), 42000, 'should extract numeric price') + t.pass() +}) + +test('extractCurrentPrice - extracts object price', (t) => { + const results = [{ data: { USD: 42000 } }] + t.is(extractCurrentPrice(results), 42000, 'should extract USD') + t.pass() +}) + +test('processCostsData - processes valid costs', (t) => { + const costs = [{ key: '2023-11', value: { energyCostPerMWh: 50, operationalCostPerMWh: 10 } }] + const result = processCostsData(costs) + t.ok(result['2023-11'], 'should have month key') + t.is(result['2023-11'].energyCostPerMWh, 50, 'should have energy cost') + t.pass() +}) + +test('processCostsData - handles non-array input', (t) => { + const result = processCostsData(null) + t.is(Object.keys(result).length, 0, 'should be empty') + t.pass() +}) + +test('processBlockData - processes valid blocks', (t) => { + const results = [ + [{ data: [{ ts: 1700006400000, difficulty: 12345 }] }] + ] + const daily = processBlockData(results) + t.ok(typeof daily === 'object', 'should return object') + t.pass() +}) + +test('processBlockData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processBlockData(results) + t.is(Object.keys(daily).length, 0, 'should be empty for errors') + t.pass() +}) + +test('calculateEbitdaSummary - calculates from log entries', (t) => { + const log = [ + { revenueBTC: 0.5, revenueUSD: 20000, totalCostsUSD: 5000, ebitdaSelling: 15000, ebitdaHodl: 15000 }, + { revenueBTC: 0.3, revenueUSD: 12000, totalCostsUSD: 3000, ebitdaSelling: 9000, ebitdaHodl: 9000 } + ] + + const summary = calculateEbitdaSummary(log, 40000) + t.is(summary.totalRevenueBTC, 0.8, 'should sum BTC revenue') + t.is(summary.totalRevenueUSD, 32000, 'should sum USD revenue') + t.is(summary.totalCostsUSD, 8000, 'should sum costs') + t.is(summary.totalEbitdaSelling, 24000, 'should sum selling EBITDA') + t.is(summary.currentBtcPrice, 40000, 'should include current BTC price') + t.ok(summary.avgBtcProductionCost !== null, 'should calculate avg production cost') + t.pass() +}) + +test('calculateEbitdaSummary - handles empty log', (t) => { + const summary = calculateEbitdaSummary([], 40000) + t.is(summary.totalRevenueBTC, 0, 'should be zero') + t.is(summary.avgBtcProductionCost, null, 'should be null') + t.is(summary.currentBtcPrice, 40000, 'should include current price') + t.pass() +}) diff --git a/tests/unit/routes/finance.routes.test.js b/tests/unit/routes/finance.routes.test.js new file mode 100644 index 0000000..db864f1 --- /dev/null +++ b/tests/unit/routes/finance.routes.test.js @@ -0,0 +1,57 @@ +'use strict' + +const test = require('brittle') +const { testModuleStructure, testHandlerFunctions, testOnRequestFunctions } = require('../helpers/routeTestHelpers') +const { createRoutesForTest } = require('../helpers/mockHelpers') + +const ROUTES_PATH = '../../../workers/lib/server/routes/finance.routes.js' + +test('finance routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, 'finance') + t.pass() +}) + +test('finance routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/finance/ebitda'), 'should have ebitda route') + + t.pass() +}) + +test('finance routes - HTTP methods', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + routes.forEach(route => { + t.is(route.method, 'GET', `route ${route.url} should be GET`) + }) + + t.pass() +}) + +test('finance routes - schema integration', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routesWithSchemas = routes.filter(route => route.schema) + routesWithSchemas.forEach(route => { + t.ok(route.schema, `route ${route.url} should have schema`) + if (route.schema.querystring) { + t.ok(typeof route.schema.querystring === 'object', `route ${route.url} querystring should be object`) + } + }) + + t.pass() +}) + +test('finance routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, 'finance') + t.pass() +}) + +test('finance routes - onRequest functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testOnRequestFunctions(t, routes, 'finance') + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index f1f407e..5cb5a64 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -108,7 +108,10 @@ const ENDPOINTS = { THING_CONFIG: '/auth/thing-config', // WebSocket endpoint - WEBSOCKET: '/ws' + WEBSOCKET: '/ws', + + // Finance endpoints + FINANCE_EBITDA: '/auth/finance/ebitda' } const HTTP_METHODS = { @@ -183,6 +186,48 @@ const STATUS_CODES = { INTERNAL_SERVER_ERROR: 500 } +const RPC_METHODS = { + TAIL_LOG_RANGE_AGGR: 'tailLogCustomRangeAggr', + GET_WRK_EXT_DATA: 'getWrkExtData', + LIST_THINGS: 'listThings', + TAIL_LOG: 'tailLog' +} + +const WORKER_TYPES = { + MINER: 'miner', + POWERMETER: 'powermeter', + MINERPOOL: 'minerpool', + MEMPOOL: 'mempool' +} + +const AGGR_FIELDS = { + HASHRATE_SUM: 'hashrate_mhs_5m_sum_aggr', + SITE_POWER: 'site_power_w' +} + +const PERIOD_TYPES = { + DAILY: 'daily', + WEEKLY: 'weekly', + MONTHLY: 'monthly', + YEARLY: 'yearly' +} + +const MINERPOOL_EXT_DATA_KEYS = { + TRANSACTIONS: 'transactions', + STATS: 'stats' +} + +const NON_METRIC_KEYS = [ + 'ts', + 'site', + 'year', + 'monthName', + 'month', + 'period' +] + +const BTC_SATS = 100000000 + const RPC_TIMEOUT = 15000 const RPC_CONCURRENCY_LIMIT = 2 @@ -202,5 +247,12 @@ module.exports = { STATUS_CODES, RPC_TIMEOUT, RPC_CONCURRENCY_LIMIT, - USER_SETTINGS_TYPE + USER_SETTINGS_TYPE, + RPC_METHODS, + WORKER_TYPES, + AGGR_FIELDS, + PERIOD_TYPES, + MINERPOOL_EXT_DATA_KEYS, + NON_METRIC_KEYS, + BTC_SATS } diff --git a/workers/lib/period.utils.js b/workers/lib/period.utils.js new file mode 100644 index 0000000..63a1144 --- /dev/null +++ b/workers/lib/period.utils.js @@ -0,0 +1,176 @@ +'use strict' + +const { PERIOD_TYPES, NON_METRIC_KEYS } = require('./constants') + +const getStartOfDay = (ts) => Math.floor(ts / 86400000) * 86400000 + +const convertMsToSeconds = (timestampMs) => { + return Math.floor(timestampMs / 1000) +} + +const PERIOD_CALCULATORS = { + daily: (timestamp) => getStartOfDay(timestamp), + monthly: (timestamp) => { + const date = new Date(timestamp) + return new Date(date.getFullYear(), date.getMonth(), 1).getTime() + }, + yearly: (timestamp) => { + const date = new Date(timestamp) + return new Date(date.getFullYear(), 0, 1).getTime() + } +} + +const aggregateByPeriod = (log, period, nonMetricKeys = []) => { + if (period === PERIOD_TYPES.DAILY) { + return log + } + + const allNonMetricKeys = new Set([...NON_METRIC_KEYS, ...nonMetricKeys]) + + const grouped = log.reduce((acc, entry) => { + let date + try { + date = new Date(Number(entry.ts)) + + if (isNaN(date.getTime())) { + return acc + } + } catch (error) { + return acc + } + + let groupKey + + if (period === PERIOD_TYPES.MONTHLY) { + groupKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + } else if (period === PERIOD_TYPES.YEARLY) { + groupKey = `${date.getFullYear()}` + } else { + groupKey = `${entry.ts}` + } + + if (!acc[groupKey]) { + acc[groupKey] = [] + } + acc[groupKey].push(entry) + return acc + }, {}) + + const aggregatedResults = Object.entries(grouped).map(([groupKey, entries]) => { + const aggregated = entries.reduce((acc, entry) => { + Object.entries(entry).forEach(([key, val]) => { + if (allNonMetricKeys.has(key)) { + if (!acc[key] || acc[key] === null || acc[key] === undefined) { + acc[key] = val + } + } else { + const numVal = Number(val) || 0 + acc[key] = (acc[key] || 0) + numVal + } + }) + return acc + }, {}) + + try { + if (period === PERIOD_TYPES.MONTHLY) { + const [year, month] = groupKey.split('-').map(Number) + + const newDate = new Date(year, month - 1, 1) + if (isNaN(newDate.getTime())) { + throw new Error(`Invalid date for monthly grouping: ${groupKey}`) + } + + aggregated.ts = newDate.getTime() + aggregated.month = month + aggregated.year = year + aggregated.monthName = newDate.toLocaleString('en-US', { month: 'long' }) + } else if (period === PERIOD_TYPES.YEARLY) { + const year = parseInt(groupKey) + + const newDate = new Date(year, 0, 1) + if (isNaN(newDate.getTime())) { + throw new Error(`Invalid date for yearly grouping: ${groupKey}`) + } + + aggregated.ts = newDate.getTime() + aggregated.year = year + } + } catch (error) { + aggregated.ts = entries[0].ts + + try { + const fallbackDate = new Date(Number(entries[0].ts)) + if (!isNaN(fallbackDate.getTime())) { + if (period === PERIOD_TYPES.MONTHLY) { + aggregated.month = fallbackDate.getMonth() + 1 + aggregated.year = fallbackDate.getFullYear() + aggregated.monthName = fallbackDate.toLocaleString('en-US', { month: 'long' }) + } else if (period === PERIOD_TYPES.YEARLY) { + aggregated.year = fallbackDate.getFullYear() + } + } + } catch (fallbackError) { + console.warn('Could not extract date info from fallback timestamp', fallbackError) + } + } + + return aggregated + }) + + return aggregatedResults.sort((a, b) => Number(b.ts) - Number(a.ts)) +} + +const getPeriodKey = (timestamp, period) => { + const calculator = PERIOD_CALCULATORS[period] || PERIOD_CALCULATORS.daily + return calculator(timestamp) +} + +const getPeriodEndDate = (periodTs, period) => { + const periodEnd = new Date(periodTs) + + switch (period) { + case PERIOD_TYPES.MONTHLY: + periodEnd.setMonth(periodEnd.getMonth() + 1) + break + case PERIOD_TYPES.YEARLY: + periodEnd.setFullYear(periodEnd.getFullYear() + 1) + break + } + + return periodEnd +} + +const isTimestampInPeriod = (timestamp, periodTs, period) => { + if (period === PERIOD_TYPES.DAILY) return timestamp === periodTs + + const periodEnd = getPeriodEndDate(periodTs, period) + return timestamp >= periodTs && timestamp < periodEnd.getTime() +} + +const getFilteredPeriodData = ( + sourceData, + periodTs, + period, + filterFn = (entries) => entries +) => { + if (period === PERIOD_TYPES.DAILY) { + return sourceData[periodTs] || (typeof filterFn === 'function' ? {} : 0) + } + + const entriesInPeriod = Object.entries(sourceData).filter(([tsStr]) => { + const timestamp = Number(tsStr) + return isTimestampInPeriod(timestamp, periodTs, period) + }) + + return filterFn(entriesInPeriod, sourceData) +} + +module.exports = { + getStartOfDay, + convertMsToSeconds, + getPeriodEndDate, + aggregateByPeriod, + getPeriodKey, + isTimestampInPeriod, + getFilteredPeriodData +} diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js new file mode 100644 index 0000000..e4380fe --- /dev/null +++ b/workers/lib/server/handlers/finance.handlers.js @@ -0,0 +1,324 @@ +'use strict' + +const { + WORKER_TYPES, + AGGR_FIELDS, + PERIOD_TYPES, + MINERPOOL_EXT_DATA_KEYS, + RPC_METHODS, + BTC_SATS +} = require('../../constants') +const { + requestRpcEachLimit, + getStartOfDay, + safeDiv, + runParallel +} = require('../../utils') +const { aggregateByPeriod } = require('../../period.utils') + +async function getEbitda (ctx, req) { + const start = Number(req.query.start) + const end = Number(req.query.end) + const period = req.query.period || PERIOD_TYPES.MONTHLY + + if (!start || !end) { + throw new Error('ERR_MISSING_START_END') + } + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + const startDate = new Date(start).toISOString() + const endDate = new Date(end).toISOString() + const startYearMonth = `${new Date(start).getFullYear()}-${String(new Date(start).getMonth() + 1).padStart(2, '0')}` + const endYearMonth = `${new Date(end).getFullYear()}-${String(new Date(end).getMonth() + 1).padStart(2, '0')}` + + const [transactionResults, tailLogResults, priceResults, currentPriceResults, productionCosts, blockResults] = await runParallel([ + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MINERPOOL, + query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + keys: [ + { + type: WORKER_TYPES.POWERMETER, + startDate, + endDate, + fields: { [AGGR_FIELDS.SITE_POWER]: 1 }, + shouldReturnDailyData: 1 + }, + { + type: WORKER_TYPES.MINER, + startDate, + endDate, + fields: { [AGGR_FIELDS.HASHRATE_SUM]: 1 }, + shouldReturnDailyData: 1 + } + ] + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'prices', start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'current_price' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => ctx.globalDataLib.getGlobalData({ + type: 'productionCosts', + range: { gte: startYearMonth, lte: endYearMonth } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'blocks', start, end } + }).then(r => cb(null, r)).catch(cb) + ]) + + const dailyTransactions = processTransactionData(transactionResults) + const dailyTailLog = processTailLogData(tailLogResults) + const dailyPrices = processPriceData(priceResults) + const currentBtcPrice = extractCurrentPrice(currentPriceResults) + const costsByMonth = processCostsData(productionCosts) + const dailyBlocks = processBlockData(blockResults) + + const allDays = new Set([ + ...Object.keys(dailyTransactions), + ...Object.keys(dailyTailLog) + ]) + + const log = [] + for (const dayTs of [...allDays].sort()) { + const ts = Number(dayTs) + const transactions = dailyTransactions[dayTs] || {} + const tailLog = dailyTailLog[dayTs] || {} + const btcPrice = dailyPrices[dayTs] || currentBtcPrice || 0 + const blocks = dailyBlocks[dayTs] || {} + + const revenueBTC = transactions.revenueBTC || 0 + const revenueUSD = revenueBTC * btcPrice + const powerW = tailLog.powerW || 0 + const hashrateMhs = tailLog.hashrateMhs || 0 + const powerMWh = (powerW * 24) / 1000000 + + const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` + const costs = costsByMonth[monthKey] || {} + const energyCostPerMWh = costs.energyCostPerMWh || 0 + const operationalCostPerMWh = costs.operationalCostPerMWh || 0 + + const energyCostsUSD = powerMWh * energyCostPerMWh + const operationalCostsUSD = powerMWh * operationalCostPerMWh + const totalCostsUSD = energyCostsUSD + operationalCostsUSD + + const ebitdaSelling = revenueUSD - totalCostsUSD + const ebitdaHodl = (revenueBTC * currentBtcPrice) - totalCostsUSD + const btcProductionCost = safeDiv(totalCostsUSD, revenueBTC) + + log.push({ + ts, + revenueBTC, + revenueUSD, + btcPrice, + powerW, + hashrateMhs, + consumptionMWh: powerMWh, + energyCostsUSD, + operationalCostsUSD, + totalCostsUSD, + ebitdaSelling, + ebitdaHodl, + btcProductionCost, + blockCount: blocks.count || 0, + difficulty: blocks.difficulty || 0, + ebitdaMarginSelling: safeDiv(ebitdaSelling, revenueUSD), + ebitdaMarginHodl: safeDiv(ebitdaHodl, revenueBTC * currentBtcPrice) + }) + } + + const aggregated = aggregateByPeriod(log, period) + const summary = calculateEbitdaSummary(aggregated, currentBtcPrice) + + return { log: aggregated, summary } +} + +function processTailLogData (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const items = entry.data || entry.items || entry + if (typeof items === 'object' && !Array.isArray(items)) { + for (const [key, val] of Object.entries(items)) { + const ts = getStartOfDay(Number(key)) + if (!daily[ts]) daily[ts] = { powerW: 0, hashrateMhs: 0 } + if (typeof val === 'object') { + daily[ts].powerW += (val[AGGR_FIELDS.SITE_POWER] || 0) + daily[ts].hashrateMhs += (val[AGGR_FIELDS.HASHRATE_SUM] || 0) + } + } + } else if (Array.isArray(items)) { + for (const item of items) { + const ts = getStartOfDay(item.ts || item.timestamp) + if (!daily[ts]) daily[ts] = { powerW: 0, hashrateMhs: 0 } + daily[ts].powerW += (item[AGGR_FIELDS.SITE_POWER] || 0) + daily[ts].hashrateMhs += (item[AGGR_FIELDS.HASHRATE_SUM] || 0) + } + } + } + } + return daily +} + +function processTransactionData (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const tx of data) { + if (!tx) continue + const txList = tx.data || tx.transactions || tx + if (!Array.isArray(txList)) continue + for (const t of txList) { + if (!t) continue + const ts = getStartOfDay(t.ts || t.timestamp || t.time) + if (!ts) continue + if (!daily[ts]) daily[ts] = { revenueBTC: 0 } + const amount = t.changed_balance || t.amount || t.value || 0 + daily[ts].revenueBTC += Math.abs(amount) / BTC_SATS + } + } + } + return daily +} + +function processPriceData (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const items = entry.data || entry.prices || entry + if (Array.isArray(items)) { + for (const item of items) { + const ts = getStartOfDay(item.ts || item.timestamp || item.time) + if (ts && item.price) { + daily[ts] = item.price + } + } + } else if (typeof items === 'object' && !Array.isArray(items)) { + for (const [key, val] of Object.entries(items)) { + const ts = getStartOfDay(Number(key)) + if (ts) { + daily[ts] = typeof val === 'object' ? (val.USD || val.price || 0) : Number(val) || 0 + } + } + } + } + } + return daily +} + +function extractCurrentPrice (results) { + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res[0] : res + if (!data) continue + const price = data.data || data.result || data + if (typeof price === 'number') return price + if (typeof price === 'object') return price.USD || price.price || price.current_price || 0 + } + return 0 +} + +function processCostsData (costs) { + const byMonth = {} + if (!Array.isArray(costs)) return byMonth + for (const entry of costs) { + if (!entry || !entry.key) continue + const key = entry.key + const data = entry.value || entry.data || entry + byMonth[key] = { + energyCostPerMWh: data.energyCostPerMWh || data.energy_cost_per_mwh || 0, + operationalCostPerMWh: data.operationalCostPerMWh || data.operational_cost_per_mwh || 0 + } + } + return byMonth +} + +function processBlockData (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const blocks = entry.data || entry.blocks || entry + if (Array.isArray(blocks)) { + for (const block of blocks) { + const ts = getStartOfDay(block.ts || block.timestamp || block.time) + if (!ts) continue + if (!daily[ts]) daily[ts] = { count: 0, difficulty: 0 } + daily[ts].count += 1 + daily[ts].difficulty = block.difficulty || daily[ts].difficulty + } + } + } + } + return daily +} + +function calculateEbitdaSummary (log, currentBtcPrice) { + if (!log.length) { + return { + totalRevenueBTC: 0, + totalRevenueUSD: 0, + totalCostsUSD: 0, + totalEbitdaSelling: 0, + totalEbitdaHodl: 0, + avgBtcProductionCost: null, + currentBtcPrice: currentBtcPrice || 0 + } + } + + const totals = log.reduce((acc, entry) => { + acc.revenueBTC += entry.revenueBTC || 0 + acc.revenueUSD += entry.revenueUSD || 0 + acc.costsUSD += entry.totalCostsUSD || 0 + acc.ebitdaSelling += entry.ebitdaSelling || 0 + acc.ebitdaHodl += entry.ebitdaHodl || 0 + return acc + }, { revenueBTC: 0, revenueUSD: 0, costsUSD: 0, ebitdaSelling: 0, ebitdaHodl: 0 }) + + return { + totalRevenueBTC: totals.revenueBTC, + totalRevenueUSD: totals.revenueUSD, + totalCostsUSD: totals.costsUSD, + totalEbitdaSelling: totals.ebitdaSelling, + totalEbitdaHodl: totals.ebitdaHodl, + avgBtcProductionCost: safeDiv(totals.costsUSD, totals.revenueBTC), + currentBtcPrice: currentBtcPrice || 0 + } +} + +module.exports = { + getEbitda, + processTailLogData, + processTransactionData, + processPriceData, + extractCurrentPrice, + processCostsData, + processBlockData, + calculateEbitdaSummary +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index ac884da..eb67811 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -8,6 +8,7 @@ const globalRoutes = require('./routes/global.routes') const thingsRoutes = require('./routes/things.routes') const settingsRoutes = require('./routes/settings.routes') const wsRoutes = require('./routes/ws.routes') +const financeRoutes = require('./routes/finance.routes') /** * Collect all routes into a flat array for server injection. @@ -22,7 +23,8 @@ function routes (ctx) { ...thingsRoutes(ctx), ...usersRoutes(ctx), ...settingsRoutes(ctx), - ...wsRoutes(ctx) + ...wsRoutes(ctx), + ...financeRoutes(ctx) ] } diff --git a/workers/lib/server/routes/finance.routes.js b/workers/lib/server/routes/finance.routes.js new file mode 100644 index 0000000..932bfa4 --- /dev/null +++ b/workers/lib/server/routes/finance.routes.js @@ -0,0 +1,35 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS +} = require('../../constants') +const { + getEbitda +} = require('../handlers/finance.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => { + const schemas = require('../schemas/finance.schemas.js') + + return [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.FINANCE_EBITDA, + schema: { + querystring: schemas.query.ebitda + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'finance/ebitda', + req.query.start, + req.query.end, + req.query.period + ], + ENDPOINTS.FINANCE_EBITDA, + getEbitda + ) + } + ] +} diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js new file mode 100644 index 0000000..e892a0a --- /dev/null +++ b/workers/lib/server/schemas/finance.schemas.js @@ -0,0 +1,18 @@ +'use strict' + +const schemas = { + query: { + ebitda: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + } + } +} + +module.exports = schemas diff --git a/workers/lib/utils.js b/workers/lib/utils.js index 886ae5f..9d5878e 100644 --- a/workers/lib/utils.js +++ b/workers/lib/utils.js @@ -2,6 +2,7 @@ const async = require('async') const { RPC_TIMEOUT, RPC_CONCURRENCY_LIMIT } = require('./constants') +const { getStartOfDay } = require('./period.utils') const dateNowSec = () => Math.floor(Date.now() / 1000) @@ -128,6 +129,19 @@ const requestRpcMapLimit = async (ctx, method, payload) => { }) } +const safeDiv = (num, denom) => { + if (!denom || !num) return null + return num / denom +} + +const runParallel = (tasks) => + new Promise((resolve, reject) => { + async.parallel(tasks, (err, results) => { + if (err) reject(err) + else resolve(results) + }) + }) + module.exports = { dateNowSec, extractIps, @@ -137,5 +151,8 @@ module.exports = { getAuthTokenFromHeaders, parseJsonQueryParam, requestRpcEachLimit, - requestRpcMapLimit + requestRpcMapLimit, + getStartOfDay, + safeDiv, + runParallel } From 4a8e8bda0dbaea4acaaf627dedb3a74ebea3ef18 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 12 Feb 2026 18:10:13 +0300 Subject: [PATCH 2/3] feat: remove block data, fix costs processing, add site param to ebitda - Remove block data RPC call and processBlockData (not in spec) - Remove blockCount, difficulty, ebitdaMarginSelling, ebitdaMarginHodl from log - Fix processCostsData to handle flat objects from globalDataLib with daily costs - Replace direct globalDataLib.getGlobalData range query with getProductionCosts helper - Add site query param to schema, cache key, and handler for site-specific costs --- tests/unit/handlers/finance.handlers.test.js | 30 ++----- .../lib/server/handlers/finance.handlers.js | 86 +++++++------------ workers/lib/server/routes/finance.routes.js | 3 +- workers/lib/server/schemas/finance.schemas.js | 1 + 4 files changed, 42 insertions(+), 78 deletions(-) diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js index 463c866..f2beb22 100644 --- a/tests/unit/handlers/finance.handlers.test.js +++ b/tests/unit/handlers/finance.handlers.test.js @@ -8,7 +8,6 @@ const { processPriceData, extractCurrentPrice, processCostsData, - processBlockData, calculateEbitdaSummary } = require('../../../workers/lib/server/handlers/finance.handlers') @@ -32,9 +31,6 @@ test('getEbitda - happy path', async (t) => { if (payload.query && payload.query.key === 'current_price') { return { data: { USD: 40000 } } } - if (payload.query && payload.query.key === 'blocks') { - return { data: [] } - } } return {} } @@ -148,11 +144,15 @@ test('extractCurrentPrice - extracts object price', (t) => { t.pass() }) -test('processCostsData - processes valid costs', (t) => { - const costs = [{ key: '2023-11', value: { energyCostPerMWh: 50, operationalCostPerMWh: 10 } }] +test('processCostsData - processes app-node format (energyCost)', (t) => { + const costs = [ + { site: 'site1', year: 2023, month: 11, energyCost: 30000, operationalCost: 6000 } + ] + const result = processCostsData(costs) t.ok(result['2023-11'], 'should have month key') - t.is(result['2023-11'].energyCostPerMWh, 50, 'should have energy cost') + t.is(result['2023-11'].energyCostPerDay, 1000, 'should have daily energy cost (30000/30)') + t.is(result['2023-11'].operationalCostPerDay, 200, 'should have daily operational cost (6000/30)') t.pass() }) @@ -162,22 +162,6 @@ test('processCostsData - handles non-array input', (t) => { t.pass() }) -test('processBlockData - processes valid blocks', (t) => { - const results = [ - [{ data: [{ ts: 1700006400000, difficulty: 12345 }] }] - ] - const daily = processBlockData(results) - t.ok(typeof daily === 'object', 'should return object') - t.pass() -}) - -test('processBlockData - handles error results', (t) => { - const results = [{ error: 'timeout' }] - const daily = processBlockData(results) - t.is(Object.keys(daily).length, 0, 'should be empty for errors') - t.pass() -}) - test('calculateEbitdaSummary - calculates from log entries', (t) => { const log = [ { revenueBTC: 0.5, revenueUSD: 20000, totalCostsUSD: 5000, ebitdaSelling: 15000, ebitdaHodl: 15000 }, diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index e4380fe..fb771cb 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -6,7 +6,8 @@ const { PERIOD_TYPES, MINERPOOL_EXT_DATA_KEYS, RPC_METHODS, - BTC_SATS + BTC_SATS, + GLOBAL_DATA_TYPES } = require('../../constants') const { requestRpcEachLimit, @@ -20,6 +21,7 @@ async function getEbitda (ctx, req) { const start = Number(req.query.start) const end = Number(req.query.end) const period = req.query.period || PERIOD_TYPES.MONTHLY + const site = req.query.site if (!start || !end) { throw new Error('ERR_MISSING_START_END') @@ -31,10 +33,8 @@ async function getEbitda (ctx, req) { const startDate = new Date(start).toISOString() const endDate = new Date(end).toISOString() - const startYearMonth = `${new Date(start).getFullYear()}-${String(new Date(start).getMonth() + 1).padStart(2, '0')}` - const endYearMonth = `${new Date(end).getFullYear()}-${String(new Date(end).getMonth() + 1).padStart(2, '0')}` - const [transactionResults, tailLogResults, priceResults, currentPriceResults, productionCosts, blockResults] = await runParallel([ + const [transactionResults, tailLogResults, priceResults, currentPriceResults, productionCosts] = await runParallel([ (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MINERPOOL, query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } @@ -69,15 +69,8 @@ async function getEbitda (ctx, req) { query: { key: 'current_price' } }).then(r => cb(null, r)).catch(cb), - (cb) => ctx.globalDataLib.getGlobalData({ - type: 'productionCosts', - range: { gte: startYearMonth, lte: endYearMonth } - }).then(r => cb(null, r)).catch(cb), - - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { - type: WORKER_TYPES.MEMPOOL, - query: { key: 'blocks', start, end } - }).then(r => cb(null, r)).catch(cb) + (cb) => getProductionCosts(ctx, site, start, end) + .then(r => cb(null, r)).catch(cb) ]) const dailyTransactions = processTransactionData(transactionResults) @@ -85,7 +78,6 @@ async function getEbitda (ctx, req) { const dailyPrices = processPriceData(priceResults) const currentBtcPrice = extractCurrentPrice(currentPriceResults) const costsByMonth = processCostsData(productionCosts) - const dailyBlocks = processBlockData(blockResults) const allDays = new Set([ ...Object.keys(dailyTransactions), @@ -98,7 +90,6 @@ async function getEbitda (ctx, req) { const transactions = dailyTransactions[dayTs] || {} const tailLog = dailyTailLog[dayTs] || {} const btcPrice = dailyPrices[dayTs] || currentBtcPrice || 0 - const blocks = dailyBlocks[dayTs] || {} const revenueBTC = transactions.revenueBTC || 0 const revenueUSD = revenueBTC * btcPrice @@ -108,11 +99,8 @@ async function getEbitda (ctx, req) { const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` const costs = costsByMonth[monthKey] || {} - const energyCostPerMWh = costs.energyCostPerMWh || 0 - const operationalCostPerMWh = costs.operationalCostPerMWh || 0 - - const energyCostsUSD = powerMWh * energyCostPerMWh - const operationalCostsUSD = powerMWh * operationalCostPerMWh + const energyCostsUSD = costs.energyCostPerDay || 0 + const operationalCostsUSD = costs.operationalCostPerDay || 0 const totalCostsUSD = energyCostsUSD + operationalCostsUSD const ebitdaSelling = revenueUSD - totalCostsUSD @@ -132,11 +120,7 @@ async function getEbitda (ctx, req) { totalCostsUSD, ebitdaSelling, ebitdaHodl, - btcProductionCost, - blockCount: blocks.count || 0, - difficulty: blocks.difficulty || 0, - ebitdaMarginSelling: safeDiv(ebitdaSelling, revenueUSD), - ebitdaMarginHodl: safeDiv(ebitdaHodl, revenueBTC * currentBtcPrice) + btcProductionCost }) } @@ -241,44 +225,38 @@ function extractCurrentPrice (results) { return 0 } +async function getProductionCosts (ctx, site, start, end) { + if (!ctx.globalDataLib) return [] + const costs = await ctx.globalDataLib.getGlobalData({ + type: GLOBAL_DATA_TYPES.PRODUCTION_COSTS + }) + if (!Array.isArray(costs)) return [] + + const startDate = new Date(start) + const endDate = new Date(end) + return costs.filter(entry => { + if (!entry || !entry.year || !entry.month) return false + if (site && entry.site !== site) return false + const entryDate = new Date(entry.year, entry.month - 1, 1) + return entryDate >= startDate && entryDate <= endDate + }) +} + function processCostsData (costs) { const byMonth = {} if (!Array.isArray(costs)) return byMonth for (const entry of costs) { - if (!entry || !entry.key) continue - const key = entry.key - const data = entry.value || entry.data || entry + if (!entry || !entry.year || !entry.month) continue + const key = `${entry.year}-${String(entry.month).padStart(2, '0')}` + const daysInMonth = new Date(entry.year, entry.month, 0).getDate() byMonth[key] = { - energyCostPerMWh: data.energyCostPerMWh || data.energy_cost_per_mwh || 0, - operationalCostPerMWh: data.operationalCostPerMWh || data.operational_cost_per_mwh || 0 + energyCostPerDay: (entry.energyCost || entry.energyCostsUSD || 0) / daysInMonth, + operationalCostPerDay: (entry.operationalCost || entry.operationalCostsUSD || 0) / daysInMonth } } return byMonth } -function processBlockData (results) { - const daily = {} - for (const res of results) { - if (res.error || !res) continue - const data = Array.isArray(res) ? res : (res.data || res.result || []) - if (!Array.isArray(data)) continue - for (const entry of data) { - if (!entry) continue - const blocks = entry.data || entry.blocks || entry - if (Array.isArray(blocks)) { - for (const block of blocks) { - const ts = getStartOfDay(block.ts || block.timestamp || block.time) - if (!ts) continue - if (!daily[ts]) daily[ts] = { count: 0, difficulty: 0 } - daily[ts].count += 1 - daily[ts].difficulty = block.difficulty || daily[ts].difficulty - } - } - } - } - return daily -} - function calculateEbitdaSummary (log, currentBtcPrice) { if (!log.length) { return { @@ -318,7 +296,7 @@ module.exports = { processTransactionData, processPriceData, extractCurrentPrice, + getProductionCosts, processCostsData, - processBlockData, calculateEbitdaSummary } diff --git a/workers/lib/server/routes/finance.routes.js b/workers/lib/server/routes/finance.routes.js index 932bfa4..ff695fe 100644 --- a/workers/lib/server/routes/finance.routes.js +++ b/workers/lib/server/routes/finance.routes.js @@ -25,7 +25,8 @@ module.exports = (ctx) => { 'finance/ebitda', req.query.start, req.query.end, - req.query.period + req.query.period, + req.query.site ], ENDPOINTS.FINANCE_EBITDA, getEbitda diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js index e892a0a..543f54e 100644 --- a/workers/lib/server/schemas/finance.schemas.js +++ b/workers/lib/server/schemas/finance.schemas.js @@ -8,6 +8,7 @@ const schemas = { start: { type: 'integer' }, end: { type: 'integer' }, period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, + site: { type: 'string' }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] From b07ec35cac277ba77141482a4c64adaa5da882ef Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Sun, 15 Feb 2026 16:44:47 +0300 Subject: [PATCH 3/3] fix: remove site param from ebitda endpoint and add integration test Data is fetched for a single site, so the site parameter is unnecessary. Added security integration test for finance/ebitda endpoint. --- tests/integration/api.security.test.js | 5 +++++ workers/lib/server/handlers/finance.handlers.js | 3 +-- workers/lib/server/routes/finance.routes.js | 3 +-- workers/lib/server/schemas/finance.schemas.js | 1 - 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/integration/api.security.test.js b/tests/integration/api.security.test.js index 58f9728..6fb8947 100644 --- a/tests/integration/api.security.test.js +++ b/tests/integration/api.security.test.js @@ -409,6 +409,11 @@ test('Api security', { timeout: 90000 }, async (main) => { await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) }) + await main.test('Api: get finance/ebitda', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.FINANCE_EBITDA}?start=1700000000000&end=1700100000000` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + await main.test('Api: get ext-data', async (n) => { const api = `${appNodeBaseUrl}${ENDPOINTS.EXT_DATA}?type=miner` await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index fb771cb..7c9fe8c 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -21,7 +21,6 @@ async function getEbitda (ctx, req) { const start = Number(req.query.start) const end = Number(req.query.end) const period = req.query.period || PERIOD_TYPES.MONTHLY - const site = req.query.site if (!start || !end) { throw new Error('ERR_MISSING_START_END') @@ -69,7 +68,7 @@ async function getEbitda (ctx, req) { query: { key: 'current_price' } }).then(r => cb(null, r)).catch(cb), - (cb) => getProductionCosts(ctx, site, start, end) + (cb) => getProductionCosts(ctx, null, start, end) .then(r => cb(null, r)).catch(cb) ]) diff --git a/workers/lib/server/routes/finance.routes.js b/workers/lib/server/routes/finance.routes.js index ff695fe..932bfa4 100644 --- a/workers/lib/server/routes/finance.routes.js +++ b/workers/lib/server/routes/finance.routes.js @@ -25,8 +25,7 @@ module.exports = (ctx) => { 'finance/ebitda', req.query.start, req.query.end, - req.query.period, - req.query.site + req.query.period ], ENDPOINTS.FINANCE_EBITDA, getEbitda diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js index 543f54e..e892a0a 100644 --- a/workers/lib/server/schemas/finance.schemas.js +++ b/workers/lib/server/schemas/finance.schemas.js @@ -8,7 +8,6 @@ const schemas = { start: { type: 'integer' }, end: { type: 'integer' }, period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, - site: { type: 'string' }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end']