From 90bc90412bab8674c229f0a49ef809225e244587 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Wed, 26 Aug 2026 23:17:52 +0300 Subject: [PATCH 1/2] Fix: parse ISO-8601 and string worker timestamps instead of dropping the record Ocean stamps pool earnings with an ISO-8601 string, which normalizeTimestampMs returned unchanged, turning into NaN in getStartOfDay so every earning was silently skipped. Numeric strings failed the same way. ISO strings carry no timezone designator and are treated as UTC rather than host-local, and processDailyRevenueBtc no longer coerces with Number() before normalizing. --- tests/unit/handlers/finance.utils.test.js | 56 +++++++++++++++++++ .../lib/server/handlers/finance.handlers.js | 2 +- workers/lib/server/handlers/finance.utils.js | 18 ++++++ 3 files changed, 75 insertions(+), 1 deletion(-) 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/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index ccd4f84..1529da2 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -1273,7 +1273,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 } From 7c9f7c1b17998903ea83c48785d6aebe54e974a8 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 27 Aug 2026 00:02:38 +0300 Subject: [PATCH 2/2] Fix: read the historical price key, and bucket finance periods in UTC Three defects on the same finance data contract: - getEbitda and getHashRevenue asked the mempool worker for key 'prices', which is the one field its fallback reply destructures away. Both fell back to the current spot price, valuing every historical day at today's rate. The other four call sites already used HISTORICAL_PRICES. - aggregateByPeriod grouped and stamped monthly/yearly buckets with local-time getters over UTC day timestamps, so west of UTC a day's revenue could land in the previous month. The weekly branch was already UTC. - The production-cost month key was derived with local getters at four sites, pricing a UTC first-of-month against the previous month's costs and LCOE. Also adds powerW, energyRevenuePerMWh and allInCostPerMWh to the energy-balance meanKeys, so rate columns are averaged over a period rather than summed, as revenue-summary already does. --- tests/unit/handlers/finance.handlers.test.js | 33 +++++++++++++++++++ tests/unit/lib/period.utils.test.js | 29 ++++++++++++++++ workers/lib/period.utils.js | 10 +++--- .../lib/server/handlers/finance.handlers.js | 25 ++++++++++---- 4 files changed, 85 insertions(+), 12 deletions(-) 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/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 1529da2..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)