diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js index 409b1e2..71549c5 100644 --- a/tests/unit/handlers/finance.handlers.test.js +++ b/tests/unit/handlers/finance.handlers.test.js @@ -4,22 +4,21 @@ const test = require('brittle') const { getEnergyBalance, processConsumptionData, - processTransactionData, processPriceData, - extractCurrentPrice, processCostsData, calculateSummary, getEbitda, processTailLogData, - processEbitdaTransactions, processEbitdaPrices, - extractEbitdaCurrentPrice, calculateEbitdaSummary, getCostSummary, calculateCostSummary, getSubsidyFees, - processBlockData, - calculateSubsidyFeesSummary + calculateSubsidyFeesSummary, + getRevenue, + calculateRevenueSummary, + getRevenueSummary, + calculateDetailedRevenueSummary } = require('../../../workers/lib/server/handlers/finance.handlers') // ==================== Energy Balance Tests ==================== @@ -184,40 +183,6 @@ test('processConsumptionData - handles error results', (t) => { t.pass() }) -test('processTransactionData - processes F2Pool data', (t) => { - const results = [ - [{ ts: 1700006400000, transactions: [{ created_at: 1700006400, changed_balance: 0.001 }] }] - ] - - const daily = processTransactionData(results) - t.ok(typeof daily === 'object', 'should return object') - t.ok(Object.keys(daily).length > 0, 'should have entries') - const key = Object.keys(daily)[0] - t.is(daily[key].revenueBTC, 0.001, 'should use changed_balance directly as BTC') - t.pass() -}) - -test('processTransactionData - processes Ocean data', (t) => { - const results = [ - [{ ts: 1700006400000, transactions: [{ ts: 1700006400, satoshis_net_earned: 50000000 }] }] - ] - - const daily = processTransactionData(results) - t.ok(typeof daily === 'object', 'should return object') - t.ok(Object.keys(daily).length > 0, 'should have entries') - const key = Object.keys(daily)[0] - t.is(daily[key].revenueBTC, 0.5, 'should convert sats to BTC') - t.pass() -}) - -test('processTransactionData - handles error results', (t) => { - const results = [{ error: 'timeout' }] - const daily = processTransactionData(results) - t.ok(typeof daily === 'object', 'should return object') - t.is(Object.keys(daily).length, 0, 'should be empty for error results') - t.pass() -}) - test('processPriceData - processes mempool price data', (t) => { const results = [ [{ ts: 1700006400000, priceUSD: 40000 }] @@ -231,31 +196,6 @@ test('processPriceData - processes mempool price data', (t) => { t.pass() }) -test('extractCurrentPrice - extracts currentPrice from mempool data', (t) => { - const results = [ - [{ currentPrice: 42000, blockHeight: 900000 }] - ] - const price = extractCurrentPrice(results) - t.is(price, 42000, 'should extract currentPrice') - t.pass() -}) - -test('extractCurrentPrice - extracts priceUSD', (t) => { - const results = [ - [{ ts: 1700006400000, priceUSD: 42000 }] - ] - const price = extractCurrentPrice(results) - t.is(price, 42000, 'should extract priceUSD') - t.pass() -}) - -test('extractCurrentPrice - handles error results', (t) => { - const results = [{ error: 'timeout' }] - const price = extractCurrentPrice(results) - t.is(price, 0, 'should return 0 for error results') - t.pass() -}) - test('processCostsData - processes dashboard format (energyCostsUSD)', (t) => { const costs = [ { region: 'site1', year: 2023, month: 11, energyCostsUSD: 30000, operationalCostsUSD: 6000 } @@ -417,15 +357,6 @@ test('processTailLogData - handles error results', (t) => { t.pass() }) -test('processEbitdaTransactions - processes valid data', (t) => { - const results = [ - [{ transactions: [{ ts: 1700006400000, changed_balance: 100000000 }] }] - ] - const daily = processEbitdaTransactions(results) - t.ok(typeof daily === 'object', 'should return object') - t.pass() -}) - test('processEbitdaPrices - processes valid data', (t) => { const results = [ [{ prices: [{ ts: 1700006400000, price: 40000 }] }] @@ -435,18 +366,6 @@ test('processEbitdaPrices - processes valid data', (t) => { t.pass() }) -test('extractEbitdaCurrentPrice - extracts numeric price', (t) => { - const results = [{ data: 42000 }] - t.is(extractEbitdaCurrentPrice(results), 42000, 'should extract numeric price') - t.pass() -}) - -test('extractEbitdaCurrentPrice - extracts object price', (t) => { - const results = [{ data: { USD: 42000 } }] - t.is(extractEbitdaCurrentPrice(results), 42000, 'should extract USD') - t.pass() -}) - test('calculateEbitdaSummary - calculates from log entries', (t) => { const log = [ { revenueBTC: 0.5, revenueUSD: 20000, totalCostsUSD: 5000, ebitdaSelling: 15000, ebitdaHodl: 15000 }, @@ -643,69 +562,303 @@ test('getSubsidyFees - empty ork results', async (t) => { t.pass() }) -test('processBlockData - processes valid block data', (t) => { - const results = [ - [{ data: [{ ts: 1700006400000, blockReward: 6.25, blockTotalFees: 0.5 }] }] +test('calculateSubsidyFeesSummary - calculates from log entries', (t) => { + const log = [ + { blockReward: 6.25, blockTotalFees: 0.5 }, + { blockReward: 6.25, blockTotalFees: 0.3 } ] - const daily = processBlockData(results) - t.ok(typeof daily === 'object', 'should return object') - t.ok(Object.keys(daily).length > 0, 'should have entries') - const key = Object.keys(daily)[0] - t.is(daily[key].blockReward, 6.25, 'should extract blockReward') - t.is(daily[key].blockTotalFees, 0.5, 'should extract blockTotalFees') + const summary = calculateSubsidyFeesSummary(log) + t.is(summary.totalBlockReward, 12.5, 'should sum block rewards') + t.is(summary.totalBlockTotalFees, 0.8, 'should sum block fees') + t.ok(summary.avgBlockReward !== null, 'should calculate avg block reward') + t.is(summary.avgBlockReward, 6.25, 'should calculate correct avg block reward') + t.ok(summary.avgBlockTotalFees !== null, 'should calculate avg block fees') t.pass() }) -test('processBlockData - processes object-keyed data', (t) => { - const results = [ - [{ data: { 1700006400000: { blockReward: 6.25, blockTotalFees: 0.5 } } }] +test('calculateSubsidyFeesSummary - handles empty log', (t) => { + const summary = calculateSubsidyFeesSummary([]) + t.is(summary.totalBlockReward, 0, 'should be zero') + t.is(summary.totalBlockTotalFees, 0, 'should be zero') + t.is(summary.avgBlockReward, null, 'should be null') + t.is(summary.avgBlockTotalFees, null, 'should be null') + t.pass() +}) + +// ==================== Revenue Tests ==================== + +test('getRevenue - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'getWrkExtData') { + return [{ transactions: [{ ts: 1700006400000, changed_balance: 0.5, mining_extra: { tx_fee: 0.001 } }] }] + } + return {} + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getRevenue(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.pass() +}) + +test('getRevenue - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getRevenue(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('getRevenue - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getRevenue(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('getRevenue - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + } + + const result = await getRevenue(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('getRevenue - pool filter', async (t) => { + let capturedPayload = null + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [{ transactions: [{ ts: 1700006400000, changed_balance: 0.5 }] }] + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, pool: 'f2pool' } + } + + await getRevenue(mockCtx, mockReq, {}) + t.is(capturedPayload.type, 'minerpool-f2pool', 'should include pool in worker type') + t.pass() +}) + +test('calculateRevenueSummary - calculates from log entries', (t) => { + const log = [ + { revenueBTC: 0.5, feesBTC: 0.01, netRevenueBTC: 0.49 }, + { revenueBTC: 0.3, feesBTC: 0.005, netRevenueBTC: 0.295 } ] - const daily = processBlockData(results) - t.ok(typeof daily === 'object', 'should return object') - t.ok(Object.keys(daily).length > 0, 'should have entries') - const key = Object.keys(daily)[0] - t.is(daily[key].blockReward, 6.25, 'should extract blockReward') + const summary = calculateRevenueSummary(log) + t.is(summary.totalRevenueBTC, 0.8, 'should sum revenue') + t.is(summary.totalFeesBTC, 0.015, 'should sum fees') + t.ok(Math.abs(summary.totalNetRevenueBTC - 0.785) < 1e-10, 'should sum net revenue') t.pass() }) -test('processBlockData - handles error results', (t) => { - const results = [{ error: 'timeout' }] - const daily = processBlockData(results) - t.ok(typeof daily === 'object', 'should return object') - t.is(Object.keys(daily).length, 0, 'should be empty for error results') +test('calculateRevenueSummary - handles empty log', (t) => { + const summary = calculateRevenueSummary([]) + t.is(summary.totalRevenueBTC, 0, 'should be zero') + t.is(summary.totalFeesBTC, 0, 'should be zero') + t.is(summary.totalNetRevenueBTC, 0, 'should be zero') t.pass() }) -test('processBlockData - handles empty results', (t) => { - const results = [] - const daily = processBlockData(results) - t.ok(typeof daily === 'object', 'should return object') - t.is(Object.keys(daily).length, 0, 'should be empty') +// ==================== Revenue Summary Tests ==================== + +test('getRevenueSummary - happy path', async (t) => { + const dayTs = 1700006400000 + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'tailLogCustomRangeAggr') { + return [{ data: { [dayTs]: { site_power_w: 5000, hashrate_mhs_5m_sum_aggr: 100000 } } }] + } + if (method === 'getWrkExtData') { + if (payload.query && payload.query.key === 'transactions') { + return [{ transactions: [{ ts: dayTs, changed_balance: 0.5, mining_extra: { tx_fee: 0.001 } }] }] + } + if (payload.query && payload.query.key === 'HISTORICAL_PRICES') { + return [{ data: [{ ts: dayTs, priceUSD: 40000 }] }] + } + if (payload.query && payload.query.key === 'current_price') { + return { data: { USD: 40000 } } + } + if (payload.query && payload.query.key === 'HISTORICAL_BLOCKSIZES') { + return [{ data: [{ ts: dayTs, blockReward: 6.25, blockTotalFees: 0.5 }] }] + } + if (payload.query && payload.query.key === 'stats-history') { + return [] + } + } + if (method === 'getGlobalConfig') { + return { nominalPowerAvailability_MW: 10 } + } + return {} + } + }, + globalDataLib: { + getGlobalData: async () => [] + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getRevenueSummary(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') + if (result.log.length > 0) { + const entry = result.log[0] + t.ok(entry.revenueBTC !== undefined, 'entry should have revenueBTC') + t.ok(entry.feesBTC !== undefined, 'entry should have feesBTC') + t.ok(entry.revenueUSD !== undefined, 'entry should have revenueUSD') + t.ok(entry.ebitdaSelling !== undefined, 'entry should have ebitdaSelling') + t.ok(entry.ebitdaHodl !== undefined, 'entry should have ebitdaHodl') + t.ok(entry.blockReward !== undefined, 'entry should have blockReward') + } t.pass() }) -test('calculateSubsidyFeesSummary - calculates from log entries', (t) => { +test('getRevenueSummary - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + try { + await getRevenueSummary(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('getRevenueSummary - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + try { + await getRevenueSummary(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('getRevenueSummary - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + const result = await getRevenueSummary(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('calculateDetailedRevenueSummary - calculates from log entries', (t) => { const log = [ - { blockReward: 6.25, blockTotalFees: 0.5 }, - { blockReward: 6.25, blockTotalFees: 0.3 } + { + revenueBTC: 0.5, + revenueUSD: 20000, + feesBTC: 0.01, + feesUSD: 400, + totalCostsUSD: 5000, + consumptionMWh: 100, + ebitdaSelling: 15000, + ebitdaHodl: 15000, + btcPrice: 40000, + curtailmentRate: 0.1, + powerUtilization: 0.8 + }, + { + revenueBTC: 0.3, + revenueUSD: 12600, + feesBTC: 0.005, + feesUSD: 210, + totalCostsUSD: 3000, + consumptionMWh: 60, + ebitdaSelling: 9600, + ebitdaHodl: 9600, + btcPrice: 42000, + curtailmentRate: 0.15, + powerUtilization: 0.85 + } ] - const summary = calculateSubsidyFeesSummary(log) - t.is(summary.totalBlockReward, 12.5, 'should sum block rewards') - t.is(summary.totalBlockTotalFees, 0.8, 'should sum block fees') - t.ok(summary.avgBlockReward !== null, 'should calculate avg block reward') - t.is(summary.avgBlockReward, 6.25, 'should calculate correct avg block reward') - t.ok(summary.avgBlockTotalFees !== null, 'should calculate avg block fees') + const summary = calculateDetailedRevenueSummary(log, 42000) + t.is(summary.totalRevenueBTC, 0.8, 'should sum BTC revenue') + t.is(summary.totalRevenueUSD, 32600, 'should sum USD revenue') + t.is(summary.totalFeesBTC, 0.015, 'should sum fees BTC') + t.is(summary.totalCostsUSD, 8000, 'should sum costs') + t.is(summary.totalConsumptionMWh, 160, 'should sum consumption') + t.is(summary.totalEbitdaSelling, 24600, 'should sum selling EBITDA') + t.ok(summary.avgCostPerMWh !== null, 'should calculate avg cost per MWh') + t.ok(summary.avgRevenuePerMWh !== null, 'should calculate avg revenue per MWh') + t.ok(summary.avgBtcPrice !== null, 'should calculate avg BTC price') + t.ok(summary.avgCurtailmentRate !== null, 'should calculate avg curtailment rate') + t.ok(summary.avgPowerUtilization !== null, 'should calculate avg power utilization') + t.is(summary.currentBtcPrice, 42000, 'should include current BTC price') t.pass() }) -test('calculateSubsidyFeesSummary - handles empty log', (t) => { - const summary = calculateSubsidyFeesSummary([]) - t.is(summary.totalBlockReward, 0, 'should be zero') - t.is(summary.totalBlockTotalFees, 0, 'should be zero') - t.is(summary.avgBlockReward, null, 'should be null') - t.is(summary.avgBlockTotalFees, null, 'should be null') +test('calculateDetailedRevenueSummary - handles empty log', (t) => { + const summary = calculateDetailedRevenueSummary([], 42000) + t.is(summary.totalRevenueBTC, 0, 'should be zero') + t.is(summary.totalRevenueUSD, 0, 'should be zero') + t.is(summary.totalFeesBTC, 0, 'should be zero') + t.is(summary.avgCostPerMWh, null, 'should be null') + t.is(summary.currentBtcPrice, 42000, 'should include current price') t.pass() }) diff --git a/tests/unit/handlers/finance.utils.test.js b/tests/unit/handlers/finance.utils.test.js new file mode 100644 index 0000000..b82f793 --- /dev/null +++ b/tests/unit/handlers/finance.utils.test.js @@ -0,0 +1,251 @@ +'use strict' + +const test = require('brittle') +const { + validateStartEnd, + normalizeTimestampMs, + processTransactions, + extractCurrentPrice, + processBlockData +} = require('../../../workers/lib/server/handlers/finance.utils') + +// ==================== validateStartEnd ==================== + +test('validateStartEnd - valid params', (t) => { + const req = { query: { start: 1700000000000, end: 1700100000000 } } + const { start, end } = validateStartEnd(req) + t.is(start, 1700000000000, 'should return start') + t.is(end, 1700100000000, 'should return end') + t.pass() +}) + +test('validateStartEnd - missing start throws', (t) => { + const req = { query: { end: 1700100000000 } } + try { + validateStartEnd(req) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END') + } + t.pass() +}) + +test('validateStartEnd - missing end throws', (t) => { + const req = { query: { start: 1700000000000 } } + try { + validateStartEnd(req) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END') + } + t.pass() +}) + +test('validateStartEnd - invalid range throws', (t) => { + const req = { query: { start: 1700100000000, end: 1700000000000 } } + try { + validateStartEnd(req) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE') + } + t.pass() +}) + +// ==================== normalizeTimestampMs ==================== + +test('normalizeTimestampMs - falsy input returns 0', (t) => { + t.is(normalizeTimestampMs(0), 0) + t.is(normalizeTimestampMs(null), 0) + t.is(normalizeTimestampMs(undefined), 0) + t.pass() +}) + +test('normalizeTimestampMs - seconds to ms conversion', (t) => { + const ts = normalizeTimestampMs(1700006400) + t.is(ts, 1700006400000, 'should multiply by 1000') + t.pass() +}) + +test('normalizeTimestampMs - ms passthrough', (t) => { + const ts = normalizeTimestampMs(1700006400000) + t.is(ts, 1700006400000, 'should leave ms unchanged') + t.pass() +}) + +// ==================== processTransactions ==================== + +test('processTransactions - Ocean data (sats)', (t) => { + const results = [ + [{ transactions: [{ ts: 1700006400000, satoshis_net_earned: 50000000 }] }] + ] + const daily = processTransactions(results) + const key = Object.keys(daily)[0] + t.is(daily[key].revenueBTC, 0.5, 'should convert sats to BTC') + t.is(daily[key].feesBTC, undefined, 'should not track fees by default') + t.pass() +}) + +test('processTransactions - F2Pool data (BTC)', (t) => { + const results = [ + [{ transactions: [{ created_at: 1700006400, changed_balance: 0.001 }] }] + ] + const daily = processTransactions(results) + const key = Object.keys(daily)[0] + t.is(daily[key].revenueBTC, 0.001, 'should use changed_balance directly as BTC') + t.pass() +}) + +test('processTransactions - with trackFees (Ocean data)', (t) => { + const results = [ + [{ + transactions: [{ + ts: 1700006400000, + satoshis_net_earned: 50000000, + fees_colected_satoshis: 1000000 + }] + }] + ] + const daily = processTransactions(results, { trackFees: true }) + const key = Object.keys(daily)[0] + t.is(daily[key].revenueBTC, 0.5, 'should convert sats to BTC') + t.is(daily[key].feesBTC, 0.01, 'should track fees in BTC') + t.pass() +}) + +test('processTransactions - with trackFees (F2Pool data)', (t) => { + const results = [ + [{ + transactions: [{ + created_at: 1700006400, + changed_balance: 0.001, + mining_extra: { tx_fee: 0.0001 } + }] + }] + ] + const daily = processTransactions(results, { trackFees: true }) + const key = Object.keys(daily)[0] + t.is(daily[key].revenueBTC, 0.001, 'should use changed_balance directly') + t.is(daily[key].feesBTC, 0.0001, 'should extract tx_fee') + t.pass() +}) + +test('processTransactions - seconds timestamps normalized', (t) => { + const results = [ + [{ transactions: [{ ts: 1700006400, changed_balance: 0.001 }] }] + ] + const daily = processTransactions(results) + t.ok(Object.keys(daily).length > 0, 'should have entries from seconds timestamps') + t.pass() +}) + +test('processTransactions - error results skipped', (t) => { + const results = [{ error: 'timeout' }] + const daily = processTransactions(results) + t.is(Object.keys(daily).length, 0, 'should be empty for error results') + t.pass() +}) + +test('processTransactions - null entries skipped', (t) => { + const results = [ + [{ transactions: [null, undefined] }] + ] + const daily = processTransactions(results) + t.is(Object.keys(daily).length, 0, 'should be empty for null entries') + t.pass() +}) + +test('processTransactions - empty results', (t) => { + const daily = processTransactions([]) + t.is(Object.keys(daily).length, 0, 'should be empty') + t.pass() +}) + +// ==================== extractCurrentPrice ==================== + +test('extractCurrentPrice - flat entry format (currentPrice)', (t) => { + const results = [ + [{ currentPrice: 42000, blockHeight: 900000 }] + ] + t.is(extractCurrentPrice(results), 42000, 'should extract currentPrice') + t.pass() +}) + +test('extractCurrentPrice - flat entry format (priceUSD)', (t) => { + const results = [ + [{ priceUSD: 42000 }] + ] + t.is(extractCurrentPrice(results), 42000, 'should extract priceUSD') + t.pass() +}) + +test('extractCurrentPrice - nested EBITDA format (numeric)', (t) => { + const results = [{ data: 42000 }] + t.is(extractCurrentPrice(results), 42000, 'should extract numeric nested price') + t.pass() +}) + +test('extractCurrentPrice - nested EBITDA format (object)', (t) => { + const results = [{ data: { USD: 42000 } }] + t.is(extractCurrentPrice(results), 42000, 'should extract USD from nested object') + t.pass() +}) + +test('extractCurrentPrice - error results return 0', (t) => { + const results = [{ error: 'timeout' }] + t.is(extractCurrentPrice(results), 0, 'should return 0 for error results') + t.pass() +}) + +// ==================== processBlockData ==================== + +test('processBlockData - array items', (t) => { + const results = [ + [{ + blocks: [{ + ts: 1700006400000, + blockReward: 6.25, + blockTotalFees: 0.5 + }] + }] + ] + const daily = processBlockData(results) + const key = Object.keys(daily)[0] + t.is(daily[key].blockReward, 6.25, 'should extract blockReward') + t.is(daily[key].blockTotalFees, 0.5, 'should extract blockTotalFees') + t.pass() +}) + +test('processBlockData - object-keyed items', (t) => { + const results = [ + [{ data: { 1700006400000: { blockReward: 6.25, blockTotalFees: 0.5 } } }] + ] + const daily = processBlockData(results) + const key = Object.keys(daily)[0] + t.is(daily[key].blockReward, 6.25, 'should extract from object keys') + t.is(daily[key].blockTotalFees, 0.5, 'should extract fees from object keys') + t.pass() +}) + +test('processBlockData - alt field names', (t) => { + const results = [ + [{ + blocks: [{ + ts: 1700006400000, + block_reward: 6.25, + total_fees: 0.5 + }] + }] + ] + const daily = processBlockData(results) + const key = Object.keys(daily)[0] + t.is(daily[key].blockReward, 6.25, 'should handle snake_case field') + t.is(daily[key].blockTotalFees, 0.5, 'should handle total_fees field') + t.pass() +}) + +test('processBlockData - error/empty results', (t) => { + t.is(Object.keys(processBlockData([{ error: 'timeout' }])).length, 0, 'error results empty') + t.is(Object.keys(processBlockData([])).length, 0, 'empty results empty') + t.pass() +}) diff --git a/tests/unit/routes/finance.routes.test.js b/tests/unit/routes/finance.routes.test.js index 6ef74b9..08505a4 100644 --- a/tests/unit/routes/finance.routes.test.js +++ b/tests/unit/routes/finance.routes.test.js @@ -19,6 +19,8 @@ test('finance routes - route definitions', (t) => { t.ok(routeUrls.includes('/auth/finance/ebitda'), 'should have ebitda route') t.ok(routeUrls.includes('/auth/finance/cost-summary'), 'should have cost-summary route') t.ok(routeUrls.includes('/auth/finance/subsidy-fees'), 'should have subsidy-fees route') + t.ok(routeUrls.includes('/auth/finance/revenue'), 'should have revenue route') + t.ok(routeUrls.includes('/auth/finance/revenue-summary'), 'should have revenue-summary route') t.pass() }) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 400efe1..b969a5c 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -116,6 +116,8 @@ const ENDPOINTS = { FINANCE_EBITDA: '/auth/finance/ebitda', FINANCE_COST_SUMMARY: '/auth/finance/cost-summary', FINANCE_SUBSIDY_FEES: '/auth/finance/subsidy-fees', + FINANCE_REVENUE: '/auth/finance/revenue', + FINANCE_REVENUE_SUMMARY: '/auth/finance/revenue-summary', // Pools endpoints POOLS: '/auth/pools', diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index 3d29480..20eb291 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -6,7 +6,6 @@ const { PERIOD_TYPES, MINERPOOL_EXT_DATA_KEYS, RPC_METHODS, - BTC_SATS, GLOBAL_DATA_TYPES } = require('../../constants') const { @@ -16,22 +15,20 @@ const { runParallel } = require('../../utils') const { aggregateByPeriod } = require('../../period.utils') +const { + validateStartEnd, + normalizeTimestampMs, + processTransactions, + extractCurrentPrice, + processBlockData +} = require('./finance.utils') // ==================== Energy Balance ==================== async function getEnergyBalance (ctx, req) { - const start = Number(req.query.start) - const end = Number(req.query.end) + const { start, end } = validateStartEnd(req) const period = req.query.period || PERIOD_TYPES.DAILY - 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() @@ -88,7 +85,7 @@ async function getEnergyBalance (ctx, req) { ]) const dailyConsumption = processConsumptionData(consumptionResults) - const dailyTransactions = processTransactionData(transactionResults) + const dailyTransactions = processTransactions(transactionResults) const dailyPrices = processPriceData(priceResults) const currentBtcPrice = extractCurrentPrice(currentPriceResults) const costsByMonth = processCostsData(productionCosts) @@ -194,38 +191,6 @@ function processConsumptionData (results) { return daily } -function normalizeTimestampMs (ts) { - if (!ts) return 0 - return ts < 1e12 ? ts * 1000 : ts -} - -function processTransactionData (results) { - const daily = {} - for (const res of results) { - if (!res || res.error) 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 rawTs = t.ts || t.created_at || t.timestamp || t.time - const ts = getStartOfDay(normalizeTimestampMs(rawTs)) - if (!ts) continue - const day = daily[ts] ??= { revenueBTC: 0 } - if (t.satoshis_net_earned) { - day.revenueBTC += Math.abs(t.satoshis_net_earned) / BTC_SATS - } else { - day.revenueBTC += Math.abs(t.changed_balance || t.amount || t.value || 0) - } - } - } - } - return daily -} - function processPriceData (results) { const daily = {} for (const res of results) { @@ -245,20 +210,6 @@ function processPriceData (results) { return daily } -function extractCurrentPrice (results) { - for (const res of results) { - if (res.error || !res) continue - const data = Array.isArray(res) ? res : [res] - for (const entry of data) { - if (!entry) continue - if (entry.currentPrice) return entry.currentPrice - if (entry.priceUSD) return entry.priceUSD - if (entry.price) return entry.price - } - } - return 0 -} - function processEnergyData (results, aggrField) { const daily = {} for (const res of results) { @@ -362,18 +313,9 @@ function calculateSummary (log) { // ==================== EBITDA ==================== async function getEbitda (ctx, req) { - const start = Number(req.query.start) - const end = Number(req.query.end) + const { start, end } = validateStartEnd(req) 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() @@ -416,10 +358,10 @@ async function getEbitda (ctx, req) { .then(r => cb(null, r)).catch(cb) ]) - const dailyTransactions = processEbitdaTransactions(transactionResults) + const dailyTransactions = processTransactions(transactionResults) const dailyTailLog = processTailLogData(tailLogResults) const dailyPrices = processEbitdaPrices(priceResults) - const currentBtcPrice = extractEbitdaCurrentPrice(currentPriceResults) + const currentBtcPrice = extractCurrentPrice(currentPriceResults) const costsByMonth = processCostsData(productionCosts) const allDays = new Set([ @@ -504,29 +446,6 @@ function processTailLogData (results) { return daily } -function processEbitdaTransactions (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 processEbitdaPrices (results) { const daily = {} for (const res of results) { @@ -556,18 +475,6 @@ function processEbitdaPrices (results) { return daily } -function extractEbitdaCurrentPrice (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 calculateEbitdaSummary (log, currentBtcPrice) { if (!log.length) { return { @@ -604,18 +511,9 @@ function calculateEbitdaSummary (log, currentBtcPrice) { // ==================== Cost Summary ==================== async function getCostSummary (ctx, req) { - const start = Number(req.query.start) - const end = Number(req.query.end) + const { start, end } = validateStartEnd(req) 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() @@ -718,18 +616,9 @@ function calculateCostSummary (log) { // ==================== Subsidy Fees ==================== async function getSubsidyFees (ctx, req) { - const start = Number(req.query.start) - const end = Number(req.query.end) + const { start, end } = validateStartEnd(req) const period = req.query.period || PERIOD_TYPES.DAILY - if (!start || !end) { - throw new Error('ERR_MISSING_START_END') - } - - if (start >= end) { - throw new Error('ERR_INVALID_DATE_RANGE') - } - const blockResults = await requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, query: { key: 'HISTORICAL_BLOCKSIZES', start, end } @@ -754,41 +643,6 @@ async function getSubsidyFees (ctx, req) { return { log: aggregated, summary } } -function processBlockData (results) { - const daily = {} - for (const res of results) { - if (!res || res.error) 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.blocks || entry - if (Array.isArray(items)) { - for (const item of items) { - if (!item) continue - const rawTs = item.ts || item.timestamp || item.time - const ts = getStartOfDay(normalizeTimestampMs(rawTs)) - if (!ts) continue - if (!daily[ts]) daily[ts] = { blockReward: 0, blockTotalFees: 0 } - daily[ts].blockReward += (item.blockReward || item.block_reward || item.subsidy || 0) - daily[ts].blockTotalFees += (item.blockTotalFees || item.block_total_fees || item.totalFees || item.total_fees || 0) - } - } else if (typeof items === 'object' && !Array.isArray(items)) { - for (const [key, val] of Object.entries(items)) { - const ts = getStartOfDay(Number(key)) - if (!ts) continue - if (!daily[ts]) daily[ts] = { blockReward: 0, blockTotalFees: 0 } - if (typeof val === 'object') { - daily[ts].blockReward += (val.blockReward || val.block_reward || val.subsidy || 0) - daily[ts].blockTotalFees += (val.blockTotalFees || val.block_total_fees || val.totalFees || val.total_fees || 0) - } - } - } - } - } - return daily -} - function calculateSubsidyFeesSummary (log) { if (!log.length) { return { @@ -813,6 +667,311 @@ function calculateSubsidyFeesSummary (log) { } } +// ==================== Revenue ==================== + +async function getRevenue (ctx, req) { + const { start, end } = validateStartEnd(req) + const period = req.query.period || PERIOD_TYPES.DAILY + const pool = req.query.pool || null + + const type = pool ? WORKER_TYPES.MINERPOOL + '-' + pool : WORKER_TYPES.MINERPOOL + const query = { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } + + const transactionResults = await requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type, + query + }) + + const dailyRevenue = processTransactions(transactionResults, { trackFees: true }) + + const log = [] + for (const dayTs of Object.keys(dailyRevenue).sort()) { + const ts = Number(dayTs) + const day = dailyRevenue[dayTs] + const revenueBTC = day.revenueBTC || 0 + const feesBTC = day.feesBTC || 0 + log.push({ + ts, + revenueBTC, + feesBTC, + netRevenueBTC: revenueBTC - feesBTC + }) + } + + const aggregated = aggregateByPeriod(log, period) + const summary = calculateRevenueSummary(aggregated) + + return { log: aggregated, summary } +} + +function calculateRevenueSummary (log) { + if (!log.length) { + return { + totalRevenueBTC: 0, + totalFeesBTC: 0, + totalNetRevenueBTC: 0 + } + } + + const totals = log.reduce((acc, entry) => { + acc.revenueBTC += entry.revenueBTC || 0 + acc.feesBTC += entry.feesBTC || 0 + acc.netRevenueBTC += entry.netRevenueBTC || 0 + return acc + }, { revenueBTC: 0, feesBTC: 0, netRevenueBTC: 0 }) + + return { + totalRevenueBTC: totals.revenueBTC, + totalFeesBTC: totals.feesBTC, + totalNetRevenueBTC: totals.netRevenueBTC + } +} + +// ==================== Revenue Summary ==================== + +async function getRevenueSummary (ctx, req) { + const { start, end } = validateStartEnd(req) + const period = req.query.period || PERIOD_TYPES.DAILY + + const startDate = new Date(start).toISOString() + const endDate = new Date(end).toISOString() + + const [ + transactionResults, + priceResults, + currentPriceResults, + tailLogResults, + productionCosts, + blockResults, + activeEnergyInResults, + uteEnergyResults, + globalConfigResults + ] = 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.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'HISTORICAL_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) => 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) => getProductionCosts(ctx, start, end) + .then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'HISTORICAL_BLOCKSIZES', start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.ELECTRICITY, + query: { key: 'stats-history', start, end, groupRange: '1D' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.ELECTRICITY, + query: { key: 'stats-history', start, end, groupRange: '1D' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GLOBAL_CONFIG, {}) + .then(r => cb(null, r)).catch(cb) + ]) + + const dailyRevenue = processTransactions(transactionResults, { trackFees: true }) + const dailyPrices = processEbitdaPrices(priceResults) + const currentBtcPrice = extractCurrentPrice(currentPriceResults) + const dailyTailLog = processTailLogData(tailLogResults) + const costsByMonth = processCostsData(productionCosts) + const dailyBlocks = processBlockData(blockResults) + const dailyActiveEnergyIn = processEnergyData(activeEnergyInResults, AGGR_FIELDS.ACTIVE_ENERGY_IN) + const dailyUteEnergy = processEnergyData(uteEnergyResults, AGGR_FIELDS.UTE_ENERGY) + const nominalPowerMW = extractNominalPower(globalConfigResults) + + const allDays = new Set([ + ...Object.keys(dailyRevenue), + ...Object.keys(dailyTailLog), + ...Object.keys(dailyPrices) + ]) + + const log = [] + for (const dayTs of [...allDays].sort()) { + const ts = Number(dayTs) + const revenue = dailyRevenue[dayTs] || {} + const tailLog = dailyTailLog[dayTs] || {} + const btcPrice = dailyPrices[dayTs] || currentBtcPrice || 0 + const block = dailyBlocks[dayTs] || {} + + const revenueBTC = revenue.revenueBTC || 0 + const feesBTC = revenue.feesBTC || 0 + const revenueUSD = revenueBTC * btcPrice + const feesUSD = feesBTC * btcPrice + + const powerW = tailLog.powerW || 0 + const consumptionMWh = (powerW * 24) / 1000000 + const hashrateMhs = tailLog.hashrateMhs || 0 + const hashratePhs = hashrateMhs / 1e9 + + const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` + const costs = costsByMonth[monthKey] || {} + const energyCostsUSD = costs.energyCostPerDay || 0 + const operationalCostsUSD = costs.operationalCostPerDay || 0 + const totalCostsUSD = energyCostsUSD + operationalCostsUSD + + const activeEnergyIn = dailyActiveEnergyIn[dayTs] || 0 + const uteEnergy = dailyUteEnergy[dayTs] || 0 + + const curtailmentMWh = activeEnergyIn > 0 + ? activeEnergyIn - consumptionMWh + : null + const curtailmentRate = curtailmentMWh !== null + ? safeDiv(curtailmentMWh, consumptionMWh) + : null + + const operationalIssuesRate = uteEnergy > 0 + ? safeDiv(uteEnergy - consumptionMWh, uteEnergy) + : null + + const actualPowerMW = powerW / 1000000 + const powerUtilization = nominalPowerMW > 0 + ? safeDiv(actualPowerMW, nominalPowerMW) + : null + + log.push({ + ts, + revenueBTC, + feesBTC, + revenueUSD, + feesUSD, + btcPrice, + powerW, + consumptionMWh, + hashrateMhs, + energyCostsUSD, + operationalCostsUSD, + totalCostsUSD, + ebitdaSelling: revenueUSD - totalCostsUSD, + ebitdaHodl: (revenueBTC * currentBtcPrice) - totalCostsUSD, + btcProductionCost: safeDiv(totalCostsUSD, revenueBTC), + energyRevenuePerMWh: safeDiv(revenueUSD, consumptionMWh), + allInCostPerMWh: safeDiv(totalCostsUSD, consumptionMWh), + hashRevenueBTCPerPHsPerDay: safeDiv(revenueBTC, hashratePhs), + hashRevenueUSDPerPHsPerDay: safeDiv(revenueUSD, hashratePhs), + blockReward: block.blockReward || 0, + blockTotalFees: block.blockTotalFees || 0, + curtailmentMWh, + curtailmentRate, + operationalIssuesRate, + powerUtilization + }) + } + + const aggregated = aggregateByPeriod(log, period) + const summary = calculateDetailedRevenueSummary(aggregated, currentBtcPrice) + + return { log: aggregated, summary } +} + +function calculateDetailedRevenueSummary (log, currentBtcPrice) { + if (!log.length) { + return { + totalRevenueBTC: 0, + totalRevenueUSD: 0, + totalFeesBTC: 0, + totalFeesUSD: 0, + totalCostsUSD: 0, + totalConsumptionMWh: 0, + avgCostPerMWh: null, + avgRevenuePerMWh: null, + avgBtcPrice: null, + avgCurtailmentRate: null, + avgPowerUtilization: null, + totalEbitdaSelling: 0, + totalEbitdaHodl: 0, + currentBtcPrice: currentBtcPrice || 0 + } + } + + const totals = log.reduce((acc, entry) => { + acc.revenueBTC += entry.revenueBTC || 0 + acc.revenueUSD += entry.revenueUSD || 0 + acc.feesBTC += entry.feesBTC || 0 + acc.feesUSD += entry.feesUSD || 0 + acc.costsUSD += entry.totalCostsUSD || 0 + acc.consumptionMWh += entry.consumptionMWh || 0 + acc.ebitdaSelling += entry.ebitdaSelling || 0 + acc.ebitdaHodl += entry.ebitdaHodl || 0 + acc.btcPriceSum += entry.btcPrice || 0 + acc.btcPriceCount += entry.btcPrice ? 1 : 0 + if (entry.curtailmentRate !== null && entry.curtailmentRate !== undefined) { + acc.curtailmentRateSum += entry.curtailmentRate + acc.curtailmentRateCount++ + } + if (entry.powerUtilization !== null && entry.powerUtilization !== undefined) { + acc.powerUtilizationSum += entry.powerUtilization + acc.powerUtilizationCount++ + } + return acc + }, { + revenueBTC: 0, + revenueUSD: 0, + feesBTC: 0, + feesUSD: 0, + costsUSD: 0, + consumptionMWh: 0, + ebitdaSelling: 0, + ebitdaHodl: 0, + btcPriceSum: 0, + btcPriceCount: 0, + curtailmentRateSum: 0, + curtailmentRateCount: 0, + powerUtilizationSum: 0, + powerUtilizationCount: 0 + }) + + return { + totalRevenueBTC: totals.revenueBTC, + totalRevenueUSD: totals.revenueUSD, + totalFeesBTC: totals.feesBTC, + totalFeesUSD: totals.feesUSD, + totalCostsUSD: totals.costsUSD, + totalConsumptionMWh: totals.consumptionMWh, + avgCostPerMWh: safeDiv(totals.costsUSD, totals.consumptionMWh), + avgRevenuePerMWh: safeDiv(totals.revenueUSD, totals.consumptionMWh), + avgBtcPrice: safeDiv(totals.btcPriceSum, totals.btcPriceCount), + avgCurtailmentRate: safeDiv(totals.curtailmentRateSum, totals.curtailmentRateCount), + avgPowerUtilization: safeDiv(totals.powerUtilizationSum, totals.powerUtilizationCount), + totalEbitdaSelling: totals.ebitdaSelling, + totalEbitdaHodl: totals.ebitdaHodl, + currentBtcPrice: currentBtcPrice || 0 + } +} + // ==================== Shared ==================== async function getProductionCosts (ctx, start, end) { @@ -851,21 +1010,26 @@ module.exports = { getEbitda, getCostSummary, getSubsidyFees, + getRevenue, + getRevenueSummary, getProductionCosts, processConsumptionData, - processTransactionData, processPriceData, - extractCurrentPrice, processEnergyData, extractNominalPower, processCostsData, calculateSummary, processTailLogData, - processEbitdaTransactions, processEbitdaPrices, - extractEbitdaCurrentPrice, calculateEbitdaSummary, calculateCostSummary, - processBlockData, - calculateSubsidyFeesSummary + calculateSubsidyFeesSummary, + calculateRevenueSummary, + calculateDetailedRevenueSummary, + // Re-export from finance.utils + validateStartEnd, + normalizeTimestampMs, + processTransactions, + extractCurrentPrice, + processBlockData } diff --git a/workers/lib/server/handlers/finance.utils.js b/workers/lib/server/handlers/finance.utils.js new file mode 100644 index 0000000..8b6746c --- /dev/null +++ b/workers/lib/server/handlers/finance.utils.js @@ -0,0 +1,130 @@ +'use strict' + +const { BTC_SATS } = require('../../constants') +const { getStartOfDay } = require('../../utils') + +function validateStartEnd (req) { + const start = Number(req.query.start) + const end = Number(req.query.end) + + if (!start || !end) { + throw new Error('ERR_MISSING_START_END') + } + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + return { start, end } +} + +function normalizeTimestampMs (ts) { + if (!ts) return 0 + return ts < 1e12 ? ts * 1000 : ts +} + +function processTransactions (results, opts) { + const trackFees = opts && opts.trackFees + const daily = {} + for (const res of results) { + if (!res || res.error) 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 rawTs = t.ts || t.created_at || t.timestamp || t.time + const ts = getStartOfDay(normalizeTimestampMs(rawTs)) + if (!ts) continue + const day = daily[ts] ??= trackFees + ? { revenueBTC: 0, feesBTC: 0 } + : { revenueBTC: 0 } + if (t.satoshis_net_earned) { + day.revenueBTC += Math.abs(t.satoshis_net_earned) / BTC_SATS + if (trackFees) { + day.feesBTC += (t.fees_colected_satoshis || 0) / BTC_SATS + } + } else { + day.revenueBTC += Math.abs(t.changed_balance || t.amount || t.value || 0) + if (trackFees) { + day.feesBTC += (t.mining_extra?.tx_fee || 0) + } + } + } + } + } + return daily +} + +function extractCurrentPrice (results) { + for (const res of results) { + if (!res || res.error) continue + + // Flat entry format: [{currentPrice: N}, {priceUSD: N}, {price: N}] + const data = Array.isArray(res) ? res : [res] + for (const entry of data) { + if (!entry) continue + if (entry.currentPrice) return entry.currentPrice + if (entry.priceUSD) return entry.priceUSD + if (entry.price) return entry.price + + // Nested EBITDA format: {data: N} or {data: {USD: N}} or {result: ...} + const nested = entry.data || entry.result + if (nested) { + if (typeof nested === 'number') return nested + if (typeof nested === 'object') { + if (nested.USD) return nested.USD + if (nested.price) return nested.price + if (nested.current_price) return nested.current_price + } + } + } + } + return 0 +} + +function processBlockData (results) { + const daily = {} + for (const res of results) { + if (!res || res.error) 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.blocks || entry + if (Array.isArray(items)) { + for (const item of items) { + if (!item) continue + const rawTs = item.ts || item.timestamp || item.time + const ts = getStartOfDay(normalizeTimestampMs(rawTs)) + if (!ts) continue + if (!daily[ts]) daily[ts] = { blockReward: 0, blockTotalFees: 0 } + daily[ts].blockReward += (item.blockReward || item.block_reward || item.subsidy || 0) + daily[ts].blockTotalFees += (item.blockTotalFees || item.block_total_fees || item.totalFees || item.total_fees || 0) + } + } else if (typeof items === 'object' && !Array.isArray(items)) { + for (const [key, val] of Object.entries(items)) { + const ts = getStartOfDay(Number(key)) + if (!ts) continue + if (!daily[ts]) daily[ts] = { blockReward: 0, blockTotalFees: 0 } + if (typeof val === 'object') { + daily[ts].blockReward += (val.blockReward || val.block_reward || val.subsidy || 0) + daily[ts].blockTotalFees += (val.blockTotalFees || val.block_total_fees || val.totalFees || val.total_fees || 0) + } + } + } + } + } + return daily +} + +module.exports = { + validateStartEnd, + normalizeTimestampMs, + processTransactions, + extractCurrentPrice, + processBlockData +} diff --git a/workers/lib/server/routes/finance.routes.js b/workers/lib/server/routes/finance.routes.js index 7b05e02..e31b4a0 100644 --- a/workers/lib/server/routes/finance.routes.js +++ b/workers/lib/server/routes/finance.routes.js @@ -8,7 +8,9 @@ const { getEnergyBalance, getEbitda, getCostSummary, - getSubsidyFees + getSubsidyFees, + getRevenue, + getRevenueSummary } = require('../handlers/finance.handlers') const { createCachedAuthRoute } = require('../lib/routeHelpers') @@ -87,6 +89,43 @@ module.exports = (ctx) => { ENDPOINTS.FINANCE_SUBSIDY_FEES, getSubsidyFees ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.FINANCE_REVENUE, + schema: { + querystring: schemas.query.revenue + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'finance/revenue', + req.query.start, + req.query.end, + req.query.period, + req.query.pool + ], + ENDPOINTS.FINANCE_REVENUE, + getRevenue + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.FINANCE_REVENUE_SUMMARY, + schema: { + querystring: schemas.query.revenueSummary + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'finance/revenue-summary', + req.query.start, + req.query.end, + req.query.period + ], + ENDPOINTS.FINANCE_REVENUE_SUMMARY, + getRevenueSummary + ) } ] } diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js index b7d3fc2..bde6d99 100644 --- a/workers/lib/server/schemas/finance.schemas.js +++ b/workers/lib/server/schemas/finance.schemas.js @@ -41,6 +41,27 @@ const schemas = { overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] + }, + revenue: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + period: { type: 'string', enum: ['daily', 'weekly', 'monthly', 'yearly'] }, + pool: { type: 'string' }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + }, + revenueSummary: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] } } }