diff --git a/backend/src/__tests__/mining-pools-stats-deadline.test.ts b/backend/src/__tests__/mining-pools-stats-deadline.test.ts new file mode 100644 index 0000000000..599db96bcf --- /dev/null +++ b/backend/src/__tests__/mining-pools-stats-deadline.test.ts @@ -0,0 +1,48 @@ +const hashPs = jest.fn(); +const poolRow = { poolId: 1, name: 'Foundry USA', link: '', blockCount: 300, slug: 'foundryusa', avgMatchRate: null, avgFeeDelta: null, poolUniqueId: 111 }; + +jest.mock('../config', () => ({ __esModule: true, default: { MEMPOOL: { NETWORK: 'mainnet' }, DATABASE: { ENABLED: true } } })); +jest.mock('../logger', () => ({ __esModule: true, default: { debug: () => undefined, err: () => undefined, tags: { mining: 'Mining' } } })); +jest.mock('../database', () => ({ __esModule: true, default: {} })); +jest.mock('../api/common', () => ({ Common: {} })); +jest.mock('../api/loading-indicators', () => ({ __esModule: true, default: {} })); +jest.mock('../api/bitcoin/bitcoin-client', () => ({ __esModule: true, default: { getNetworkHashPs: (blocks: number) => hashPs(blocks) } })); +jest.mock('../api/bitcoin/bitcoin-api-factory', () => ({ __esModule: true, default: {} })); +jest.mock('../repositories/PoolsRepository', () => ({ __esModule: true, default: { $getPoolsInfo: async () => [poolRow] } })); +jest.mock('../repositories/BlocksRepository', () => ({ + __esModule: true, + default: { $countEmptyBlocks: async () => [{ poolId: 1, count: 2 }], $blockCount: async () => 1008 }, +})); +jest.mock('../repositories/HashratesRepository', () => ({ __esModule: true, default: {} })); +jest.mock('../repositories/DifficultyAdjustmentsRepository', () => ({ __esModule: true, default: {} })); +jest.mock('../repositories/BlocksAuditsRepository', () => ({ __esModule: true, default: {} })); +jest.mock('../repositories/PricesRepository', () => ({ __esModule: true, default: {} })); + +import mining from '../api/mining/mining'; + +describe('pool ranking does not wait on a busy node', () => { + afterEach(() => { + jest.useRealTimers(); + hashPs.mockReset(); + }); + + it('answers with the indexed ranking when the hashrate estimate never returns', /** @asyncUnsafe Jest owns the test. */ async () => { + jest.useFakeTimers(); + hashPs.mockImplementation(() => new Promise(() => undefined)); + const pending = mining.$getPoolsStats('1w'); + await jest.advanceTimersByTimeAsync(5000); + const stats = await pending as { pools: { slug: string; rank: number; emptyBlocks: number }[]; blockCount: number; lastEstimatedHashrate: number }; + expect(stats.pools).toEqual([expect.objectContaining({ slug: 'foundryusa', rank: 1, emptyBlocks: 2 })]); + expect(stats.blockCount).toBe(1008); + expect(stats.lastEstimatedHashrate).toBe(0); + expect(hashPs).toHaveBeenCalledTimes(1); + }); + + it('keeps every estimate when the node answers in time', /** @asyncUnsafe Jest owns the test. */ async () => { + hashPs.mockResolvedValue(7e20); + const stats = await mining.$getPoolsStats('1w') as Record; + expect(stats.lastEstimatedHashrate).toBe(7e20); + expect(stats.lastEstimatedHashrate3d).toBe(7e20); + expect(stats.lastEstimatedHashrate1w).toBe(7e20); + }); +}); diff --git a/backend/src/api/mining/mining.ts b/backend/src/api/mining/mining.ts index d3fadbcceb..80806a4bef 100644 --- a/backend/src/api/mining/mining.ts +++ b/backend/src/api/mining/mining.ts @@ -23,6 +23,8 @@ interface DifficultyBlock { } class Mining { + private static readonly POOLS_HASHRATE_BUDGET_MS = 5000; + private blocksPriceIndexingRunning = false; public lastHashrateIndexingDate: number | null = null; public lastWeeklyHashrateIndexingDate: number | null = null; @@ -145,13 +147,32 @@ class Mining { const totalBlock3d: number = await BlocksRepository.$blockCount(null, '3d'); const totalBlock1w: number = await BlocksRepository.$blockCount(null, '1w'); + // The ranking is complete from the database alone. The node only adds the + // hashrate estimates, so they share one short budget: a node that is busy + // indexing must not hold the answer past what the page waits for. + let expired = false; + let timer: NodeJS.Timeout | undefined; try { - poolsStatistics['lastEstimatedHashrate'] = await bitcoinClient.getNetworkHashPs(totalBlock24h); - poolsStatistics['lastEstimatedHashrate3d'] = await bitcoinClient.getNetworkHashPs(totalBlock3d); - poolsStatistics['lastEstimatedHashrate1w'] = await bitcoinClient.getNetworkHashPs(totalBlock1w); + await Promise.race([ + (/** @asyncUnsafe $getPoolsStats owns rejection through Promise.race. */ async (): Promise => { + poolsStatistics['lastEstimatedHashrate'] = await bitcoinClient.getNetworkHashPs(totalBlock24h); + if (expired) { return; } + poolsStatistics['lastEstimatedHashrate3d'] = await bitcoinClient.getNetworkHashPs(totalBlock3d); + if (expired) { return; } + poolsStatistics['lastEstimatedHashrate1w'] = await bitcoinClient.getNetworkHashPs(totalBlock1w); + })(), + new Promise((_, reject) => { + timer = setTimeout(() => { + expired = true; + reject(new Error('Hashrate estimate exceeded its budget')); + }, Mining.POOLS_HASHRATE_BUDGET_MS); + }), + ]); } catch (e) { - poolsStatistics['lastEstimatedHashrate'] = 0; + poolsStatistics['lastEstimatedHashrate'] = poolsStatistics['lastEstimatedHashrate'] ?? 0; logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate', logger.tags.mining); + } finally { + clearTimeout(timer); } return poolsStatistics; diff --git a/frontend/src/app/dashboard/dashboard.component.html b/frontend/src/app/dashboard/dashboard.component.html index eebf74c717..26afa588c1 100644 --- a/frontend/src/app/dashboard/dashboard.component.html +++ b/frontend/src/app/dashboard/dashboard.component.html @@ -90,19 +90,14 @@

Incoming trans -
+ +

Recent replacements

See all

Transactions resent at a higher fee to move ahead of the queue.

- @if (stateService.rbfSummaryState$ | async; as history) { - @if (history.status === 'unavailable') { -

Replacement history is unavailable.

- - } - } diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts index d3890fffc2..593b537906 100644 --- a/frontend/src/app/dashboard/dashboard.component.ts +++ b/frontend/src/app/dashboard/dashboard.component.ts @@ -115,8 +115,6 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit { this.stateService.retryLiveFeed(); } - retryRbfSummary(): void { this.websocketService.startTrackRbfSummary(); } - ngOnDestroy(): void { this.filterSubscription.unsubscribe(); this.mempoolInfoSubscription.unsubscribe();