diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js index de84b0a..e0fa401 100644 --- a/tests/unit/handlers/finance.handlers.test.js +++ b/tests/unit/handlers/finance.handlers.test.js @@ -1681,3 +1681,36 @@ test('getEbitda - a monthly LCOE override only moves its own month', async (t) = t.pass() }) + +// The mempool worker only serves HISTORICAL_* keys and destructures `prices` out of its +// fallback reply, so asking for 'prices' returned nothing and every historical day was +// silently valued at today's spot price. +test('getEbitda and getHashRevenue request the historical price key the mempool worker serves', async (t) => { + const requestedKeys = [] + const dayTs = 1700006400000 + + const makeCtx = () => withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'tailLog') return [{ ts: dayTs, site_power_w: 5000, hashrate_mhs_5m_sum_aggr: 100 }] + if (method === 'getWrkExtData') { + const qKey = payload.query && payload.query.key + if (payload.type === 'mempool') requestedKeys.push(qKey) + if (qKey === 'transactions') return [{ ts: dayTs, transactions: [{ ts: dayTs, changed_balance: 0.5 }] }] + if (qKey === 'HISTORICAL_PRICES') return [{ ts: dayTs, priceUSD: 61000 }] + } + return [] + } + }, + globalDataLib: { getGlobalData: async () => [] } + }) + + const req = { query: { start: dayTs - 86400000, end: dayTs + 86400000 } } + await getEbitda(makeCtx(), req, {}) + await getHashRevenue(makeCtx(), req, {}) + + t.ok(requestedKeys.includes('HISTORICAL_PRICES'), 'asks for the key the worker actually serves') + t.absent(requestedKeys.includes('prices'), 'never asks for the stripped `prices` key') + t.pass() +}) diff --git a/tests/unit/handlers/finance.utils.test.js b/tests/unit/handlers/finance.utils.test.js index 65a0adb..ccff0c1 100644 --- a/tests/unit/handlers/finance.utils.test.js +++ b/tests/unit/handlers/finance.utils.test.js @@ -73,8 +73,64 @@ test('normalizeTimestampMs - ms passthrough', (t) => { t.pass() }) +test('normalizeTimestampMs - Ocean ISO-8601 string without a timezone is read as UTC', (t) => { + t.is(normalizeTimestampMs('2026-05-28T16:46:30'), Date.UTC(2026, 4, 28, 16, 46, 30)) + t.pass() +}) + +test('normalizeTimestampMs - ISO-8601 string with an explicit zone keeps that zone', (t) => { + t.is(normalizeTimestampMs('2026-05-28T16:46:30Z'), Date.UTC(2026, 4, 28, 16, 46, 30)) + t.is(normalizeTimestampMs('2026-05-28T16:46:30+02:00'), Date.UTC(2026, 4, 28, 14, 46, 30)) + t.pass() +}) + +test('normalizeTimestampMs - numeric strings normalize like numbers', (t) => { + t.is(normalizeTimestampMs('1700006400'), 1700006400000, 'seconds as a string') + t.is(normalizeTimestampMs('1700006400000'), 1700006400000, 'ms as a string') + t.pass() +}) + +test('normalizeTimestampMs - unparseable input returns 0 rather than NaN', (t) => { + t.is(normalizeTimestampMs('not-a-date'), 0) + t.is(normalizeTimestampMs(NaN), 0) + t.is(normalizeTimestampMs(Infinity), 0) + t.is(normalizeTimestampMs({}), 0) + t.pass() +}) + // ==================== processTransactions ==================== +test('processTransactions - Ocean earnings dated by ISO string are not dropped', (t) => { + const results = [ + [{ + ts: 1779926400000, + transactions: [ + { ts: '2026-05-28T16:46:30', satoshis_net_earned: 2632155, fees_colected_satoshis: 27238 } + ] + }] + ] + + const daily = processTransactions(results, { trackFees: true }) + const day = daily[Date.UTC(2026, 4, 28)] + t.ok(day, 'the ISO-dated earning lands on its own UTC day') + t.is(day.revenueBTC, 2632155 / 1e8) + t.is(day.feesBTC, 27238 / 1e8) + t.pass() +}) + +test('processTransactions - mixed f2pool and Ocean racks both contribute', (t) => { + const results = [ + [{ transactions: [{ created_at: 1785621600, changed_balance: 0.0001, mining_extra: { tx_fee: 0.000001 } }] }], + [{ transactions: [{ ts: '2026-05-28T16:46:30', satoshis_net_earned: 100000000 }] }] + ] + + const daily = processTransactions(results, { trackFees: true }) + const total = Object.values(daily).reduce((sum, d) => sum + d.revenueBTC, 0) + t.is(Object.keys(daily).length, 2, 'one day per pool') + t.is(total, 1.0001, 'both pools counted') + t.pass() +}) + test('processTransactions - Ocean data (sats)', (t) => { const results = [ [{ transactions: [{ ts: 1700006400000, satoshis_net_earned: 50000000 }] }] diff --git a/tests/unit/lib/period.utils.test.js b/tests/unit/lib/period.utils.test.js index c13f7e0..011e60c 100644 --- a/tests/unit/lib/period.utils.test.js +++ b/tests/unit/lib/period.utils.test.js @@ -211,3 +211,32 @@ test('getPeriodEndDate - yearly returns next year', (t) => { t.is(result.getFullYear(), 2024, 'should be next year') t.pass() }) + +test('aggregateByPeriod - monthly buckets are grouped and stamped in UTC', (t) => { + const log = [ + { ts: Date.UTC(2026, 7, 1), revenueBTC: 1 }, + { ts: Date.UTC(2026, 7, 2), revenueBTC: 2 } + ] + + const [month] = aggregateByPeriod(log, 'monthly') + + t.is(month.ts, Date.UTC(2026, 7, 1), 'stamped on the UTC first of the month') + t.is(month.month, 8) + t.is(month.monthName, 'August', 'named from the UTC month, not the host month') + t.is(month.revenueBTC, 3, 'both UTC days land in the same bucket') + t.pass() +}) + +test('aggregateByPeriod - yearly buckets are grouped and stamped in UTC', (t) => { + const log = [ + { ts: Date.UTC(2026, 0, 1), revenueBTC: 1 }, + { ts: Date.UTC(2026, 11, 31), revenueBTC: 2 } + ] + + const [year] = aggregateByPeriod(log, 'yearly') + + t.is(year.ts, Date.UTC(2026, 0, 1), 'stamped on the UTC first of the year') + t.is(year.year, 2026) + t.is(year.revenueBTC, 3, 'both UTC days land in the same bucket') + t.pass() +}) diff --git a/workers/lib/period.utils.js b/workers/lib/period.utils.js index 583b610..3d5701b 100644 --- a/workers/lib/period.utils.js +++ b/workers/lib/period.utils.js @@ -49,9 +49,9 @@ const aggregateByPeriod = (log, period, nonMetricKeys = [], options = {}) => { let groupKey if (period === PERIOD_TYPES.MONTHLY) { - groupKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + groupKey = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}` } else if (period === PERIOD_TYPES.YEARLY) { - groupKey = `${date.getFullYear()}` + groupKey = `${date.getUTCFullYear()}` } else if (period === PERIOD_TYPES.WEEKLY) { const day = date.getUTCDay() const diff = date.getUTCDate() - day @@ -97,7 +97,7 @@ const aggregateByPeriod = (log, period, nonMetricKeys = [], options = {}) => { if (period === PERIOD_TYPES.MONTHLY) { const [year, month] = groupKey.split('-').map(Number) - const newDate = new Date(year, month - 1, 1) + const newDate = new Date(Date.UTC(year, month - 1, 1)) if (isNaN(newDate.getTime())) { throw new Error(`Invalid date for monthly grouping: ${groupKey}`) } @@ -105,11 +105,11 @@ const aggregateByPeriod = (log, period, nonMetricKeys = [], options = {}) => { aggregated.ts = newDate.getTime() aggregated.month = month aggregated.year = year - aggregated.monthName = newDate.toLocaleString('en-US', { month: 'long' }) + aggregated.monthName = newDate.toLocaleString('en-US', { month: 'long', timeZone: 'UTC' }) } else if (period === PERIOD_TYPES.YEARLY) { const year = parseInt(groupKey) - const newDate = new Date(year, 0, 1) + const newDate = new Date(Date.UTC(year, 0, 1)) if (isNaN(newDate.getTime())) { throw new Error(`Invalid date for yearly grouping: ${groupKey}`) } diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index ccd4f84..cafba9d 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -110,7 +110,7 @@ async function getEnergyBalance (ctx, req) { const revenueBTC = transactions.revenueBTC || 0 const revenueUSD = revenueBTC * btcPrice - const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` + const monthKey = getMonthKeyUtc(ts) const costs = costsByMonth[monthKey] || {} const energyCostUSD = resolveEnergyCostsUSD(costs, powerMWh, resolveLcoeUsdPerMwh(costParameters, monthKey)) const totalCostUSD = energyCostUSD + (costs.operationalCostPerDay || 0) @@ -156,7 +156,10 @@ async function getEnergyBalance (ctx, req) { } const aggregated = aggregateByPeriod(log, period, [], { - meanKeys: ['sitePowerMW', 'btcPrice', 'curtailmentRate', 'operationalIssuesRate', 'powerUtilization'] + meanKeys: [ + 'sitePowerMW', 'powerW', 'btcPrice', 'energyRevenuePerMWh', 'allInCostPerMWh', + 'curtailmentRate', 'operationalIssuesRate', 'powerUtilization' + ] }) for (const entry of aggregated) { @@ -327,7 +330,7 @@ async function getEbitda (ctx, req) { (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, - query: { key: 'prices', start, end } + query: { key: 'HISTORICAL_PRICES', start, end } }).then(r => cb(null, r)).catch(cb), (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { @@ -365,7 +368,7 @@ async function getEbitda (ctx, req) { const hashrateMhs = dailyHashrate[dayTs] || 0 const powerMWh = (powerW * 24) / 1000000 - const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` + const monthKey = getMonthKeyUtc(ts) const costs = costsByMonth[monthKey] || {} const energyCostsUSD = resolveEnergyCostsUSD(costs, powerMWh, resolveLcoeUsdPerMwh(costParameters, monthKey)) const operationalCostsUSD = costs.operationalCostPerDay || 0 @@ -502,7 +505,7 @@ async function getCostSummary (ctx, req) { const powerW = dailyConsumption[dayTs] || 0 const consumptionMWh = (powerW * 24) / 1000000 - const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` + const monthKey = getMonthKeyUtc(ts) const costs = costsByMonth[monthKey] || {} const energyCostsUSD = resolveEnergyCostsUSD(costs, consumptionMWh, resolveLcoeUsdPerMwh(costParameters, monthKey)) const operationalCostsUSD = costs.operationalCostPerDay || 0 @@ -827,7 +830,7 @@ async function getRevenueSummary (ctx, req) { const hashrateMhs = dailyHashrate[dayTs] || 0 const hashratePhs = hashrateMhs / 1e9 - const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` + const monthKey = getMonthKeyUtc(ts) const costs = costsByMonth[monthKey] || {} const energyCostsUSD = resolveEnergyCostsUSD(costs, consumptionMWh, resolveLcoeUsdPerMwh(costParameters, monthKey)) const operationalCostsUSD = costs.operationalCostPerDay || 0 @@ -992,7 +995,7 @@ async function getHashRevenue (ctx, req) { (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, - query: { key: 'prices', start, end } + query: { key: 'HISTORICAL_PRICES', start, end } }).then(r => cb(null, r)).catch(cb), (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { @@ -1183,6 +1186,14 @@ function calculateHashRevenueSummary (log) { const WATTS_PER_MW = 1e6 const HOURS_PER_DAY = 24 +// Day timestamps are on the UTC grid, and costsByMonth / costParameters.overrides are keyed by +// calendar month, so the key has to be derived in UTC too - local getters move a UTC midnight +// into the previous month on any host west of UTC. +function getMonthKeyUtc (ts) { + const date = new Date(ts) + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}` +} + function getStartOfMonthUtc (ts) { const date = new Date(ts) return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1) @@ -1273,7 +1284,7 @@ function processDailyRevenueBtc (results, start, end) { if (!Array.isArray(data)) continue for (const entry of data) { if (!entry || !entry.ts || !Array.isArray(entry.transactions)) continue - const dayTs = getStartOfDay(normalizeTimestampMs(Number(entry.ts))) + const dayTs = getStartOfDay(normalizeTimestampMs(entry.ts)) if (!dayTs || dayTs < startDay || dayTs > endDay) continue let revenueBTC = 0 for (const tx of entry.transactions) { diff --git a/workers/lib/server/handlers/finance.utils.js b/workers/lib/server/handlers/finance.utils.js index 853c8e5..5877b7f 100644 --- a/workers/lib/server/handlers/finance.utils.js +++ b/workers/lib/server/handlers/finance.utils.js @@ -18,8 +18,26 @@ function validateStartEnd (req) { return { start, end } } +// Worker timestamps arrive in whatever shape the upstream pool API uses: unix seconds +// (f2pool `created_at`), unix ms, or an ISO-8601 string (ocean `ts`, e.g. "2026-05-28T16:46:30"). +// Anything this returns unparsed becomes NaN in getStartOfDay and the record is dropped without +// a trace, so every shape has to be handled here rather than at the call sites. function normalizeTimestampMs (ts) { if (!ts) return 0 + + if (typeof ts === 'string') { + const numeric = Number(ts) + if (!Number.isNaN(numeric)) return normalizeTimestampMs(numeric) + + // Ocean sends no timezone designator; these timestamps are UTC, and Date.parse would + // otherwise read them as host-local and shift the day bucket. + const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/.test(ts) + const parsed = Date.parse(hasZone ? ts : `${ts}Z`) + return Number.isNaN(parsed) ? 0 : parsed + } + + if (typeof ts !== 'number' || !Number.isFinite(ts)) return 0 + return ts < 1e12 ? ts * 1000 : ts }