From 5ef0bafeb76727cc04a445a602062c4e6b16ae89 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Wed, 11 Feb 2026 04:53:26 +0300 Subject: [PATCH 1/5] feat: add GET /auth/pools/:pool/balance-history endpoint Add pool balance history API endpoint that fetches pool balance snapshots via tailLog RPC, groups them into time buckets (1D/1W/1M), and returns time-series data with balance and revenue per bucket. --- tests/unit/handlers/pools.handlers.test.js | 143 ++++++++++++++++++ tests/unit/routes/pools.routes.test.js | 39 +++++ workers/lib/constants.js | 19 ++- workers/lib/server/handlers/pools.handlers.js | 93 ++++++++++++ workers/lib/server/index.js | 4 +- workers/lib/server/routes/pools.routes.js | 36 +++++ workers/lib/server/schemas/pools.schemas.js | 18 +++ workers/lib/utils.js | 5 +- 8 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 tests/unit/handlers/pools.handlers.test.js create mode 100644 tests/unit/routes/pools.routes.test.js create mode 100644 workers/lib/server/handlers/pools.handlers.js create mode 100644 workers/lib/server/routes/pools.routes.js create mode 100644 workers/lib/server/schemas/pools.schemas.js diff --git a/tests/unit/handlers/pools.handlers.test.js b/tests/unit/handlers/pools.handlers.test.js new file mode 100644 index 0000000..1dcbe96 --- /dev/null +++ b/tests/unit/handlers/pools.handlers.test.js @@ -0,0 +1,143 @@ +'use strict' + +const test = require('brittle') +const { + getPoolBalanceHistory, + flattenSnapshots, + groupByBucket +} = require('../../../workers/lib/server/handlers/pools.handlers') + +test('getPoolBalanceHistory - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + return [{ data: [{ ts: 1700006400000, balance: 50000, revenue: 100, tag: 'pool1' }] }] + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, range: '1D' }, + params: {} + } + + const result = await getPoolBalanceHistory(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(Array.isArray(result.log), 'log should be array') + t.pass() +}) + +test('getPoolBalanceHistory - with pool filter', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async () => { + return [{ + data: [ + { ts: 1700006400000, balance: 50000, tag: 'pool1' }, + { ts: 1700006400000, balance: 30000, tag: 'pool2' } + ] + }] + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000 }, + params: { pool: 'pool1' } + } + + const result = await getPoolBalanceHistory(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.pass() +}) + +test('getPoolBalanceHistory - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getPoolBalanceHistory(mockCtx, { query: { end: 1700100000000 }, params: {} }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getPoolBalanceHistory - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getPoolBalanceHistory(mockCtx, { query: { start: 1700100000000, end: 1700000000000 }, params: {} }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getPoolBalanceHistory - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + } + + const result = await getPoolBalanceHistory(mockCtx, { query: { start: 1700000000000, end: 1700100000000 }, params: {} }, {}) + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty') + t.pass() +}) + +test('flattenSnapshots - flattens ork results', (t) => { + const results = [ + [{ data: [{ ts: 1, balance: 100 }, { ts: 2, balance: 200 }] }] + ] + const flat = flattenSnapshots(results) + t.ok(flat.length >= 1, 'should flatten snapshots') + t.pass() +}) + +test('flattenSnapshots - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const flat = flattenSnapshots(results) + t.is(flat.length, 0, 'should be empty for errors') + t.pass() +}) + +test('groupByBucket - groups by daily bucket', (t) => { + const snapshots = [ + { ts: 1700006400000, balance: 100 }, + { ts: 1700050000000, balance: 200 }, + { ts: 1700092800000, balance: 300 } + ] + const bucketSize = 86400000 + const buckets = groupByBucket(snapshots, bucketSize) + t.ok(typeof buckets === 'object', 'should return object') + t.ok(Object.keys(buckets).length >= 1, 'should have at least one bucket') + t.pass() +}) + +test('groupByBucket - handles empty snapshots', (t) => { + const buckets = groupByBucket([], 86400000) + t.is(Object.keys(buckets).length, 0, 'should be empty') + t.pass() +}) + +test('groupByBucket - handles missing timestamps', (t) => { + const snapshots = [ + { balance: 100 }, + { ts: 1700006400000, balance: 200 } + ] + const buckets = groupByBucket(snapshots, 86400000) + t.ok(Object.keys(buckets).length >= 1, 'should skip items without ts') + t.pass() +}) diff --git a/tests/unit/routes/pools.routes.test.js b/tests/unit/routes/pools.routes.test.js new file mode 100644 index 0000000..e0de6be --- /dev/null +++ b/tests/unit/routes/pools.routes.test.js @@ -0,0 +1,39 @@ +'use strict' + +const test = require('brittle') +const { testModuleStructure, testHandlerFunctions, testOnRequestFunctions } = require('../helpers/routeTestHelpers') +const { createRoutesForTest } = require('../helpers/mockHelpers') + +const ROUTES_PATH = '../../../workers/lib/server/routes/pools.routes.js' + +test('pools routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, 'pools') + t.pass() +}) + +test('pools routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/pools/:pool/balance-history'), 'should have balance-history route') + t.pass() +}) + +test('pools routes - HTTP methods', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + routes.forEach(route => { + t.is(route.method, 'GET', `route ${route.url} should be GET`) + }) + t.pass() +}) + +test('pools routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, 'pools') + t.pass() +}) + +test('pools routes - onRequest functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testOnRequestFunctions(t, routes, 'pools') + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index f1f407e..7808891 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -108,7 +108,10 @@ const ENDPOINTS = { THING_CONFIG: '/auth/thing-config', // WebSocket endpoint - WEBSOCKET: '/ws' + WEBSOCKET: '/ws', + + // Pools endpoints + POOLS_BALANCE_HISTORY: '/auth/pools/:pool/balance-history' } const HTTP_METHODS = { @@ -183,6 +186,16 @@ const STATUS_CODES = { INTERNAL_SERVER_ERROR: 500 } +const RPC_METHODS = { + TAIL_LOG: 'tailLog' +} + +const RANGE_BUCKETS = { + '1D': 86400000, + '1W': 604800000, + '1M': 2592000000 +} + const RPC_TIMEOUT = 15000 const RPC_CONCURRENCY_LIMIT = 2 @@ -202,5 +215,7 @@ module.exports = { STATUS_CODES, RPC_TIMEOUT, RPC_CONCURRENCY_LIMIT, - USER_SETTINGS_TYPE + USER_SETTINGS_TYPE, + RPC_METHODS, + RANGE_BUCKETS } diff --git a/workers/lib/server/handlers/pools.handlers.js b/workers/lib/server/handlers/pools.handlers.js new file mode 100644 index 0000000..9ad132b --- /dev/null +++ b/workers/lib/server/handlers/pools.handlers.js @@ -0,0 +1,93 @@ +'use strict' + +const { + RPC_METHODS, + RANGE_BUCKETS +} = require('../../constants') +const { + requestRpcEachLimit +} = require('../../utils') + +async function getPoolBalanceHistory (ctx, req) { + const start = Number(req.query.start) + const end = Number(req.query.end) + const range = req.query.range || '1D' + const poolFilter = req.params.pool || null + + if (!start || !end) { + throw new Error('ERR_MISSING_START_END') + } + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG, { + key: 'stat-3h', + type: 'minerpool', + start, + end + }) + + const snapshots = flattenSnapshots(results) + + const filtered = poolFilter + ? snapshots.filter(s => s.tag === poolFilter || s.pool === poolFilter || s.id === poolFilter) + : snapshots + + const bucketSize = RANGE_BUCKETS[range] || RANGE_BUCKETS['1D'] + const buckets = groupByBucket(filtered, bucketSize) + + const log = Object.entries(buckets) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([ts, entries]) => { + const latest = entries[entries.length - 1] + const revenue = entries.reduce((sum, e) => sum + (e.revenue || 0), 0) + + return { + ts: Number(ts), + balance: latest.balance || 0, + revenue, + snapshotCount: entries.length + } + }) + + return { log } +} + +function flattenSnapshots (results) { + const flat = [] + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const items = entry.data || entry.items || entry + if (Array.isArray(items)) { + flat.push(...items) + } else if (typeof items === 'object') { + flat.push(items) + } + } + } + return flat +} + +function groupByBucket (snapshots, bucketSize) { + const buckets = {} + for (const snapshot of snapshots) { + const ts = snapshot.ts || snapshot.timestamp + if (!ts) continue + const bucketTs = Math.floor(ts / bucketSize) * bucketSize + if (!buckets[bucketTs]) buckets[bucketTs] = [] + buckets[bucketTs].push(snapshot) + } + return buckets +} + +module.exports = { + getPoolBalanceHistory, + flattenSnapshots, + groupByBucket +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index ac884da..9ce3cab 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -8,6 +8,7 @@ const globalRoutes = require('./routes/global.routes') const thingsRoutes = require('./routes/things.routes') const settingsRoutes = require('./routes/settings.routes') const wsRoutes = require('./routes/ws.routes') +const poolsRoutes = require('./routes/pools.routes') /** * Collect all routes into a flat array for server injection. @@ -22,7 +23,8 @@ function routes (ctx) { ...thingsRoutes(ctx), ...usersRoutes(ctx), ...settingsRoutes(ctx), - ...wsRoutes(ctx) + ...wsRoutes(ctx), + ...poolsRoutes(ctx) ] } diff --git a/workers/lib/server/routes/pools.routes.js b/workers/lib/server/routes/pools.routes.js new file mode 100644 index 0000000..720db18 --- /dev/null +++ b/workers/lib/server/routes/pools.routes.js @@ -0,0 +1,36 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS +} = require('../../constants') +const { + getPoolBalanceHistory +} = require('../handlers/pools.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => { + const schemas = require('../schemas/pools.schemas.js') + + return [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.POOLS_BALANCE_HISTORY, + schema: { + querystring: schemas.query.balanceHistory + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'pools/balance-history', + req.params.pool, + req.query.start, + req.query.end, + req.query.range + ], + ENDPOINTS.POOLS_BALANCE_HISTORY, + getPoolBalanceHistory + ) + } + ] +} diff --git a/workers/lib/server/schemas/pools.schemas.js b/workers/lib/server/schemas/pools.schemas.js new file mode 100644 index 0000000..9d135f1 --- /dev/null +++ b/workers/lib/server/schemas/pools.schemas.js @@ -0,0 +1,18 @@ +'use strict' + +const schemas = { + query: { + balanceHistory: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + range: { type: 'string', enum: ['1D', '1W', '1M'] }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + } + } +} + +module.exports = schemas diff --git a/workers/lib/utils.js b/workers/lib/utils.js index 886ae5f..4f1ced5 100644 --- a/workers/lib/utils.js +++ b/workers/lib/utils.js @@ -128,6 +128,8 @@ const requestRpcMapLimit = async (ctx, method, payload) => { }) } +const getStartOfDay = (ts) => Math.floor(ts / 86400000) * 86400000 + module.exports = { dateNowSec, extractIps, @@ -137,5 +139,6 @@ module.exports = { getAuthTokenFromHeaders, parseJsonQueryParam, requestRpcEachLimit, - requestRpcMapLimit + requestRpcMapLimit, + getStartOfDay } From 9655dec2a4b9e86d722a7df9864bcff352429679 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 12 Feb 2026 18:14:03 +0300 Subject: [PATCH 2/5] feat: add hashrate and remove snapshotCount from balance-history log entries Align pool balance-history endpoint with API v2 spec which requires balance, hashrate, and revenue time-series data. --- tests/unit/handlers/pools.handlers.test.js | 7 ++++++- workers/lib/server/handlers/pools.handlers.js | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/unit/handlers/pools.handlers.test.js b/tests/unit/handlers/pools.handlers.test.js index 1dcbe96..68899ce 100644 --- a/tests/unit/handlers/pools.handlers.test.js +++ b/tests/unit/handlers/pools.handlers.test.js @@ -14,7 +14,7 @@ test('getPoolBalanceHistory - happy path', async (t) => { }, net_r0: { jRequest: async () => { - return [{ data: [{ ts: 1700006400000, balance: 50000, revenue: 100, tag: 'pool1' }] }] + return [{ data: [{ ts: 1700006400000, balance: 50000, hashrate: 120000, revenue: 100, tag: 'pool1' }] }] } } } @@ -27,6 +27,11 @@ test('getPoolBalanceHistory - happy path', async (t) => { const result = await getPoolBalanceHistory(mockCtx, mockReq, {}) t.ok(result.log, 'should return log array') t.ok(Array.isArray(result.log), 'log should be array') + t.ok(result.log.length > 0, 'should have entries') + const entry = result.log[0] + t.ok(entry.hashrate !== undefined, 'should include hashrate') + t.is(entry.hashrate, 120000, 'hashrate should match source data') + t.ok(entry.snapshotCount === undefined, 'should not include snapshotCount') t.pass() }) diff --git a/workers/lib/server/handlers/pools.handlers.js b/workers/lib/server/handlers/pools.handlers.js index 9ad132b..544d913 100644 --- a/workers/lib/server/handlers/pools.handlers.js +++ b/workers/lib/server/handlers/pools.handlers.js @@ -47,8 +47,8 @@ async function getPoolBalanceHistory (ctx, req) { return { ts: Number(ts), balance: latest.balance || 0, - revenue, - snapshotCount: entries.length + hashrate: latest.hashrate || 0, + revenue } }) From 5c49d4954af4cde4e2e34c9d8a80c1fbec628189 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 12 Feb 2026 21:11:14 +0300 Subject: [PATCH 3/5] fix: use ext-data transactions instead of tail-log for balance history tail-log returns empty for type=minerpool. Switch to ext-data with transactions key which provides daily revenue (changed_balance) and hashrate (mining_extra.hash_rate). Also treat 'all' pool param as no filter. --- tests/unit/handlers/pools.handlers.test.js | 111 ++++++++++++++---- workers/lib/constants.js | 9 +- workers/lib/server/handlers/pools.handlers.js | 88 +++++++++----- 3 files changed, 151 insertions(+), 57 deletions(-) diff --git a/tests/unit/handlers/pools.handlers.test.js b/tests/unit/handlers/pools.handlers.test.js index 68899ce..e484757 100644 --- a/tests/unit/handlers/pools.handlers.test.js +++ b/tests/unit/handlers/pools.handlers.test.js @@ -3,7 +3,7 @@ const test = require('brittle') const { getPoolBalanceHistory, - flattenSnapshots, + flattenTransactionResults, groupByBucket } = require('../../../workers/lib/server/handlers/pools.handlers') @@ -14,7 +14,14 @@ test('getPoolBalanceHistory - happy path', async (t) => { }, net_r0: { jRequest: async () => { - return [{ data: [{ ts: 1700006400000, balance: 50000, hashrate: 120000, revenue: 100, tag: 'pool1' }] }] + return [{ + ts: '1700006400000', + transactions: [{ + username: 'user1', + changed_balance: 0.001, + mining_extra: { hash_rate: 611000000000000 } + }] + }] } } } @@ -29,9 +36,8 @@ test('getPoolBalanceHistory - happy path', async (t) => { t.ok(Array.isArray(result.log), 'log should be array') t.ok(result.log.length > 0, 'should have entries') const entry = result.log[0] - t.ok(entry.hashrate !== undefined, 'should include hashrate') - t.is(entry.hashrate, 120000, 'hashrate should match source data') - t.ok(entry.snapshotCount === undefined, 'should not include snapshotCount') + t.ok(entry.hashrate > 0, 'should include hashrate') + t.ok(entry.revenue > 0, 'should include revenue') t.pass() }) @@ -41,9 +47,10 @@ test('getPoolBalanceHistory - with pool filter', async (t) => { net_r0: { jRequest: async () => { return [{ - data: [ - { ts: 1700006400000, balance: 50000, tag: 'pool1' }, - { ts: 1700006400000, balance: 30000, tag: 'pool2' } + ts: '1700006400000', + transactions: [ + { username: 'user1', changed_balance: 0.001 }, + { username: 'user2', changed_balance: 0.002 } ] }] } @@ -52,11 +59,39 @@ test('getPoolBalanceHistory - with pool filter', async (t) => { const mockReq = { query: { start: 1700000000000, end: 1700100000000 }, - params: { pool: 'pool1' } + params: { pool: 'user1' } } const result = await getPoolBalanceHistory(mockCtx, mockReq, {}) t.ok(result.log, 'should return log array') + t.ok(result.log.length > 0, 'should have entries') + t.ok(result.log[0].revenue > 0, 'should have filtered revenue') + t.pass() +}) + +test('getPoolBalanceHistory - "all" pool filter returns all pools', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async () => { + return [{ + ts: '1700006400000', + transactions: [ + { username: 'user1', changed_balance: 0.001 }, + { username: 'user2', changed_balance: 0.002 } + ] + }] + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000 }, + params: { pool: 'all' } + } + + const result = await getPoolBalanceHistory(mockCtx, mockReq, {}) + t.ok(result.log.length > 0, 'should return entries for all pools') t.pass() }) @@ -102,47 +137,71 @@ test('getPoolBalanceHistory - empty ork results', async (t) => { t.pass() }) -test('flattenSnapshots - flattens ork results', (t) => { +test('flattenTransactionResults - extracts daily entries from ext-data', (t) => { + const results = [ + [{ + ts: '1700006400000', + transactions: [ + { username: 'user1', changed_balance: 0.001, mining_extra: { hash_rate: 500000 } }, + { username: 'user2', changed_balance: 0.002, mining_extra: { hash_rate: 600000 } } + ] + }] + ] + const entries = flattenTransactionResults(results, null) + t.is(entries.length, 1, 'should have 1 daily entry') + t.ok(entries[0].revenue > 0, 'should have revenue') + t.ok(entries[0].hashrate > 0, 'should have hashrate') + t.pass() +}) + +test('flattenTransactionResults - filters by pool', (t) => { const results = [ - [{ data: [{ ts: 1, balance: 100 }, { ts: 2, balance: 200 }] }] + [{ + ts: '1700006400000', + transactions: [ + { username: 'user1', changed_balance: 0.001 }, + { username: 'user2', changed_balance: 0.002 } + ] + }] ] - const flat = flattenSnapshots(results) - t.ok(flat.length >= 1, 'should flatten snapshots') + const entries = flattenTransactionResults(results, 'user1') + t.is(entries.length, 1, 'should have 1 entry') + t.is(entries[0].revenue, 0.001, 'should only include user1 revenue') t.pass() }) -test('flattenSnapshots - handles error results', (t) => { +test('flattenTransactionResults - handles error results', (t) => { const results = [{ error: 'timeout' }] - const flat = flattenSnapshots(results) - t.is(flat.length, 0, 'should be empty for errors') + const entries = flattenTransactionResults(results, null) + t.is(entries.length, 0, 'should be empty for errors') t.pass() }) test('groupByBucket - groups by daily bucket', (t) => { - const snapshots = [ - { ts: 1700006400000, balance: 100 }, - { ts: 1700050000000, balance: 200 }, - { ts: 1700092800000, balance: 300 } + const entries = [ + { ts: 1700006400000, revenue: 100 }, + { ts: 1700050000000, revenue: 200 }, + { ts: 1700092800000, revenue: 300 } ] const bucketSize = 86400000 - const buckets = groupByBucket(snapshots, bucketSize) + const buckets = groupByBucket(entries, bucketSize) t.ok(typeof buckets === 'object', 'should return object') t.ok(Object.keys(buckets).length >= 1, 'should have at least one bucket') t.pass() }) -test('groupByBucket - handles empty snapshots', (t) => { +test('groupByBucket - handles empty entries', (t) => { const buckets = groupByBucket([], 86400000) t.is(Object.keys(buckets).length, 0, 'should be empty') t.pass() }) test('groupByBucket - handles missing timestamps', (t) => { - const snapshots = [ - { balance: 100 }, - { ts: 1700006400000, balance: 200 } + const entries = [ + { revenue: 100 }, + { ts: 1700006400000, revenue: 200 } ] - const buckets = groupByBucket(snapshots, 86400000) + const buckets = groupByBucket(entries, 86400000) t.ok(Object.keys(buckets).length >= 1, 'should skip items without ts') t.pass() }) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 7808891..be0a531 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -187,7 +187,13 @@ const STATUS_CODES = { } const RPC_METHODS = { - TAIL_LOG: 'tailLog' + TAIL_LOG: 'tailLog', + GET_WRK_EXT_DATA: 'getWrkExtData' +} + +const MINERPOOL_EXT_DATA_KEYS = { + TRANSACTIONS: 'transactions', + STATS: 'stats' } const RANGE_BUCKETS = { @@ -217,5 +223,6 @@ module.exports = { RPC_CONCURRENCY_LIMIT, USER_SETTINGS_TYPE, RPC_METHODS, + MINERPOOL_EXT_DATA_KEYS, RANGE_BUCKETS } diff --git a/workers/lib/server/handlers/pools.handlers.js b/workers/lib/server/handlers/pools.handlers.js index 544d913..63a1d2e 100644 --- a/workers/lib/server/handlers/pools.handlers.js +++ b/workers/lib/server/handlers/pools.handlers.js @@ -2,17 +2,20 @@ const { RPC_METHODS, + MINERPOOL_EXT_DATA_KEYS, RANGE_BUCKETS } = require('../../constants') const { - requestRpcEachLimit + requestRpcEachLimit, + getStartOfDay } = require('../../utils') async function getPoolBalanceHistory (ctx, req) { const start = Number(req.query.start) const end = Number(req.query.end) const range = req.query.range || '1D' - const poolFilter = req.params.pool || null + const poolParam = req.params.pool || null + const poolFilter = poolParam === 'all' ? null : poolParam if (!start || !end) { throw new Error('ERR_MISSING_START_END') @@ -22,72 +25,97 @@ async function getPoolBalanceHistory (ctx, req) { throw new Error('ERR_INVALID_DATE_RANGE') } - const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG, { - key: 'stat-3h', + const results = await requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { type: 'minerpool', - start, - end + query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } }) - const snapshots = flattenSnapshots(results) - - const filtered = poolFilter - ? snapshots.filter(s => s.tag === poolFilter || s.pool === poolFilter || s.id === poolFilter) - : snapshots + const dailyEntries = flattenTransactionResults(results, poolFilter) const bucketSize = RANGE_BUCKETS[range] || RANGE_BUCKETS['1D'] - const buckets = groupByBucket(filtered, bucketSize) + const buckets = groupByBucket(dailyEntries, bucketSize) const log = Object.entries(buckets) .sort(([a], [b]) => Number(a) - Number(b)) .map(([ts, entries]) => { - const latest = entries[entries.length - 1] - const revenue = entries.reduce((sum, e) => sum + (e.revenue || 0), 0) + const totalRevenue = entries.reduce((sum, e) => sum + (e.revenue || 0), 0) + const hashrates = entries.filter(e => e.hashrate > 0) + const avgHashrate = hashrates.length + ? hashrates.reduce((sum, e) => sum + e.hashrate, 0) / hashrates.length + : 0 return { ts: Number(ts), - balance: latest.balance || 0, - hashrate: latest.hashrate || 0, - revenue + balance: totalRevenue, + hashrate: avgHashrate, + revenue: totalRevenue } }) return { log } } -function flattenSnapshots (results) { - const flat = [] +/** + * Flattens ext-data transaction results into daily entries. + * The ext-data response structure for transactions: + * [ { ts, stats, transactions: [{username, changed_balance, mining_extra: {hash_rate, ...}}, ...] }, ... ] + */ +function flattenTransactionResults (results, poolFilter) { + const daily = [] for (const res of results) { if (res.error || !res) continue const data = Array.isArray(res) ? res : (res.data || res.result || []) if (!Array.isArray(data)) continue + for (const entry of data) { if (!entry) continue - const items = entry.data || entry.items || entry - if (Array.isArray(items)) { - flat.push(...items) - } else if (typeof items === 'object') { - flat.push(items) + const ts = Number(entry.ts) + if (!ts) continue + + const txs = entry.transactions || [] + if (!Array.isArray(txs) || txs.length === 0) continue + + let revenue = 0 + let hashrate = 0 + let hashCount = 0 + + for (const tx of txs) { + if (!tx) continue + if (poolFilter && tx.username !== poolFilter) continue + revenue += Math.abs(tx.changed_balance || 0) + if (tx.mining_extra?.hash_rate) { + hashrate += tx.mining_extra.hash_rate + hashCount++ + } } + + if (revenue === 0 && hashCount === 0) continue + + daily.push({ + ts: getStartOfDay(ts), + revenue, + hashrate: hashCount > 0 ? hashrate / hashCount : 0 + }) } } - return flat + + return daily } -function groupByBucket (snapshots, bucketSize) { +function groupByBucket (entries, bucketSize) { const buckets = {} - for (const snapshot of snapshots) { - const ts = snapshot.ts || snapshot.timestamp + for (const entry of entries) { + const ts = entry.ts if (!ts) continue const bucketTs = Math.floor(ts / bucketSize) * bucketSize if (!buckets[bucketTs]) buckets[bucketTs] = [] - buckets[bucketTs].push(snapshot) + buckets[bucketTs].push(entry) } return buckets } module.exports = { getPoolBalanceHistory, - flattenSnapshots, + flattenTransactionResults, groupByBucket } From 82121baa3ad2a3420bdb7fa3a6e46be403dbcb4b Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 12 Feb 2026 22:47:13 +0300 Subject: [PATCH 4/5] cleanup --- workers/lib/server/handlers/pools.handlers.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/workers/lib/server/handlers/pools.handlers.js b/workers/lib/server/handlers/pools.handlers.js index 63a1d2e..6f900a1 100644 --- a/workers/lib/server/handlers/pools.handlers.js +++ b/workers/lib/server/handlers/pools.handlers.js @@ -55,11 +55,6 @@ async function getPoolBalanceHistory (ctx, req) { return { log } } -/** - * Flattens ext-data transaction results into daily entries. - * The ext-data response structure for transactions: - * [ { ts, stats, transactions: [{username, changed_balance, mining_extra: {hash_rate, ...}}, ...] }, ... ] - */ function flattenTransactionResults (results, poolFilter) { const daily = [] for (const res of results) { From 93ba9ee30f6aad394a2592422451ac623b9355f0 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Sun, 15 Feb 2026 16:48:05 +0300 Subject: [PATCH 5/5] fix: move pool filter to RPC payload for balance-history endpoint Pool name filter should be handled by the RPC worker, not filtered client-side. The tx username is the account id. --- tests/unit/handlers/pools.handlers.test.js | 27 +++++-------------- workers/lib/server/handlers/pools.handlers.js | 7 +++-- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/tests/unit/handlers/pools.handlers.test.js b/tests/unit/handlers/pools.handlers.test.js index e484757..850a35c 100644 --- a/tests/unit/handlers/pools.handlers.test.js +++ b/tests/unit/handlers/pools.handlers.test.js @@ -42,10 +42,12 @@ test('getPoolBalanceHistory - happy path', async (t) => { }) test('getPoolBalanceHistory - with pool filter', async (t) => { + let capturedPayload = null const mockCtx = { conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { - jRequest: async () => { + jRequest: async (key, method, payload) => { + capturedPayload = payload return [{ ts: '1700006400000', transactions: [ @@ -64,8 +66,7 @@ test('getPoolBalanceHistory - with pool filter', async (t) => { const result = await getPoolBalanceHistory(mockCtx, mockReq, {}) t.ok(result.log, 'should return log array') - t.ok(result.log.length > 0, 'should have entries') - t.ok(result.log[0].revenue > 0, 'should have filtered revenue') + t.is(capturedPayload.query.pool, 'user1', 'should pass pool filter in RPC payload') t.pass() }) @@ -147,32 +148,16 @@ test('flattenTransactionResults - extracts daily entries from ext-data', (t) => ] }] ] - const entries = flattenTransactionResults(results, null) + const entries = flattenTransactionResults(results) t.is(entries.length, 1, 'should have 1 daily entry') t.ok(entries[0].revenue > 0, 'should have revenue') t.ok(entries[0].hashrate > 0, 'should have hashrate') t.pass() }) -test('flattenTransactionResults - filters by pool', (t) => { - const results = [ - [{ - ts: '1700006400000', - transactions: [ - { username: 'user1', changed_balance: 0.001 }, - { username: 'user2', changed_balance: 0.002 } - ] - }] - ] - const entries = flattenTransactionResults(results, 'user1') - t.is(entries.length, 1, 'should have 1 entry') - t.is(entries[0].revenue, 0.001, 'should only include user1 revenue') - t.pass() -}) - test('flattenTransactionResults - handles error results', (t) => { const results = [{ error: 'timeout' }] - const entries = flattenTransactionResults(results, null) + const entries = flattenTransactionResults(results) t.is(entries.length, 0, 'should be empty for errors') t.pass() }) diff --git a/workers/lib/server/handlers/pools.handlers.js b/workers/lib/server/handlers/pools.handlers.js index 6f900a1..a7c6636 100644 --- a/workers/lib/server/handlers/pools.handlers.js +++ b/workers/lib/server/handlers/pools.handlers.js @@ -27,10 +27,10 @@ async function getPoolBalanceHistory (ctx, req) { const results = await requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { type: 'minerpool', - query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } + query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end, pool: poolFilter } }) - const dailyEntries = flattenTransactionResults(results, poolFilter) + const dailyEntries = flattenTransactionResults(results) const bucketSize = RANGE_BUCKETS[range] || RANGE_BUCKETS['1D'] const buckets = groupByBucket(dailyEntries, bucketSize) @@ -55,7 +55,7 @@ async function getPoolBalanceHistory (ctx, req) { return { log } } -function flattenTransactionResults (results, poolFilter) { +function flattenTransactionResults (results) { const daily = [] for (const res of results) { if (res.error || !res) continue @@ -76,7 +76,6 @@ function flattenTransactionResults (results, poolFilter) { for (const tx of txs) { if (!tx) continue - if (poolFilter && tx.username !== poolFilter) continue revenue += Math.abs(tx.changed_balance || 0) if (tx.mining_extra?.hash_rate) { hashrate += tx.mining_extra.hash_rate