diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index b0a04116f..75d3e6e8f 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -115,6 +115,11 @@ class BitcoinApi implements AbstractBitcoinApi { return outSpends; } + $getEstimatedHashrate(blockHeight: number): Promise { + // 120 is the default block span in Core + return this.bitcoindClient.getNetworkHashPs(120, blockHeight); + } + protected async $convertTransaction(transaction: IBitcoinApi.Transaction, addPrevout: boolean): Promise { let esploraTransaction: IEsploraApi.Transaction = { txid: transaction.txid, diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index c9bb46754..4f1e17101 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -115,7 +115,7 @@ class Blocks { */ private async $findBlockMiner(txMinerInfo: TransactionMinerInfo | undefined) : Promise { if (txMinerInfo === undefined) { - return poolsRepository.getUnknownPool(); + return await poolsRepository.$getUnknownPool(); } const asciiScriptSig = transactionUtils.hex2ascii(txMinerInfo.vin[0].scriptsig); @@ -139,7 +139,7 @@ class Blocks { } } - return poolsRepository.getUnknownPool(); + return await poolsRepository.$getUnknownPool(); } /** @@ -147,7 +147,7 @@ class Blocks { */ public async $generateBlockDatabase() { let currentBlockHeight = await bitcoinApi.$getBlockHeightTip(); - let maxBlocks = 100; // tmp + let maxBlocks = 1008*2; // tmp while (currentBlockHeight-- > 0 && maxBlocks-- > 0) { if (await blocksRepository.$isBlockAlreadyIndexed(currentBlockHeight)) { diff --git a/backend/src/api/mining.ts b/backend/src/api/mining.ts new file mode 100644 index 000000000..d22a29d5b --- /dev/null +++ b/backend/src/api/mining.ts @@ -0,0 +1,53 @@ +import { PoolInfo, PoolStats } from "../mempool.interfaces"; +import BlocksRepository, { EmptyBlocks } from "../repositories/BlocksRepository"; +import PoolsRepository from "../repositories/PoolsRepository"; +import bitcoinClient from "./bitcoin/bitcoin-client"; +import BitcoinApi from "./bitcoin/bitcoin-api"; + +class Mining { + private bitcoinApi: BitcoinApi; + + constructor() { + this.bitcoinApi = new BitcoinApi(bitcoinClient); + } + + /** + * Generate high level overview of the pool ranks and general stats + */ + public async $getPoolsStats(interval: string = "100 YEAR") : Promise { + let poolsStatistics = {}; + + const lastBlockHashrate = await this.bitcoinApi.$getEstimatedHashrate(717960); + const poolsInfo: PoolInfo[] = await PoolsRepository.$getPoolsInfo(interval); + const blockCount: number = await BlocksRepository.$blockCount(interval); + const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$countEmptyBlocks(interval); + + let poolsStats: PoolStats[] = []; + let rank = 1; + + poolsInfo.forEach((poolInfo: PoolInfo) => { + let poolStat: PoolStats = { + poolId: poolInfo.poolId, // mysql row id + name: poolInfo.name, + link: poolInfo.link, + blockCount: poolInfo.blockCount, + rank: rank++, + emptyBlocks: 0, + } + for (let i = 0; i < emptyBlocks.length; ++i) { + if (emptyBlocks[i].poolId === poolInfo.poolId) { + poolStat.emptyBlocks++; + } + } + poolsStats.push(poolStat); + }) + + poolsStatistics["blockCount"] = blockCount; + poolsStatistics["poolsStats"] = poolsStats; + poolsStatistics["lastEstimatedHashrate"] = lastBlockHashrate; + + return poolsStatistics; + } +} + +export default new Mining(); \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index be26d53fe..bf3abf01f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -261,6 +261,7 @@ class Server { .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1y', routes.$getStatisticsByTime.bind(routes, '1y')) .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2y', routes.$getStatisticsByTime.bind(routes, '2y')) .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3y', routes.$getStatisticsByTime.bind(routes, '3y')) + .get(config.MEMPOOL.API_URL_PREFIX + 'pools', routes.getPools) ; } diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index ae921f073..5fb83d792 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -1,11 +1,23 @@ -import { RowDataPacket } from 'mysql2'; import { IEsploraApi } from './api/bitcoin/esplora-api.interface'; -export interface PoolTag extends RowDataPacket { +export interface PoolTag { + id: number | null, // mysql row id name: string, link: string, - regexes: string, - addresses: string, + regexes: string, // JSON array + addresses: string, // JSON array +} + +export interface PoolInfo { + poolId: number, // mysql row id + name: string, + link: string, + blockCount: number, +} + +export interface PoolStats extends PoolInfo { + rank: number, + emptyBlocks: number, } export interface MempoolBlock { diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index f62b261a1..9802a0a74 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -1,16 +1,15 @@ -import { IEsploraApi } from "../api/bitcoin/esplora-api.interface"; import { BlockExtended, PoolTag } from "../mempool.interfaces"; import { DB } from "../database"; import logger from "../logger"; -import bitcoinApi from '../api/bitcoin/bitcoin-api-factory'; + +export interface EmptyBlocks { + emptyBlocks: number, + poolId: number, +} class BlocksRepository { /** * Save indexed block data in the database - * @param block - * @param blockHash - * @param coinbaseTxid - * @param poolTag */ public async $saveBlockInDatabase( block: BlockExtended, @@ -26,7 +25,7 @@ class BlocksRepository { weight, tx_count, coinbase_raw, difficulty, pool_id, fees, fee_span, median_fee ) VALUE ( - ?, ?, ?, ?, + ?, ?, FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ? )`; @@ -49,25 +48,49 @@ class BlocksRepository { /** * Check if a block has already been indexed in the database. Query the databse directly. * This can be cached/optimized if required later on to avoid too many db queries. - * @param blockHeight - * @returns */ public async $isBlockAlreadyIndexed(blockHeight: number) { const connection = await DB.pool.getConnection(); let exists = false; - try { - const query = `SELECT height from blocks where blocks.height = ${blockHeight}`; - const [rows]: any[] = await connection.query(query); - exists = rows.length === 1; - } catch (e) { - console.log(e); - logger.err('$isBlockAlreadyIndexed() error' + (e instanceof Error ? e.message : e)); - } + const query = `SELECT height from blocks where blocks.height = ${blockHeight}`; + const [rows]: any[] = await connection.query(query); + exists = rows.length === 1; connection.release(); return exists; } + + /** + * Count empty blocks for all pools + */ + public async $countEmptyBlocks(interval: string = "100 YEAR") : Promise { + const connection = await DB.pool.getConnection(); + const [rows] = await connection.query(` + SELECT pool_id as poolId + FROM blocks + WHERE timestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() + AND tx_count = 1; + `); + connection.release(); + + return rows; + } + + /** + * Get blocks count for a period + */ + public async $blockCount(interval: string = "100 YEAR") : Promise { + const connection = await DB.pool.getConnection(); + const [rows] = await connection.query(` + SELECT count(height) as blockCount + FROM blocks + WHERE timestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW(); + `); + connection.release(); + + return rows[0].blockCount; + } } export default new BlocksRepository(); \ No newline at end of file diff --git a/backend/src/repositories/PoolsRepository.ts b/backend/src/repositories/PoolsRepository.ts index aa6f457f2..bf3ee18e0 100644 --- a/backend/src/repositories/PoolsRepository.ts +++ b/backend/src/repositories/PoolsRepository.ts @@ -1,30 +1,43 @@ -import { FieldPacket } from "mysql2"; import { DB } from "../database"; -import { PoolTag } from "../mempool.interfaces" +import { PoolInfo, PoolTag } from "../mempool.interfaces" class PoolsRepository { /** * Get all pools tagging info */ - public async $getPools() : Promise { + public async $getPools(): Promise { const connection = await DB.pool.getConnection(); - const [rows]: [PoolTag[], FieldPacket[]] = await connection.query("SELECT * FROM pools;"); + const [rows] = await connection.query("SELECT * FROM pools;"); connection.release(); - return rows; + return rows; } /** * Get unknown pool tagging info */ - public getUnknownPool(): PoolTag { - return { - id: null, - name: 'Unknown', - link: 'rickroll?', - regexes: "[]", - addresses: "[]", - }; - } + public async $getUnknownPool(): Promise { + const connection = await DB.pool.getConnection(); + const [rows] = await connection.query("SELECT * FROM pools where name = 'Unknown'"); + connection.release(); + return rows[0]; + } + + /** + * Get basic pool info and block count + */ + public async $getPoolsInfo(interval: string = "100 YEARS"): Promise { + const connection = await DB.pool.getConnection(); + const [rows] = await connection.query(` + SELECT COUNT(height) as blockCount, pool_id as poolId, pools.name as name, pools.link as link + FROM blocks + JOIN pools on pools.id = pool_id + WHERE timestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() + GROUP BY pool_id + ORDER BY COUNT(height) DESC; + `); + connection.release(); + return rows; + } } export default new PoolsRepository(); \ No newline at end of file diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 1d98c9f4e..b7956cd64 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -20,6 +20,7 @@ import { Common } from './api/common'; import bitcoinClient from './api/bitcoin/bitcoin-client'; import elementsParser from './api/liquid/elements-parser'; import icons from './api/liquid/icons'; +import miningStats from './api/mining'; class Routes { constructor() {} @@ -531,6 +532,15 @@ class Routes { } } + public async getPools(req: Request, res: Response) { + try { + let stats = await miningStats.$getPoolsStats(req.query.interval as string); + res.json(stats); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + public async getBlock(req: Request, res: Response) { try { const result = await bitcoinApi.$getBlock(req.params.hash); diff --git a/frontend/server.ts b/frontend/server.ts index af27fcd08..059522a05 100644 --- a/frontend/server.ts +++ b/frontend/server.ts @@ -6,7 +6,6 @@ import * as express from 'express'; import * as fs from 'fs'; import * as path from 'path'; import * as domino from 'domino'; -import { createProxyMiddleware } from 'http-proxy-middleware'; import { join } from 'path'; import { AppServerModule } from './src/main.server'; @@ -66,6 +65,7 @@ export function app(locale: string): express.Express { server.get('/mempool-block/*', getLocalizedSSR(indexHtml)); server.get('/address/*', getLocalizedSSR(indexHtml)); server.get('/blocks', getLocalizedSSR(indexHtml)); + server.get('/pools', getLocalizedSSR(indexHtml)); server.get('/graphs', getLocalizedSSR(indexHtml)); server.get('/liquid', getLocalizedSSR(indexHtml)); server.get('/liquid/tx/*', getLocalizedSSR(indexHtml)); diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 43705b85e..19e4ee7ab 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -22,6 +22,7 @@ import { BisqMasterPageComponent } from './components/bisq-master-page/bisq-mast import { SponsorComponent } from './components/sponsor/sponsor.component'; import { LiquidMasterPageComponent } from './components/liquid-master-page/liquid-master-page.component'; import { PushTransactionComponent } from './components/push-transaction/push-transaction.component'; +import { PoolRankingComponent } from './components/pool-ranking/pool-ranking.component'; let routes: Routes = [ { @@ -58,6 +59,10 @@ let routes: Routes = [ path: 'blocks', component: LatestBlocksComponent, }, + { + path: 'pools', + component: PoolRankingComponent, + }, { path: 'graphs', component: StatisticsComponent, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 3e2c40b25..b16c5bdea 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -37,6 +37,7 @@ import { IncomingTransactionsGraphComponent } from './components/incoming-transa import { TimeSpanComponent } from './components/time-span/time-span.component'; import { SeoService } from './services/seo.service'; import { MempoolGraphComponent } from './components/mempool-graph/mempool-graph.component'; +import { PoolRankingComponent } from './components/pool-ranking/pool-ranking.component'; import { LbtcPegsGraphComponent } from './components/lbtc-pegs-graph/lbtc-pegs-graph.component'; import { AssetComponent } from './components/asset/asset.component'; import { AssetsComponent } from './assets/assets.component'; @@ -91,6 +92,7 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; FeeDistributionGraphComponent, IncomingTransactionsGraphComponent, MempoolGraphComponent, + PoolRankingComponent, LbtcPegsGraphComponent, AssetComponent, AssetsComponent, diff --git a/frontend/src/app/components/pool-ranking/pool-ranking.component.html b/frontend/src/app/components/pool-ranking/pool-ranking.component.html new file mode 100644 index 000000000..9d38ced93 --- /dev/null +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.html @@ -0,0 +1,68 @@ +
+
+ Pools +
+
+ + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
RankNameHashrateBlock Count (share)Empty Blocks (ratio)
-All miners{{ pools["lastEstimatedHashrate"]}} PH/s{{ pools["blockCount"] }}{{ pools["totalEmptyBlock"] }} ({{ pools["totalEmptyBlockRatio"] }}%)
{{ pool.rank }}{{ pool.name }}{{ pool.lastEstimatedHashrate }} PH/s{{ pool.blockCount }} ({{ pool.share }}%){{ pool.emptyBlocks }} ({{ pool.emptyBlockRatio }}%)
+ +
diff --git a/frontend/src/app/components/pool-ranking/pool-ranking.component.ts b/frontend/src/app/components/pool-ranking/pool-ranking.component.ts new file mode 100644 index 000000000..b1841e867 --- /dev/null +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.ts @@ -0,0 +1,103 @@ +import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { merge, Observable, ObservableInput, of } from 'rxjs'; +import { map, share, switchMap, tap } from 'rxjs/operators'; +import { PoolsStats } from 'src/app/interfaces/node-api.interface'; +import { StorageService } from 'src/app/services/storage.service'; +import { ApiService } from '../../services/api.service'; + +@Component({ + selector: 'app-pool-ranking', + templateUrl: './pool-ranking.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PoolRankingComponent implements OnInit { + pools$: Observable + + radioGroupForm: FormGroup; + poolsWindowPreference: string; + + constructor( + private formBuilder: FormBuilder, + private route: ActivatedRoute, + private apiService: ApiService, + private storageService: StorageService, + ) { } + + ngOnInit(): void { + this.poolsWindowPreference = this.storageService.getValue('poolsWindowPreference') ? this.storageService.getValue('poolsWindowPreference').trim() : '2h'; + this.radioGroupForm = this.formBuilder.group({ + dateSpan: this.poolsWindowPreference + }); + + // Setup datespan triggers + this.route.fragment.subscribe((fragment) => { + if (['1d', '3d', '1w', '1m', '3m', '6m', '1y', '2y', '3y', 'all'].indexOf(fragment) > -1) { + this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); + } + }); + merge(of(''), this.radioGroupForm.controls.dateSpan.valueChanges) + .pipe(switchMap(() => this.onDateSpanChanged())) + .subscribe((pools: any) => { + console.log(pools); + }); + + // Fetch initial mining pool data + this.onDateSpanChanged(); + } + + ngOnChanges() { + } + + rendered() { + } + + savePoolsPreference() { + this.storageService.setValue('poolsWindowPreference', this.radioGroupForm.controls.dateSpan.value); + this.poolsWindowPreference = this.radioGroupForm.controls.dateSpan.value; + } + + onDateSpanChanged(): ObservableInput { + let interval: string; + console.log(this.poolsWindowPreference); + switch (this.poolsWindowPreference) { + case '1d': interval = '1 DAY'; break; + case '3d': interval = '3 DAY'; break; + case '1w': interval = '1 WEEK'; break; + case '1m': interval = '1 MONTH'; break; + case '3m': interval = '3 MONTH'; break; + case '6m': interval = '6 MONTH'; break; + case '1y': interval = '1 YEAR'; break; + case '2y': interval = '2 YEAR'; break; + case '3y': interval = '3 YEAR'; break; + case 'all': interval = '1000 YEAR'; break; + } + this.pools$ = this.apiService.listPools$(interval).pipe(map(res => this.computeMiningStats(res))); + return this.pools$; + } + + computeMiningStats(stats: PoolsStats) { + const totalEmptyBlock = Object.values(stats.poolsStats).reduce((prev, cur) => { + return prev + cur.emptyBlocks; + }, 0); + const totalEmptyBlockRatio = (totalEmptyBlock / stats.blockCount * 100).toFixed(2); + const poolsStats = stats.poolsStats.map((poolStat) => { + return { + share: (poolStat.blockCount / stats.blockCount * 100).toFixed(2), + lastEstimatedHashrate: (poolStat.blockCount / stats.blockCount * stats.lastEstimatedHashrate / Math.pow(10, 15)).toFixed(2), + emptyBlockRatio: (poolStat.emptyBlocks / poolStat.blockCount * 100).toFixed(2), + ...poolStat + } + }); + + return { + lastEstimatedHashrate: (stats.lastEstimatedHashrate / Math.pow(10, 15)).toFixed(2), + blockCount: stats.blockCount, + totalEmptyBlock: totalEmptyBlock, + totalEmptyBlockRatio: totalEmptyBlockRatio, + poolsStats: poolsStats, + } + } +} + diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index fa0ddeb77..669c8d251 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -51,3 +51,16 @@ export interface LiquidPegs { } export interface ITranslators { [language: string]: string; } + +export interface PoolsStats { + poolsStats: { + pooldId: number, + name: string, + link: string, + blockCount: number, + emptyBlocks: number, + rank: number, + }[], + blockCount: number, + lastEstimatedHashrate: number, +} diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 05c6f074e..f17f32032 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { CpfpInfo, OptimizedMempoolStats, DifficultyAdjustment, AddressInformation, LiquidPegs, ITranslators } from '../interfaces/node-api.interface'; +import { CpfpInfo, OptimizedMempoolStats, DifficultyAdjustment, AddressInformation, LiquidPegs, ITranslators, PoolsStats } from '../interfaces/node-api.interface'; import { Observable } from 'rxjs'; import { StateService } from './state.service'; import { WebsocketResponse } from '../interfaces/websocket.interface'; @@ -120,4 +120,9 @@ export class ApiService { postTransaction$(hexPayload: string): Observable { return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/tx', hexPayload, { responseType: 'text' as 'json'}); } + + listPools$(interval: string) : Observable { + const params = new HttpParams().set('interval', interval); + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/pools', {params}); + } } diff --git a/frontend/src/app/services/storage.service.ts b/frontend/src/app/services/storage.service.ts index 7494784f6..6eff9d391 100644 --- a/frontend/src/app/services/storage.service.ts +++ b/frontend/src/app/services/storage.service.ts @@ -6,18 +6,23 @@ import { Router, ActivatedRoute } from '@angular/router'; }) export class StorageService { constructor(private router: Router, private route: ActivatedRoute) { - let graphWindowPreference: string = this.getValue('graphWindowPreference'); + this.setDefaultValueIfNeeded('graphWindowPreference', '2h'); + this.setDefaultValueIfNeeded('poolsWindowPreference', '1d'); + } + + setDefaultValueIfNeeded(key: string, defaultValue: string) { + let graphWindowPreference: string = this.getValue(key); if (graphWindowPreference === null) { // First visit to mempool.space - if (this.router.url.includes("graphs")) { - this.setValue('graphWindowPreference', this.route.snapshot.fragment ? this.route.snapshot.fragment : "2h"); + if (this.router.url.includes("graphs") || this.router.url.includes("pools")) { + this.setValue(key, this.route.snapshot.fragment ? this.route.snapshot.fragment : defaultValue); } else { - this.setValue('graphWindowPreference', "2h"); + this.setValue(key, defaultValue); } - } else if (this.router.url.includes("graphs")) { // Visit a different graphs#fragment from last visit - if (this.route.snapshot.fragment !== null && graphWindowPreference !== this.route.snapshot.fragment) { - this.setValue('graphWindowPreference', this.route.snapshot.fragment); - } + } else if (this.router.url.includes("graphs") || this.router.url.includes("pools")) { // Visit a different graphs#fragment from last visit + if (this.route.snapshot.fragment !== null && graphWindowPreference !== this.route.snapshot.fragment) { + this.setValue(key, this.route.snapshot.fragment); } + } } getValue(key: string): string {