diff --git a/backend/src/api/bitcoin/bitcoin-api.interface.ts b/backend/src/api/bitcoin/bitcoin-api.interface.ts index e2b9158bb..6a22af9a0 100644 --- a/backend/src/api/bitcoin/bitcoin-api.interface.ts +++ b/backend/src/api/bitcoin/bitcoin-api.interface.ts @@ -4,6 +4,7 @@ export namespace IBitcoinApi { size: number; // (numeric) Current tx count bytes: number; // (numeric) Sum of all virtual transaction sizes as defined in BIP 141. usage: number; // (numeric) Total memory usage for the mempool + total_fee: number; // (numeric) Total fees of transactions in the mempool maxmempool: number; // (numeric) Maximum memory usage for the mempool mempoolminfee: number; // (numeric) Minimum fee rate in BTC/kB for tx to be accepted. minrelaytxfee: number; // (numeric) Current minimum relay fee for transactions diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 198f2a204..1452b6fc8 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -10,6 +10,7 @@ import bitcoinClient from './bitcoin/bitcoin-client'; import { IEsploraApi } from './bitcoin/esplora-api.interface'; import poolsRepository from '../repositories/PoolsRepository'; import blocksRepository from '../repositories/BlocksRepository'; +import loadingIndicators from './loading-indicators'; class Blocks { private blocks: BlockExtended[] = []; @@ -41,7 +42,12 @@ class Blocks { * @param onlyCoinbase - Set to true if you only need the coinbase transaction * @returns Promise */ - private async $getTransactionsExtended(blockHash: string, blockHeight: number, onlyCoinbase: boolean): Promise { + private async $getTransactionsExtended( + blockHash: string, + blockHeight: number, + onlyCoinbase: boolean, + quiet: boolean = false, + ): Promise { const transactions: TransactionExtended[] = []; const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash); @@ -55,9 +61,9 @@ class Blocks { // optimize here by directly fetching txs in the "outdated" mempool transactions.push(mempool[txIds[i]]); transactionsFound++; - } else if (config.MEMPOOL.BACKEND === 'esplora' || memPool.isInSync() || i === 0) { + } else if (config.MEMPOOL.BACKEND === 'esplora' || !memPool.hasPriority() || i === 0) { // Otherwise we fetch the tx data through backend services (esplora, electrum, core rpc...) - if (i % (Math.round((txIds.length) / 10)) === 0 || i + 1 === txIds.length) { // Avoid log spam + if (!quiet && (i % (Math.round((txIds.length) / 10)) === 0 || i + 1 === txIds.length)) { // Avoid log spam logger.debug(`Indexing tx ${i + 1} of ${txIds.length} in block #${blockHeight}`); } try { @@ -83,7 +89,9 @@ class Blocks { } }); - logger.debug(`${transactionsFound} of ${txIds.length} found in mempool. ${transactionsFetched} fetched through backend service.`); + if (!quiet) { + logger.debug(`${transactionsFound} of ${txIds.length} found in mempool. ${transactionsFetched} fetched through backend service.`); + } return transactions; } @@ -94,13 +102,10 @@ class Blocks { * @param transactions * @returns BlockExtended */ - private getBlockExtended(block: IEsploraApi.Block, transactions: TransactionExtended[]): BlockExtended { - const blockExtended: BlockExtended = Object.assign({}, block); - - blockExtended.extras = { - reward: transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0), - coinbaseTx: transactionUtils.stripCoinbaseTransaction(transactions[0]), - }; + private async $getBlockExtended(block: IEsploraApi.Block, transactions: TransactionExtended[]): Promise { + const blockExtended: BlockExtended = Object.assign({extras: {}}, block); + blockExtended.extras.reward = transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0); + blockExtended.extras.coinbaseTx = transactionUtils.stripCoinbaseTransaction(transactions[0]); const transactionsTmp = [...transactions]; transactionsTmp.shift(); @@ -111,6 +116,19 @@ class Blocks { blockExtended.extras.feeRange = transactionsTmp.length > 0 ? Common.getFeesInRange(transactionsTmp, 8) : [0, 0]; + if (Common.indexingEnabled()) { + let pool: PoolTag; + if (blockExtended.extras?.coinbaseTx !== undefined) { + pool = await this.$findBlockMiner(blockExtended.extras?.coinbaseTx); + } else { + pool = await poolsRepository.$getUnknownPool(); + } + blockExtended.extras.pool = { + id: pool.id, + name: pool.name + }; + } + return blockExtended; } @@ -152,20 +170,20 @@ class Blocks { * Index all blocks metadata for the mining dashboard */ public async $generateBlockDatabase() { - if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false || // Bitcoin only - config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === 0 || // Indexing must be enabled - !memPool.isInSync() || // We sync the mempool first - this.blockIndexingStarted === true // Indexing must not already be in progress + if (this.blockIndexingStarted === true || + !Common.indexingEnabled() || + memPool.hasPriority() ) { return; } const blockchainInfo = await bitcoinClient.getBlockchainInfo(); - if (blockchainInfo.blocks !== blockchainInfo.headers) { + if (blockchainInfo.blocks !== blockchainInfo.headers) { // Wait for node to sync return; } this.blockIndexingStarted = true; + const startedAt = new Date().getTime() / 1000; try { let currentBlockHeight = blockchainInfo.blocks; @@ -180,6 +198,8 @@ class Blocks { logger.info(`Indexing blocks from #${currentBlockHeight} to #${lastBlockToIndex}`); const chunkSize = 10000; + let totaIndexed = await blocksRepository.$blockCount(null, null); + let indexedThisRun = 0; while (currentBlockHeight >= lastBlockToIndex) { const endBlock = Math.max(0, lastBlockToIndex, currentBlockHeight - chunkSize + 1); @@ -198,21 +218,19 @@ class Blocks { break; } try { - logger.debug(`Indexing block #${blockHeight}`); + ++indexedThisRun; + if (++totaIndexed % 100 === 0 || blockHeight === lastBlockToIndex) { + const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); + const blockPerSeconds = Math.max(1, Math.round(indexedThisRun / elapsedSeconds)); + const progress = Math.round(totaIndexed / indexingBlockAmount * 100); + const timeLeft = Math.round((indexingBlockAmount - totaIndexed) / blockPerSeconds); + logger.debug(`Indexing block #${blockHeight} | ~${blockPerSeconds} blocks/sec | total: ${totaIndexed}/${indexingBlockAmount} (${progress}%) | elapsed: ${elapsedSeconds} seconds | left: ~${timeLeft} seconds`); + } const blockHash = await bitcoinApi.$getBlockHash(blockHeight); const block = await bitcoinApi.$getBlock(blockHash); - const transactions = await this.$getTransactionsExtended(blockHash, block.height, true); - const blockExtended = this.getBlockExtended(block, transactions); - - let miner: PoolTag; - if (blockExtended?.extras?.coinbaseTx) { - miner = await this.$findBlockMiner(blockExtended.extras.coinbaseTx); - } else { - miner = await poolsRepository.$getUnknownPool(); - } - - const coinbase: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(transactions[0].txid, true); - await blocksRepository.$saveBlockInDatabase(blockExtended, blockHash, coinbase.hex, miner); + const transactions = await this.$getTransactionsExtended(blockHash, block.height, true, true); + const blockExtended = await this.$getBlockExtended(block, transactions); + await blocksRepository.$saveBlockInDatabase(blockExtended); } catch (e) { logger.err(`Something went wrong while indexing blocks.` + e); } @@ -271,17 +289,10 @@ class Blocks { const block = await bitcoinApi.$getBlock(blockHash); const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash); const transactions = await this.$getTransactionsExtended(blockHash, block.height, false); - const blockExtended: BlockExtended = this.getBlockExtended(block, transactions); - const coinbase: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(transactions[0].txid, true); + const blockExtended: BlockExtended = await this.$getBlockExtended(block, transactions); - if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === true) { - let miner: PoolTag; - if (blockExtended?.extras?.coinbaseTx) { - miner = await this.$findBlockMiner(blockExtended.extras.coinbaseTx); - } else { - miner = await poolsRepository.$getUnknownPool(); - } - await blocksRepository.$saveBlockInDatabase(blockExtended, blockHash, coinbase.hex, miner); + if (Common.indexingEnabled()) { + await blocksRepository.$saveBlockInDatabase(blockExtended); } if (block.height % 2016 === 0) { @@ -298,12 +309,98 @@ class Blocks { if (this.newBlockCallbacks.length) { this.newBlockCallbacks.forEach((cb) => cb(blockExtended, txIds, transactions)); } - if (memPool.isInSync()) { + if (!memPool.hasPriority()) { diskCache.$saveCacheToDisk(); } } } + /** + * Index a block if it's missing from the database. Returns the block after indexing + */ + public async $indexBlock(height: number): Promise { + const dbBlock = await blocksRepository.$getBlockByHeight(height); + if (dbBlock != null) { + return this.prepareBlock(dbBlock); + } + + const blockHash = await bitcoinApi.$getBlockHash(height); + const block = await bitcoinApi.$getBlock(blockHash); + const transactions = await this.$getTransactionsExtended(blockHash, block.height, true); + const blockExtended = await this.$getBlockExtended(block, transactions); + + await blocksRepository.$saveBlockInDatabase(blockExtended); + + return blockExtended; + } + + public async $getBlocksExtras(fromHeight: number): Promise { + try { + loadingIndicators.setProgress('blocks', 0); + + let currentHeight = fromHeight ? fromHeight : this.getCurrentBlockHeight(); + const returnBlocks: BlockExtended[] = []; + + if (currentHeight < 0) { + return returnBlocks; + } + + // Check if block height exist in local cache to skip the hash lookup + const blockByHeight = this.getBlocks().find((b) => b.height === currentHeight); + let startFromHash: string | null = null; + if (blockByHeight) { + startFromHash = blockByHeight.id; + } else { + startFromHash = await bitcoinApi.$getBlockHash(currentHeight); + } + + let nextHash = startFromHash; + for (let i = 0; i < 10 && currentHeight >= 0; i++) { + let block = this.getBlocks().find((b) => b.height === currentHeight); + if (!block && Common.indexingEnabled()) { + block = this.prepareBlock(await this.$indexBlock(currentHeight)); + } else if (!block) { + block = this.prepareBlock(await bitcoinApi.$getBlock(nextHash)); + } + returnBlocks.push(block); + nextHash = block.previousblockhash; + loadingIndicators.setProgress('blocks', i / 10 * 100); + currentHeight--; + } + + return returnBlocks; + } catch (e) { + loadingIndicators.setProgress('blocks', 100); + throw e; + } + } + + private prepareBlock(block: any): BlockExtended { + return { + id: block.id ?? block.hash, // hash for indexed block + timestamp: block?.timestamp ?? block?.blockTimestamp, // blockTimestamp for indexed block + height: block?.height, + version: block?.version, + bits: block?.bits, + nonce: block?.nonce, + difficulty: block?.difficulty, + merkle_root: block?.merkle_root, + tx_count: block?.tx_count, + size: block?.size, + weight: block?.weight, + previousblockhash: block?.previousblockhash, + extras: { + medianFee: block?.medianFee, + feeRange: block?.feeRange ?? [], // TODO + reward: block?.reward, + pool: block?.extras?.pool ?? (block?.pool_id ? { + id: block?.pool_id, + name: block?.pool_name, + } : undefined), + } + }; + } + public getLastDifficultyAdjustmentTime(): number { return this.lastDifficultyAdjustmentTime; } diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 5e99e870c..f9ae196b3 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -154,4 +154,27 @@ export class Common { }); return parents; } + + static getSqlInterval(interval: string | null): string | null { + switch (interval) { + case '24h': return '1 DAY'; + case '3d': return '3 DAY'; + case '1w': return '1 WEEK'; + case '1m': return '1 MONTH'; + case '3m': return '3 MONTH'; + case '6m': return '6 MONTH'; + case '1y': return '1 YEAR'; + case '2y': return '2 YEAR'; + case '3y': return '3 YEAR'; + default: return null; + } + } + + static indexingEnabled(): boolean { + return ( + ['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) && + config.DATABASE.ENABLED === true && + config.MEMPOOL.INDEXING_BLOCKS_AMOUNT != 0 + ); + } } diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 24ecc03cf..b44585580 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -6,7 +6,7 @@ import logger from '../logger'; const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); class DatabaseMigration { - private static currentVersion = 4; + private static currentVersion = 6; private queryTimeout = 120000; private statisticsAddedIndexed = false; @@ -76,6 +76,7 @@ class DatabaseMigration { private async $createMissingTablesAndIndexes(databaseSchemaVersion: number) { await this.$setStatisticsAddedIndexedFlag(databaseSchemaVersion); + const isBitcoin = ['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK); const connection = await DB.pool.getConnection(); try { await this.$executeQuery(connection, this.getCreateElementsTableQuery(), await this.$checkIfTableExists('elements_pegs')); @@ -90,6 +91,31 @@ class DatabaseMigration { await this.$executeQuery(connection, 'DROP table IF EXISTS blocks;'); await this.$executeQuery(connection, this.getCreateBlocksTableQuery(), await this.$checkIfTableExists('blocks')); } + if (databaseSchemaVersion < 5 && isBitcoin === true) { + await this.$executeQuery(connection, 'TRUNCATE blocks;'); // Need to re-index + await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `reward` double unsigned NOT NULL DEFAULT "0"'); + } + + if (databaseSchemaVersion < 6 && isBitcoin === true) { + await this.$executeQuery(connection, 'TRUNCATE blocks;'); // Need to re-index + // Cleanup original blocks fields type + await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `height` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `tx_count` smallint unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `size` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `weight` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `difficulty` double NOT NULL DEFAULT "0"'); + // We also fix the pools.id type so we need to drop/re-create the foreign key + await this.$executeQuery(connection, 'ALTER TABLE blocks DROP FOREIGN KEY IF EXISTS `blocks_ibfk_1`'); + await this.$executeQuery(connection, 'ALTER TABLE pools MODIFY `id` smallint unsigned AUTO_INCREMENT'); + await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `pool_id` smallint unsigned NULL'); + await this.$executeQuery(connection, 'ALTER TABLE blocks ADD FOREIGN KEY (`pool_id`) REFERENCES `pools` (`id`)'); + // Add new block indexing fields + await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `version` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `bits` integer unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `nonce` bigint unsigned NOT NULL DEFAULT "0"'); + await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `merkle_root` varchar(65) NOT NULL DEFAULT ""'); + await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `previous_block_hash` varchar(65) NULL'); + } connection.release(); } catch (e) { connection.release(); diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index b1bd6a159..64505ba2b 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -13,8 +13,9 @@ class Mempool { private static WEBSOCKET_REFRESH_RATE_MS = 10000; private static LAZY_DELETE_AFTER_SECONDS = 30; private inSync: boolean = false; + private mempoolCacheDelta: number = -1; private mempoolCache: { [txId: string]: TransactionExtended } = {}; - private mempoolInfo: IBitcoinApi.MempoolInfo = { loaded: false, size: 0, bytes: 0, usage: 0, + private mempoolInfo: IBitcoinApi.MempoolInfo = { loaded: false, size: 0, bytes: 0, usage: 0, total_fee: 0, maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 }; private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => void) | undefined; @@ -32,6 +33,17 @@ class Mempool { setInterval(this.deleteExpiredTransactions.bind(this), 20000); } + /** + * Return true if we should leave resources available for mempool tx caching + */ + public hasPriority(): boolean { + if (this.inSync) { + return false; + } else { + return this.mempoolCacheDelta == -1 || this.mempoolCacheDelta > 25; + } + } + public isInSync(): boolean { return this.inSync; } @@ -100,6 +112,8 @@ class Mempool { const diff = transactions.length - currentMempoolSize; const newTransactions: TransactionExtended[] = []; + this.mempoolCacheDelta = Math.abs(diff); + if (!this.inSync) { loadingIndicators.setProgress('mempool', Object.keys(this.mempoolCache).length / transactions.length * 100); } @@ -168,13 +182,14 @@ class Mempool { const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx)); this.latestTransactions = newTransactionsStripped.concat(this.latestTransactions).slice(0, 6); - const syncedThreshold = 0.99; // If we synced 99% of the mempool tx count, consider we're synced - if (!this.inSync && Object.keys(this.mempoolCache).length >= transactions.length * syncedThreshold) { + if (!this.inSync && transactions.length === Object.keys(this.mempoolCache).length) { this.inSync = true; logger.notice('The mempool is now in sync!'); loadingIndicators.setProgress('mempool', 100); } + this.mempoolCacheDelta = Math.abs(transactions.length - Object.keys(this.mempoolCache).length); + if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) { this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions); } diff --git a/backend/src/api/mining.ts b/backend/src/api/mining.ts index c89ea9324..beca52893 100644 --- a/backend/src/api/mining.ts +++ b/backend/src/api/mining.ts @@ -11,24 +11,10 @@ class Mining { * Generate high level overview of the pool ranks and general stats */ public async $getPoolsStats(interval: string | null) : Promise { - let sqlInterval: string | null = null; - switch (interval) { - case '24h': sqlInterval = '1 DAY'; break; - case '3d': sqlInterval = '3 DAY'; break; - case '1w': sqlInterval = '1 WEEK'; break; - case '1m': sqlInterval = '1 MONTH'; break; - case '3m': sqlInterval = '3 MONTH'; break; - case '6m': sqlInterval = '6 MONTH'; break; - case '1y': sqlInterval = '1 YEAR'; break; - case '2y': sqlInterval = '2 YEAR'; break; - case '3y': sqlInterval = '3 YEAR'; break; - default: sqlInterval = null; break; - } - const poolsStatistics = {}; - const poolsInfo: PoolInfo[] = await PoolsRepository.$getPoolsInfo(sqlInterval); - const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$countEmptyBlocks(sqlInterval); + const poolsInfo: PoolInfo[] = await PoolsRepository.$getPoolsInfo(interval); + const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$getEmptyBlocks(null, interval); const poolsStats: PoolStats[] = []; let rank = 1; @@ -55,7 +41,7 @@ class Mining { const oldestBlock = new Date(await BlocksRepository.$oldestBlockTimestamp()); poolsStatistics['oldestIndexedBlockTimestamp'] = oldestBlock.getTime(); - const blockCount: number = await BlocksRepository.$blockCount(sqlInterval); + const blockCount: number = await BlocksRepository.$blockCount(null, interval); poolsStatistics['blockCount'] = blockCount; const blockHeightTip = await bitcoinClient.getBlockCount(); @@ -64,6 +50,38 @@ class Mining { return poolsStatistics; } + + /** + * Get all mining pool stats for a pool + */ + public async $getPoolStat(interval: string | null, poolId: number): Promise { + const pool = await PoolsRepository.$getPool(poolId); + if (!pool) { + throw new Error(`This mining pool does not exist`); + } + + const blockCount: number = await BlocksRepository.$blockCount(poolId, interval); + const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$getEmptyBlocks(poolId, interval); + + return { + pool: pool, + blockCount: blockCount, + emptyBlocks: emptyBlocks, + }; + } + + /** + * Return the historical difficulty adjustments and oldest indexed block timestamp + */ + public async $getHistoricalDifficulty(interval: string | null): Promise { + const difficultyAdjustments = await BlocksRepository.$getBlocksDifficulty(interval); + const oldestBlock = new Date(await BlocksRepository.$oldestBlockTimestamp()); + + return { + adjustments: difficultyAdjustments, + oldestIndexedBlockTimestamp: oldestBlock.getTime(), + } + } } export default new Mining(); diff --git a/backend/src/index.ts b/backend/src/index.ts index 557c269dd..23c70f59d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -256,6 +256,11 @@ 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')) + ; + } + + if (Common.indexingEnabled()) { + this.app .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/24h', routes.$getPools.bind(routes, '24h')) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/3d', routes.$getPools.bind(routes, '3d')) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/1w', routes.$getPools.bind(routes, '1w')) @@ -266,7 +271,12 @@ class Server { .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/2y', routes.$getPools.bind(routes, '2y')) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/3y', routes.$getPools.bind(routes, '3y')) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/all', routes.$getPools.bind(routes, 'all')) - ; + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/blocks', routes.$getPoolBlocks) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/blocks/:height', routes.$getPoolBlocks) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId', routes.$getPool) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/:interval', routes.$getPool) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty', routes.$getHistoricalDifficulty) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty/:interval', routes.$getHistoricalDifficulty); } if (config.BISQ.ENABLED) { @@ -290,6 +300,10 @@ class Server { ; } + this.app + .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-extras', routes.getBlocksExtras) + .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-extras/:height', routes.getBlocksExtras); + if (config.MEMPOOL.BACKEND !== 'esplora') { this.app .get(config.MEMPOOL.API_URL_PREFIX + 'mempool', routes.getMempool) diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 7dfcd3956..4869561c2 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -1,23 +1,23 @@ import { IEsploraApi } from './api/bitcoin/esplora-api.interface'; export interface PoolTag { - id: number | null, // mysql row id - name: string, - link: string, - regexes: string, // JSON array - addresses: string, // JSON array + id: number; // mysql row id + name: string; + link: string; + regexes: string; // JSON array + addresses: string; // JSON array } export interface PoolInfo { - poolId: number, // mysql row id - name: string, - link: string, - blockCount: number, + poolId: number; // mysql row id + name: string; + link: string; + blockCount: number; } export interface PoolStats extends PoolInfo { - rank: number, - emptyBlocks: number, + rank: number; + emptyBlocks: number; } export interface MempoolBlock { @@ -83,10 +83,14 @@ export interface BlockExtension { reward?: number; coinbaseTx?: TransactionMinerInfo; matchRate?: number; + pool?: { + id: number; + name: string; + } } export interface BlockExtended extends IEsploraApi.Block { - extras?: BlockExtension; + extras: BlockExtension; } export interface TransactionMinerInfo { diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index 654376402..ac0ea25bc 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -1,6 +1,7 @@ import { BlockExtended, PoolTag } from '../mempool.interfaces'; import { DB } from '../database'; import logger from '../logger'; +import { Common } from '../api/common'; export interface EmptyBlocks { emptyBlocks: number; @@ -11,40 +12,46 @@ class BlocksRepository { /** * Save indexed block data in the database */ - public async $saveBlockInDatabase( - block: BlockExtended, - blockHash: string, - coinbaseHex: string | undefined, - poolTag: PoolTag - ) { + public async $saveBlockInDatabase(block: BlockExtended) { const connection = await DB.pool.getConnection(); try { const query = `INSERT INTO blocks( height, hash, blockTimestamp, size, weight, tx_count, coinbase_raw, difficulty, - pool_id, fees, fee_span, median_fee + pool_id, fees, fee_span, median_fee, + reward, version, bits, nonce, + merkle_root, previous_block_hash ) VALUE ( ?, ?, FROM_UNIXTIME(?), ?, ?, ?, ?, ?, - ?, ?, ?, ? + ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ? )`; const params: any[] = [ block.height, - blockHash, + block.id, block.timestamp, block.size, block.weight, block.tx_count, - coinbaseHex ? coinbaseHex : '', + '', block.difficulty, - poolTag.id, + block.extras.pool?.id, // Should always be set to something 0, '[]', - block.extras ? block.extras.medianFee : 0, + block.extras.medianFee ?? 0, + block.extras.reward ?? 0, + block.version, + block.bits, + block.nonce, + block.merkle_root, + block.previousblockhash ]; + // logger.debug(query); await connection.query(query, params); } catch (e: any) { if (e.errno === 1062) { // ER_DUP_ENTRY @@ -66,35 +73,45 @@ class BlocksRepository { } const connection = await DB.pool.getConnection(); - const [rows] : any[] = await connection.query(` + const [rows]: any[] = await connection.query(` SELECT height FROM blocks - WHERE height <= ${startHeight} AND height >= ${endHeight} + WHERE height <= ? AND height >= ? ORDER BY height DESC; - `); + `, [startHeight, endHeight]); connection.release(); const indexedBlockHeights: number[] = []; rows.forEach((row: any) => { indexedBlockHeights.push(row.height); }); const seekedBlocks: number[] = Array.from(Array(startHeight - endHeight + 1).keys(), n => n + endHeight).reverse(); - const missingBlocksHeights = seekedBlocks.filter(x => indexedBlockHeights.indexOf(x) === -1); + const missingBlocksHeights = seekedBlocks.filter(x => indexedBlockHeights.indexOf(x) === -1); return missingBlocksHeights; } /** - * Count empty blocks for all pools + * Get empty blocks for one or all pools */ - public async $countEmptyBlocks(interval: string | null): Promise { - const query = ` - SELECT pool_id as poolId - FROM blocks - WHERE tx_count = 1` + - (interval != null ? ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()` : ``) - ; + public async $getEmptyBlocks(poolId: number | null, interval: string | null = null): Promise { + interval = Common.getSqlInterval(interval); + const params: any[] = []; + let query = `SELECT height, hash, tx_count, size, pool_id, weight, UNIX_TIMESTAMP(blockTimestamp) as timestamp + FROM blocks + WHERE tx_count = 1`; + + if (poolId) { + query += ` AND pool_id = ?`; + params.push(poolId); + } + + if (interval) { + query += ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } + + // logger.debug(query); const connection = await DB.pool.getConnection(); - const [rows] = await connection.query(query); + const [rows] = await connection.query(query, params); connection.release(); return rows; @@ -103,15 +120,30 @@ class BlocksRepository { /** * Get blocks count for a period */ - public async $blockCount(interval: string | null): Promise { - const query = ` - SELECT count(height) as blockCount - FROM blocks` + - (interval != null ? ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()` : ``) - ; + public async $blockCount(poolId: number | null, interval: string | null): Promise { + interval = Common.getSqlInterval(interval); + const params: any[] = []; + let query = `SELECT count(height) as blockCount + FROM blocks`; + + if (poolId) { + query += ` WHERE pool_id = ?`; + params.push(poolId); + } + + if (interval) { + if (poolId) { + query += ` AND`; + } else { + query += ` WHERE`; + } + query += ` blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } + + // logger.debug(query); const connection = await DB.pool.getConnection(); - const [rows] = await connection.query(query); + const [rows] = await connection.query(query, params); connection.release(); return rows[0].blockCount; @@ -121,13 +153,15 @@ class BlocksRepository { * Get the oldest indexed block */ public async $oldestBlockTimestamp(): Promise { - const connection = await DB.pool.getConnection(); - const [rows]: any[] = await connection.query(` - SELECT blockTimestamp + const query = `SELECT blockTimestamp FROM blocks ORDER BY height - LIMIT 1; - `); + LIMIT 1;`; + + + // logger.debug(query); + const connection = await DB.pool.getConnection(); + const [rows]: any[] = await connection.query(query); connection.release(); if (rows.length <= 0) { @@ -136,6 +170,83 @@ class BlocksRepository { return rows[0].blockTimestamp; } + + /** + * Get blocks mined by a specific mining pool + */ + public async $getBlocksByPool( + poolId: number, + startHeight: number | null = null + ): Promise { + const params: any[] = []; + let query = `SELECT height, hash as id, tx_count, size, weight, pool_id, UNIX_TIMESTAMP(blockTimestamp) as timestamp, reward + FROM blocks + WHERE pool_id = ?`; + params.push(poolId); + + if (startHeight) { + query += ` AND height < ?`; + params.push(startHeight); + } + + query += ` ORDER BY height DESC + LIMIT 10`; + + // logger.debug(query); + const connection = await DB.pool.getConnection(); + const [rows] = await connection.query(query, params); + connection.release(); + + for (const block of rows) { + delete block['blockTimestamp']; + } + + return rows; + } + + /** + * Get one block by height + */ + public async $getBlockByHeight(height: number): Promise { + const connection = await DB.pool.getConnection(); + const [rows]: any[] = await connection.query(` + SELECT *, UNIX_TIMESTAMP(blocks.blockTimestamp) as blockTimestamp, pools.id as pool_id, pools.name as pool_name, pools.link as pool_link, pools.addresses as pool_addresses, pools.regexes as pool_regexes + FROM blocks + JOIN pools ON blocks.pool_id = pools.id + WHERE height = ${height}; + `); + connection.release(); + + if (rows.length <= 0) { + return null; + } + + return rows[0]; + } + + /** + * Return blocks difficulty + */ + public async $getBlocksDifficulty(interval: string | null): Promise { + interval = Common.getSqlInterval(interval); + + const connection = await DB.pool.getConnection(); + + let query = `SELECT MIN(UNIX_TIMESTAMP(blockTimestamp)) as timestamp, difficulty, height + FROM blocks`; + + if (interval) { + query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } + + query += ` GROUP BY difficulty + ORDER BY blockTimestamp DESC`; + + const [rows]: any[] = await connection.query(query); + connection.release(); + + return rows; + } } -export default new BlocksRepository(); \ No newline at end of file +export default new BlocksRepository(); diff --git a/backend/src/repositories/PoolsRepository.ts b/backend/src/repositories/PoolsRepository.ts index d1fb0da9a..a7b716da7 100644 --- a/backend/src/repositories/PoolsRepository.ts +++ b/backend/src/repositories/PoolsRepository.ts @@ -1,4 +1,6 @@ +import { Common } from '../api/common'; import { DB } from '../database'; +import logger from '../logger'; import { PoolInfo, PoolTag } from '../mempool.interfaces'; class PoolsRepository { @@ -7,7 +9,7 @@ class PoolsRepository { */ public async $getPools(): Promise { const connection = await DB.pool.getConnection(); - const [rows] = await connection.query('SELECT * FROM pools;'); + const [rows] = await connection.query('SELECT id, name, addresses, regexes FROM pools;'); connection.release(); return rows; } @@ -17,7 +19,7 @@ class PoolsRepository { */ public async $getUnknownPool(): Promise { const connection = await DB.pool.getConnection(); - const [rows] = await connection.query('SELECT * FROM pools where name = "Unknown"'); + const [rows] = await connection.query('SELECT id, name FROM pools where name = "Unknown"'); connection.release(); return rows[0]; } @@ -25,22 +27,47 @@ class PoolsRepository { /** * Get basic pool info and block count */ - public async $getPoolsInfo(interval: string | null): Promise { - const 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` + - (interval != null ? ` WHERE blocks.blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()` : ``) + - ` GROUP BY pool_id - ORDER BY COUNT(height) DESC - `; + public async $getPoolsInfo(interval: string | null = null): Promise { + interval = Common.getSqlInterval(interval); + let 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`; + + if (interval) { + query += ` WHERE blocks.blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } + + query += ` GROUP BY pool_id + ORDER BY COUNT(height) DESC`; + + // logger.debug(query); const connection = await DB.pool.getConnection(); const [rows] = await connection.query(query); connection.release(); return rows; } + + /** + * Get mining pool statistics for one pool + */ + public async $getPool(poolId: any): Promise { + const query = ` + SELECT * + FROM pools + WHERE pools.id = ?`; + + // logger.debug(query); + const connection = await DB.pool.getConnection(); + const [rows] = await connection.query(query, [poolId]); + connection.release(); + + rows[0].regexes = JSON.parse(rows[0].regexes); + rows[0].addresses = JSON.parse(rows[0].addresses); + + return rows[0]; + } } export default new PoolsRepository(); diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 8ae2f9609..4a9cb1f8f 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -22,6 +22,9 @@ import elementsParser from './api/liquid/elements-parser'; import icons from './api/liquid/icons'; import miningStats from './api/mining'; import axios from 'axios'; +import PoolsRepository from './repositories/PoolsRepository'; +import mining from './api/mining'; +import BlocksRepository from './repositories/BlocksRepository'; class Routes { constructor() {} @@ -533,9 +536,9 @@ class Routes { } } - public async $getPools(interval: string, req: Request, res: Response) { + public async $getPool(req: Request, res: Response) { try { - let stats = await miningStats.$getPoolsStats(interval); + const stats = await mining.$getPoolStat(req.params.interval ?? null, parseInt(req.params.poolId, 10)); res.header('Pragma', 'public'); res.header('Cache-control', 'public'); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); @@ -545,6 +548,45 @@ class Routes { } } + public async $getPoolBlocks(req: Request, res: Response) { + try { + const poolBlocks = await BlocksRepository.$getBlocksByPool( + parseInt(req.params.poolId, 10), + parseInt(req.params.height, 10) ?? null, + ); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); + res.json(poolBlocks); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + public async $getPools(interval: string, req: Request, res: Response) { + try { + const stats = await miningStats.$getPoolsStats(interval); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); + res.json(stats); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + public async $getHistoricalDifficulty(req: Request, res: Response) { + try { + const stats = await mining.$getHistoricalDifficulty(req.params.interval ?? null); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); + 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); @@ -564,6 +606,14 @@ class Routes { } } + public async getBlocksExtras(req: Request, res: Response) { + try { + res.json(await blocks.$getBlocksExtras(parseInt(req.params.height, 10))) + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + public async getBlocks(req: Request, res: Response) { try { loadingIndicators.setProgress('blocks', 0); @@ -691,7 +741,13 @@ class Routes { } public async getMempool(req: Request, res: Response) { - res.status(501).send('Not implemented'); + const info = mempool.getMempoolInfo(); + res.json({ + count: info.size, + vsize: info.bytes, + total_fee: info.total_fee * 1e8, + fee_histogram: [] + }); } public async getMempoolTxIds(req: Request, res: Response) { diff --git a/docker/backend/mempool-config.json b/docker/backend/mempool-config.json index 3a0a0ec0b..6b2319a59 100644 --- a/docker/backend/mempool-config.json +++ b/docker/backend/mempool-config.json @@ -15,7 +15,8 @@ "PRICE_FEED_UPDATE_INTERVAL": __MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__, "USE_SECOND_NODE_FOR_MINFEE": __MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__, "EXTERNAL_ASSETS": __MEMPOOL_EXTERNAL_ASSETS__, - "STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__" + "STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__", + "INDEXING_BLOCKS_AMOUNT": __MEMPOOL_INDEXING_BLOCKS_AMOUNT__ }, "CORE_RPC": { "HOST": "__CORE_RPC_HOST__", diff --git a/frontend/cypress/integration/mainnet/mainnet.spec.ts b/frontend/cypress/integration/mainnet/mainnet.spec.ts index 752617092..473c480f4 100644 --- a/frontend/cypress/integration/mainnet/mainnet.spec.ts +++ b/frontend/cypress/integration/mainnet/mainnet.spec.ts @@ -274,113 +274,19 @@ describe('Mainnet', () => { }); }); }); - }); - }); - - it('loads skeleton when changes between networks', () => { - cy.visit('/'); - cy.waitForSkeletonGone(); - - cy.changeNetwork("testnet"); - cy.changeNetwork("signet"); - cy.changeNetwork("mainnet"); - }); - - it.skip('loads the dashboard with the skeleton blocks', () => { - cy.mockMempoolSocket(); - cy.visit("/"); - cy.get(':nth-child(1) > #bitcoin-block-0').should('be.visible'); - cy.get(':nth-child(2) > #bitcoin-block-0').should('be.visible'); - cy.get(':nth-child(3) > #bitcoin-block-0').should('be.visible'); - cy.get('#mempool-block-0').should('be.visible'); - cy.get('#mempool-block-1').should('be.visible'); - cy.get('#mempool-block-2').should('be.visible'); - - emitMempoolInfo({ - 'params': { - command: 'init' - } - }); - - cy.get(':nth-child(1) > #bitcoin-block-0').should('not.exist'); - cy.get(':nth-child(2) > #bitcoin-block-0').should('not.exist'); - cy.get(':nth-child(3) > #bitcoin-block-0').should('not.exist'); - }); - - it('loads the pools screen', () => { - cy.visit('/'); - cy.waitForSkeletonGone(); - cy.get('#btn-pools').click().then(() => { - cy.waitForPageIdle(); - }); - }); - - it('loads the graphs screen', () => { - cy.visit('/'); - cy.waitForSkeletonGone(); - cy.get('#btn-graphs').click().then(() => { - cy.wait(1000); - }); - }); - - describe('graphs page', () => { - it('check buttons - mobile', () => { - cy.viewport('iphone-6'); - cy.visit('/graphs'); - cy.waitForSkeletonGone(); - cy.get('.small-buttons > :nth-child(2)').should('be.visible'); - cy.get('#dropdownFees').should('be.visible'); - cy.get('.btn-group').should('be.visible'); - }); - it('check buttons - tablet', () => { - cy.viewport('ipad-2'); - cy.visit('/graphs'); - cy.waitForSkeletonGone(); - cy.get('.small-buttons > :nth-child(2)').should('be.visible'); - cy.get('#dropdownFees').should('be.visible'); - cy.get('.btn-group').should('be.visible'); - }); - it('check buttons - desktop', () => { - cy.viewport('macbook-16'); - cy.visit('/graphs'); - cy.waitForSkeletonGone(); - cy.get('.small-buttons > :nth-child(2)').should('be.visible'); - cy.get('#dropdownFees').should('be.visible'); - cy.get('.btn-group').should('be.visible'); - }); - }); - - it('loads the tv screen - desktop', () => { - cy.viewport('macbook-16'); - cy.visit('/'); - cy.waitForSkeletonGone(); - cy.get('#btn-tv').click().then(() => { - cy.viewport('macbook-16'); - cy.get('.chart-holder'); - cy.get('.blockchain-wrapper').should('be.visible'); - cy.get('#mempool-block-0').should('be.visible'); - }); - }); - - it('loads the tv screen - mobile', () => { - cy.viewport('iphone-6'); - cy.visit('/tv'); - cy.waitForSkeletonGone(); - cy.get('.chart-holder'); - cy.get('.blockchain-wrapper').should('not.visible'); - }); - - it('loads genesis block and click on the arrow left', () => { - cy.viewport('macbook-16'); - cy.visit('/block/0'); - cy.waitForSkeletonGone(); - cy.waitForPageIdle(); - cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible'); - cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('not.exist'); - cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').click().then(() => { - cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible'); - cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible'); + it('loads genesis block and click on the arrow left', () => { + cy.viewport('macbook-16'); + cy.visit('/block/0'); + cy.waitForSkeletonGone(); + cy.waitForPageIdle(); + cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible'); + cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('not.exist'); + cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').click().then(() => { + cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible'); + cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible'); + }); + }); }); }); diff --git a/frontend/mempool-frontend-config.sample.json b/frontend/mempool-frontend-config.sample.json index 0715cb0bd..231f1c7c8 100644 --- a/frontend/mempool-frontend-config.sample.json +++ b/frontend/mempool-frontend-config.sample.json @@ -15,5 +15,6 @@ "BASE_MODULE": "mempool", "MEMPOOL_WEBSITE_URL": "https://mempool.space", "LIQUID_WEBSITE_URL": "https://liquid.network", - "BISQ_WEBSITE_URL": "https://bisq.markets" + "BISQ_WEBSITE_URL": "https://bisq.markets", + "MINING_DASHBOARD": true } diff --git a/frontend/server.ts b/frontend/server.ts index df4ab1294..b6c765588 100644 --- a/frontend/server.ts +++ b/frontend/server.ts @@ -66,6 +66,7 @@ export function app(locale: string): express.Express { server.get('/address/*', getLocalizedSSR(indexHtml)); server.get('/blocks', getLocalizedSSR(indexHtml)); server.get('/mining/pools', getLocalizedSSR(indexHtml)); + server.get('/mining/pool/*', 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 aaf545206..f0aa73e3d 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -26,6 +26,9 @@ import { PoolRankingComponent } from './components/pool-ranking/pool-ranking.com import { AssetGroupComponent } from './components/assets/asset-group/asset-group.component'; import { AssetsFeaturedComponent } from './components/assets/assets-featured/assets-featured.component'; import { AssetsComponent } from './components/assets/assets.component'; +import { PoolComponent } from './components/pool/pool.component'; +import { MiningDashboardComponent } from './components/mining-dashboard/mining-dashboard.component'; +import { DifficultyChartComponent } from './components/difficulty-chart/difficulty-chart.component'; let routes: Routes = [ { @@ -56,16 +59,28 @@ let routes: Routes = [ path: 'mempool-block/:id', component: MempoolBlockComponent }, + { + path: 'mining', + component: MiningDashboardComponent, + }, ], }, { path: 'blocks', component: LatestBlocksComponent, }, + { + path: 'mining/difficulty', + component: DifficultyChartComponent, + }, { path: 'mining/pools', component: PoolRankingComponent, }, + { + path: 'mining/pool/:poolId', + component: PoolComponent, + }, { path: 'graphs', component: StatisticsComponent, @@ -144,16 +159,28 @@ let routes: Routes = [ path: 'mempool-block/:id', component: MempoolBlockComponent }, + { + path: 'mining', + component: MiningDashboardComponent, + }, ], }, { path: 'blocks', component: LatestBlocksComponent, }, + { + path: 'mining/difficulty', + component: DifficultyChartComponent, + }, { path: 'mining/pools', component: PoolRankingComponent, }, + { + path: 'mining/pool/:poolId', + component: PoolComponent, + }, { path: 'graphs', component: StatisticsComponent, @@ -226,16 +253,28 @@ let routes: Routes = [ path: 'mempool-block/:id', component: MempoolBlockComponent }, + { + path: 'mining', + component: MiningDashboardComponent, + }, ], }, { path: 'blocks', component: LatestBlocksComponent, }, + { + path: 'mining/difficulty', + component: DifficultyChartComponent, + }, { path: 'mining/pools', component: PoolRankingComponent, }, + { + path: 'mining/pool/:poolId', + component: PoolComponent, + }, { path: 'graphs', component: StatisticsComponent, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 97fc16204..677d88d6e 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -38,6 +38,7 @@ 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 { PoolComponent } from './components/pool/pool.component'; import { LbtcPegsGraphComponent } from './components/lbtc-pegs-graph/lbtc-pegs-graph.component'; import { AssetComponent } from './components/asset/asset.component'; import { AssetsComponent } from './components/assets/assets.component'; @@ -67,6 +68,8 @@ import { PushTransactionComponent } from './components/push-transaction/push-tra import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { AssetsFeaturedComponent } from './components/assets/assets-featured/assets-featured.component'; import { AssetGroupComponent } from './components/assets/asset-group/asset-group.component'; +import { MiningDashboardComponent } from './components/mining-dashboard/mining-dashboard.component'; +import { DifficultyChartComponent } from './components/difficulty-chart/difficulty-chart.component'; @NgModule({ declarations: [ @@ -96,6 +99,7 @@ import { AssetGroupComponent } from './components/assets/asset-group/asset-group IncomingTransactionsGraphComponent, MempoolGraphComponent, PoolRankingComponent, + PoolComponent, LbtcPegsGraphComponent, AssetComponent, AssetsComponent, @@ -116,6 +120,8 @@ import { AssetGroupComponent } from './components/assets/asset-group/asset-group AssetsNavComponent, AssetsFeaturedComponent, AssetGroupComponent, + MiningDashboardComponent, + DifficultyChartComponent, ], imports: [ BrowserModule.withServerTransition({ appId: 'serverApp' }), diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html index 50fd82b09..1914b5d08 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html @@ -21,9 +21,13 @@
+ -
+
diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss index 3b1347cea..a20b1cb35 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss @@ -124,3 +124,9 @@ 50% {opacity: 1.0;} 100% {opacity: 0.7;} } + +.badge { + position: relative; + top: 15px; + z-index: 101; +} \ No newline at end of file diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts index ef076e74b..a8d055602 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; +import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, Input } from '@angular/core'; import { Observable, Subscription } from 'rxjs'; import { StateService } from 'src/app/services/state.service'; import { Router } from '@angular/router'; @@ -12,6 +12,7 @@ import { BlockExtended } from 'src/app/interfaces/node-api.interface'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class BlockchainBlocksComponent implements OnInit, OnDestroy { + @Input() showMiningInfo: boolean = false; specialBlocks = specialBlocks; network = ''; blocks: BlockExtended[] = []; diff --git a/frontend/src/app/components/blockchain/blockchain.component.html b/frontend/src/app/components/blockchain/blockchain.component.html index 04f71e130..19ee5676d 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.html +++ b/frontend/src/app/components/blockchain/blockchain.component.html @@ -1,8 +1,8 @@ -
+
- +
diff --git a/frontend/src/app/components/blockchain/blockchain.component.scss b/frontend/src/app/components/blockchain/blockchain.component.scss index 0527798a2..a33fc58d2 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.scss +++ b/frontend/src/app/components/blockchain/blockchain.component.scss @@ -16,7 +16,6 @@ } .blockchain-wrapper { - overflow: hidden; height: 250px; -webkit-user-select: none; /* Safari */ @@ -60,4 +59,14 @@ width: 300px; left: -150px; top: 0px; -} \ No newline at end of file +} + +.animate { + transition: all 1s ease-in-out; +} +.move-left { + transform: translate(-40%, 0); + @media (max-width: 767.98px) { + transform: translate(-85%, 0); + } +} diff --git a/frontend/src/app/components/blockchain/blockchain.component.ts b/frontend/src/app/components/blockchain/blockchain.component.ts index f17569e27..b47eee833 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.ts +++ b/frontend/src/app/components/blockchain/blockchain.component.ts @@ -8,10 +8,11 @@ import { StateService } from 'src/app/services/state.service'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class BlockchainComponent implements OnInit { + showMiningInfo: boolean = false; network: string; constructor( - private stateService: StateService, + public stateService: StateService, ) {} ngOnInit() { diff --git a/frontend/src/app/components/difficulty-chart/difficulty-chart.component.html b/frontend/src/app/components/difficulty-chart/difficulty-chart.component.html new file mode 100644 index 000000000..ca005f2d4 --- /dev/null +++ b/frontend/src/app/components/difficulty-chart/difficulty-chart.component.html @@ -0,0 +1,53 @@ +
+ +
+
+
+
+ +
+
+
+ + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + +
BlockTimestampDifficultyChange
{{ diffChange.height }}‎{{ diffChange.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}{{ formatNumber(diffChange.difficulty, locale, '1.2-2') }}{{ diffChange.difficultyShorten }}{{ formatNumber(diffChange.change, locale, '1.2-2') }}%
+ +
diff --git a/frontend/src/app/components/difficulty-chart/difficulty-chart.component.scss b/frontend/src/app/components/difficulty-chart/difficulty-chart.component.scss new file mode 100644 index 000000000..c3a63e9fa --- /dev/null +++ b/frontend/src/app/components/difficulty-chart/difficulty-chart.component.scss @@ -0,0 +1,10 @@ +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} diff --git a/frontend/src/app/components/difficulty-chart/difficulty-chart.component.ts b/frontend/src/app/components/difficulty-chart/difficulty-chart.component.ts new file mode 100644 index 000000000..350e3c4be --- /dev/null +++ b/frontend/src/app/components/difficulty-chart/difficulty-chart.component.ts @@ -0,0 +1,154 @@ +import { Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; +import { EChartsOption } from 'echarts'; +import { Observable } from 'rxjs'; +import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; +import { ApiService } from 'src/app/services/api.service'; +import { SeoService } from 'src/app/services/seo.service'; +import { formatNumber } from '@angular/common'; +import { FormBuilder, FormGroup } from '@angular/forms'; + +@Component({ + selector: 'app-difficulty-chart', + templateUrl: './difficulty-chart.component.html', + styleUrls: ['./difficulty-chart.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 38%; + left: calc(50% - 15px); + z-index: 100; + } + `], +}) +export class DifficultyChartComponent implements OnInit { + @Input() widget: boolean = false; + + radioGroupForm: FormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg' + }; + + difficultyObservable$: Observable; + isLoading = true; + formatNumber = formatNumber; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private seoService: SeoService, + private apiService: ApiService, + private formBuilder: FormBuilder, + ) { + this.seoService.setTitle($localize`:@@mining.difficulty:Difficulty`); + this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); + this.radioGroupForm.controls.dateSpan.setValue('1y'); + } + + ngOnInit(): void { + const powerOfTen = { + terra: Math.pow(10, 12), + giga: Math.pow(10, 9), + mega: Math.pow(10, 6), + kilo: Math.pow(10, 3), + } + + this.difficultyObservable$ = this.radioGroupForm.get('dateSpan').valueChanges + .pipe( + startWith('1y'), + switchMap((timespan) => { + return this.apiService.getHistoricalDifficulty$(timespan) + .pipe( + tap(data => { + this.prepareChartOptions(data.adjustments.map(val => [val.timestamp * 1000, val.difficulty])); + this.isLoading = false; + }), + map(data => { + const availableTimespanDay = ( + (new Date().getTime() / 1000) - (data.oldestIndexedBlockTimestamp / 1000) + ) / 3600 / 24; + + const tableData = []; + for (let i = 0; i < data.adjustments.length - 1; ++i) { + const change = (data.adjustments[i].difficulty / data.adjustments[i + 1].difficulty - 1) * 100; + let selectedPowerOfTen = { divider: powerOfTen.terra, unit: 'T' }; + if (data.adjustments[i].difficulty < powerOfTen.mega) { + selectedPowerOfTen = { divider: 1, unit: '' }; // no scaling + } else if (data.adjustments[i].difficulty < powerOfTen.giga) { + selectedPowerOfTen = { divider: powerOfTen.mega, unit: 'M' }; + } else if (data.adjustments[i].difficulty < powerOfTen.terra) { + selectedPowerOfTen = { divider: powerOfTen.giga, unit: 'G' }; + } + + tableData.push(Object.assign(data.adjustments[i], { + change: change, + difficultyShorten: formatNumber( + data.adjustments[i].difficulty / selectedPowerOfTen.divider, + this.locale, '1.2-2') + selectedPowerOfTen.unit + })); + } + return { + availableTimespanDay: availableTimespanDay, + data: tableData + }; + }), + ); + }), + share() + ); + } + + prepareChartOptions(data) { + this.chartOptions = { + title: { + text: this.widget? '' : $localize`:@@mining.difficulty:Difficulty`, + left: 'center', + textStyle: { + color: '#FFF', + }, + }, + tooltip: { + show: true, + trigger: 'axis', + }, + axisPointer: { + type: 'line', + }, + xAxis: { + type: 'time', + splitNumber: this.isMobile() ? 5 : 10, + }, + yAxis: { + type: 'value', + axisLabel: { + formatter: (val) => { + const diff = val / Math.pow(10, 12); // terra + return diff.toString() + 'T'; + } + }, + splitLine: { + lineStyle: { + type: 'dotted', + color: '#ffffff66', + opacity: 0.25, + } + } + }, + series: [ + { + data: data, + type: 'line', + smooth: false, + lineStyle: { + width: 3, + }, + areaStyle: {} + }, + ], + }; + } + + isMobile() { + return (window.innerWidth <= 767.98); + } +} diff --git a/frontend/src/app/components/master-page/master-page.component.html b/frontend/src/app/components/master-page/master-page.component.html index 4624340d3..bab0f42e9 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -31,8 +31,11 @@ - +
+ + + + + + + + + + + + + + + + + + + + +
HeightTimestampMinedRewardTransactionsSize
{{ block.height }}‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}{{ block.tx_count | number }} +
+
+
+
+
+ +
\ No newline at end of file diff --git a/frontend/src/app/components/pool/pool.component.scss b/frontend/src/app/components/pool/pool.component.scss new file mode 100644 index 000000000..271696a39 --- /dev/null +++ b/frontend/src/app/components/pool/pool.component.scss @@ -0,0 +1,41 @@ +.progress { + background-color: #2d3348; +} + +@media (min-width: 768px) { + .d-md-block { + display: table-cell !important; + } +} +@media (min-width: 992px) { + .d-lg-block { + display: table-cell !important; + } +} + +.formRadioGroup { + margin-top: 6px; + display: flex; + flex-direction: column; + @media (min-width: 830px) { + margin-left: 2%; + flex-direction: row; + float: left; + margin-top: 0px; + } + .btn-sm { + font-size: 9px; + @media (min-width: 830px) { + font-size: 14px; + } + } +} + +div.scrollable { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow: auto; + max-height: 100px; +} \ No newline at end of file diff --git a/frontend/src/app/components/pool/pool.component.ts b/frontend/src/app/components/pool/pool.component.ts new file mode 100644 index 000000000..9d094dce0 --- /dev/null +++ b/frontend/src/app/components/pool/pool.component.ts @@ -0,0 +1,84 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; +import { distinctUntilChanged, map, startWith, switchMap, tap } from 'rxjs/operators'; +import { BlockExtended, PoolStat } from 'src/app/interfaces/node-api.interface'; +import { ApiService } from 'src/app/services/api.service'; +import { StateService } from 'src/app/services/state.service'; + +@Component({ + selector: 'app-pool', + templateUrl: './pool.component.html', + styleUrls: ['./pool.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PoolComponent implements OnInit { + poolStats$: Observable; + blocks$: Observable; + + fromHeight: number = -1; + fromHeightSubject: BehaviorSubject = new BehaviorSubject(this.fromHeight); + + blocks: BlockExtended[] = []; + poolId: number = undefined; + radioGroupForm: FormGroup; + + constructor( + private apiService: ApiService, + private route: ActivatedRoute, + public stateService: StateService, + private formBuilder: FormBuilder, + ) { + this.radioGroupForm = this.formBuilder.group({ dateSpan: '1w' }); + this.radioGroupForm.controls.dateSpan.setValue('1w'); + } + + ngOnInit(): void { + this.poolStats$ = combineLatest([ + this.route.params.pipe(map((params) => params.poolId)), + this.radioGroupForm.get('dateSpan').valueChanges.pipe(startWith('1w')), + ]) + .pipe( + switchMap((params: any) => { + this.poolId = params[0]; + if (this.blocks.length === 0) { + this.fromHeightSubject.next(undefined); + } + return this.apiService.getPoolStats$(this.poolId, params[1] ?? '1w'); + }), + map((poolStats) => { + let regexes = '"'; + for (const regex of poolStats.pool.regexes) { + regexes += regex + '", "'; + } + poolStats.pool.regexes = regexes.slice(0, -3); + poolStats.pool.addresses = poolStats.pool.addresses; + + return Object.assign({ + logo: `./resources/mining-pools/` + poolStats.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg' + }, poolStats); + }) + ); + + this.blocks$ = this.fromHeightSubject + .pipe( + distinctUntilChanged(), + switchMap((fromHeight) => { + return this.apiService.getPoolBlocks$(this.poolId, fromHeight); + }), + tap((newBlocks) => { + this.blocks = this.blocks.concat(newBlocks); + }), + map(() => this.blocks) + ) + } + + loadMore() { + this.fromHeightSubject.next(this.blocks[this.blocks.length - 1]?.height); + } + + trackByBlock(index: number, block: BlockExtended) { + return block.height; + } +} diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 373385422..472df0088 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -54,8 +54,11 @@ export interface LiquidPegs { export interface ITranslators { [language: string]: string; } +/** + * PoolRanking component + */ export interface SinglePoolStats { - pooldId: number; + poolId: number; name: string; link: string; blockCount: number; @@ -66,20 +69,35 @@ export interface SinglePoolStats { emptyBlockRatio: string; logo: string; } - export interface PoolsStats { blockCount: number; lastEstimatedHashrate: number; oldestIndexedBlockTimestamp: number; pools: SinglePoolStats[]; } - export interface MiningStats { - lastEstimatedHashrate: string, - blockCount: number, - totalEmptyBlock: number, - totalEmptyBlockRatio: string, - pools: SinglePoolStats[], + lastEstimatedHashrate: string; + blockCount: number; + totalEmptyBlock: number; + totalEmptyBlockRatio: string; + pools: SinglePoolStats[]; +} + +/** + * Pool component + */ +export interface PoolInfo { + id: number | null; // mysql row id + name: string; + link: string; + regexes: string; // JSON array + addresses: string; // JSON array + emptyBlocks: number; +} +export interface PoolStat { + pool: PoolInfo; + blockCount: number; + emptyBlocks: BlockExtended[]; } export interface BlockExtension { @@ -88,6 +106,10 @@ export interface BlockExtension { reward?: number; coinbaseTx?: Transaction; matchRate?: number; + pool?: { + id: number; + name: string; + } stage?: number; // Frontend only } diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index c19bf5a41..9a6bbc0b8 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, PoolsStats } from '../interfaces/node-api.interface'; +import { CpfpInfo, OptimizedMempoolStats, DifficultyAdjustment, AddressInformation, LiquidPegs, ITranslators, PoolsStats, PoolStat, BlockExtended, BlockExtension } from '../interfaces/node-api.interface'; import { Observable } from 'rxjs'; import { StateService } from './state.service'; import { WebsocketResponse } from '../interfaces/websocket.interface'; @@ -129,7 +129,31 @@ export class ApiService { return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/tx', hexPayload, { responseType: 'text' as 'json'}); } - listPools$(interval: string | null) : Observable { - return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pools/${interval}`); + listPools$(interval: string | undefined) : Observable { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pools` + + (interval !== undefined ? `/${interval}` : '') + ); + } + + getPoolStats$(poolId: number, interval: string | undefined): Observable { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}` + + (interval !== undefined ? `/${interval}` : '') + ); + } + + getPoolBlocks$(poolId: number, fromHeight: number): Observable { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}/blocks` + + (fromHeight !== undefined ? `/${fromHeight}` : '') + ); + } + + getHistoricalDifficulty$(interval: string | undefined): Observable { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/difficulty` + + (interval !== undefined ? `/${interval}` : '') + ); } } diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 230c9b150..14d67e765 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -36,6 +36,7 @@ export interface Env { MEMPOOL_WEBSITE_URL: string; LIQUID_WEBSITE_URL: string; BISQ_WEBSITE_URL: string; + MINING_DASHBOARD: boolean; } const defaultEnv: Env = { @@ -59,6 +60,7 @@ const defaultEnv: Env = { 'MEMPOOL_WEBSITE_URL': 'https://mempool.space', 'LIQUID_WEBSITE_URL': 'https://liquid.network', 'BISQ_WEBSITE_URL': 'https://bisq.markets', + 'MINING_DASHBOARD': true }; @Injectable({