Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions tests/unit/handlers/finance.handlers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
56 changes: 56 additions & 0 deletions tests/unit/handlers/finance.utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }] }]
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/lib/period.utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
10 changes: 5 additions & 5 deletions workers/lib/period.utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,19 +97,19 @@ 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}`)
}

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}`)
}
Expand Down
27 changes: 19 additions & 8 deletions workers/lib/server/handlers/finance.handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions workers/lib/server/handlers/finance.utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading