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
48 changes: 48 additions & 0 deletions backend/src/__tests__/mining-pools-stats-deadline.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
expect(stats.lastEstimatedHashrate).toBe(7e20);
expect(stats.lastEstimatedHashrate3d).toBe(7e20);
expect(stats.lastEstimatedHashrate1w).toBe(7e20);
});
});
29 changes: 25 additions & 4 deletions backend/src/api/mining/mining.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> => {
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<never>((_, 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;
Expand Down
9 changes: 2 additions & 7 deletions frontend/src/app/dashboard/dashboard.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -90,19 +90,14 @@ <h3 class="panel-subtitle" i18n="dashboard.incoming-transactions">Incoming trans
</section>

<!-- Replacements: the clearest sign that someone is competing for space. -->
<section class="panel panel-replacements" *ngIf="(network$ | async) !== 'liquidtestnet'; else latestBlocks" aria-labelledby="panel-rbf-title">
<!-- A backend that cannot serve replacement history is logged there, never reported here: the panel falls back to recent blocks. -->
<section class="panel panel-replacements" *ngIf="(network$ | async) !== 'liquidtestnet' && (stateService.rbfSummaryState$ | async)?.status !== 'unavailable'; else latestBlocks" aria-labelledby="panel-rbf-title">
<div class="panel-head">
<h2 class="panel-title" id="panel-rbf-title" i18n="dashboard.recent-rbf-replacements">Recent replacements</h2>
<a class="panel-link" [routerLink]="['/rbf' | relativeUrl]" i18n="dashboard.see-all-replacements">See all</a>
<p class="panel-note" i18n="dashboard.rbf-note">Transactions resent at a higher fee to move ahead of the queue.</p>
</div>
<div class="table-scroll" tabindex="0" role="region" aria-labelledby="panel-rbf-title">
@if (stateService.rbfSummaryState$ | async; as history) {
@if (history.status === 'unavailable') {
<p role="alert">Replacement history is unavailable.</p>
<button type="button" class="btn btn-primary" (click)="retryRbfSummary()">Retry</button>
}
}
<table class="table lastest-replacements-table">
<thead>
<tr>
Expand Down
2 changes: 0 additions & 2 deletions frontend/src/app/dashboard/dashboard.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down