diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index e6d74ddf0..97a428c23 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -41,7 +41,9 @@ class BitcoinApi implements AbstractBitcoinApi { $getBlockHeightTip(): Promise { return this.bitcoindClient.getChainTips() - .then((result: IBitcoinApi.ChainTips[]) => result[0].height); + .then((result: IBitcoinApi.ChainTips[]) => { + return result.find(tip => tip.status === 'active')!.height; + }); } $getTxIdsForBlock(hash: string): Promise { @@ -216,7 +218,7 @@ class BitcoinApi implements AbstractBitcoinApi { if (map[outputType]) { return map[outputType]; } else { - return ''; + return 'unknown'; } } diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index f0c04455b..40687060f 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -108,17 +108,14 @@ class Blocks { 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(); - transactionsTmp.sort((a, b) => b.effectiveFeePerVsize - a.effectiveFeePerVsize); - - blockExtended.extras.medianFee = transactionsTmp.length > 0 ? - Common.median(transactionsTmp.map((tx) => tx.effectiveFeePerVsize)) : 0; - blockExtended.extras.feeRange = transactionsTmp.length > 0 ? - Common.getFeesInRange(transactionsTmp, 8) : [0, 0]; - blockExtended.extras.totalFees = transactionsTmp.reduce((acc, tx) => { - return acc + tx.fee; - }, 0) + const stats = await bitcoinClient.getBlockStats(block.id); + const coinbaseRaw: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(transactions[0].txid, true); + blockExtended.extras.coinbaseRaw = coinbaseRaw.hex; + blockExtended.extras.medianFee = stats.feerate_percentiles[2]; // 50th percentiles + blockExtended.extras.feeRange = [stats.minfeerate, stats.feerate_percentiles, stats.maxfeerate].flat(); + blockExtended.extras.totalFees = stats.totalfee; + blockExtended.extras.avgFee = stats.avgfee; + blockExtended.extras.avgFeeRate = stats.avgfeerate; if (Common.indexingEnabled()) { let pool: PoolTag; @@ -184,7 +181,6 @@ class Blocks { } this.blockIndexingStarted = true; - const startedAt = new Date().getTime() / 1000; try { let currentBlockHeight = blockchainInfo.blocks; @@ -201,6 +197,9 @@ class Blocks { const chunkSize = 10000; let totaIndexed = await blocksRepository.$blockCount(null, null); let indexedThisRun = 0; + const startedAt = new Date().getTime() / 1000; + let timer = new Date().getTime() / 1000; + while (currentBlockHeight >= lastBlockToIndex) { const endBlock = Math.max(0, lastBlockToIndex, currentBlockHeight - chunkSize + 1); @@ -219,12 +218,16 @@ class Blocks { break; } ++indexedThisRun; - if (++totaIndexed % 100 === 0 || blockHeight === lastBlockToIndex) { - const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); + ++totaIndexed; + const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer)); + if (elapsedSeconds > 5 || blockHeight === lastBlockToIndex) { + const runningFor = 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`); + logger.debug(`Indexing block #${blockHeight} | ~${blockPerSeconds} blocks/sec | total: ${totaIndexed}/${indexingBlockAmount} (${progress}%) | elapsed: ${runningFor} seconds | left: ~${timeLeft} seconds`); + timer = new Date().getTime() / 1000; + indexedThisRun = 0; } const blockHash = await bitcoinApi.$getBlockHash(blockHeight); const block = await bitcoinApi.$getBlock(blockHash); @@ -249,7 +252,7 @@ class Blocks { const blockHeightTip = await bitcoinApi.$getBlockHeightTip(); if (this.blocks.length === 0) { - this.currentBlockHeight = blockHeightTip - config.MEMPOOL.INITIAL_BLOCKS_AMOUNT; + this.currentBlockHeight = Math.max(blockHeightTip - config.MEMPOOL.INITIAL_BLOCKS_AMOUNT, -1); } else { this.currentBlockHeight = this.blocks[this.blocks.length - 1].height; } @@ -268,17 +271,19 @@ class Blocks { this.lastDifficultyAdjustmentTime = block.timestamp; this.currentDifficulty = block.difficulty; - const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016); - const previousPeriodBlock = await bitcoinApi.$getBlock(previousPeriodBlockHash); - this.previousDifficultyRetarget = (block.difficulty - previousPeriodBlock.difficulty) / previousPeriodBlock.difficulty * 100; - logger.debug(`Initial difficulty adjustment data set.`); + if (blockHeightTip >= 2016) { + const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016); + const previousPeriodBlock = await bitcoinApi.$getBlock(previousPeriodBlockHash); + this.previousDifficultyRetarget = (block.difficulty - previousPeriodBlock.difficulty) / previousPeriodBlock.difficulty * 100; + logger.debug(`Initial difficulty adjustment data set.`); + } } else { logger.debug(`Blockchain headers (${blockchainInfo.headers}) and blocks (${blockchainInfo.blocks}) not in sync. Waiting...`); } } while (this.currentBlockHeight < blockHeightTip) { - if (this.currentBlockHeight === 0) { + if (this.currentBlockHeight < blockHeightTip - config.MEMPOOL.INITIAL_BLOCKS_AMOUNT) { this.currentBlockHeight = blockHeightTip; } else { this.currentBlockHeight++; diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 38ceb250b..39ed12bb0 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 = 9; + private static currentVersion = 11; private queryTimeout = 120000; private statisticsAddedIndexed = false; @@ -92,13 +92,13 @@ class DatabaseMigration { await this.$executeQuery(connection, this.getCreateBlocksTableQuery(), await this.$checkIfTableExists('blocks')); } if (databaseSchemaVersion < 5 && isBitcoin === true) { - logger.warn(`'blocks' table has been truncated. Re-indexing from scratch.'`); + logger.warn(`'blocks' table has been truncated. Re-indexing from scratch.`); 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) { - logger.warn(`'blocks' table has been truncated. Re-indexing from scratch.'`); + logger.warn(`'blocks' table has been truncated. Re-indexing from scratch.`); 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"'); @@ -125,7 +125,7 @@ class DatabaseMigration { } if (databaseSchemaVersion < 8 && isBitcoin === true) { - logger.warn(`'hashrates' table has been truncated. Re-indexing from scratch.'`); + logger.warn(`'hashrates' table has been truncated. Re-indexing from scratch.`); await this.$executeQuery(connection, 'TRUNCATE hashrates;'); // Need to re-index await this.$executeQuery(connection, 'ALTER TABLE `hashrates` DROP INDEX `PRIMARY`'); await this.$executeQuery(connection, 'ALTER TABLE `hashrates` ADD `id` int NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST'); @@ -134,12 +134,28 @@ class DatabaseMigration { } if (databaseSchemaVersion < 9 && isBitcoin === true) { - logger.warn(`'hashrates' table has been truncated. Re-indexing from scratch.'`); + logger.warn(`'hashrates' table has been truncated. Re-indexing from scratch.`); await this.$executeQuery(connection, 'TRUNCATE hashrates;'); // Need to re-index await this.$executeQuery(connection, 'ALTER TABLE `state` CHANGE `name` `name` varchar(100)'); await this.$executeQuery(connection, 'ALTER TABLE `hashrates` ADD UNIQUE `hashrate_timestamp_pool_id` (`hashrate_timestamp`, `pool_id`)'); } + if (databaseSchemaVersion < 10 && isBitcoin === true) { + await this.$executeQuery(connection, 'ALTER TABLE `blocks` ADD INDEX `blockTimestamp` (`blockTimestamp`)'); + } + + if (databaseSchemaVersion < 11 && isBitcoin === true) { + logger.warn(`'blocks' table has been truncated. Re-indexing from scratch.`); + await this.$executeQuery(connection, 'TRUNCATE blocks;'); // Need to re-index + await this.$executeQuery(connection, `ALTER TABLE blocks + ADD avg_fee INT UNSIGNED NULL, + ADD avg_fee_rate INT UNSIGNED NULL + `); + await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `reward` BIGINT UNSIGNED NOT NULL DEFAULT "0"'); + await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `median_fee` INT UNSIGNED NOT NULL DEFAULT "0"'); + await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `fees` INT UNSIGNED NOT NULL DEFAULT "0"'); + } + connection.release(); } catch (e) { connection.release(); diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 64505ba2b..43aea6059 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -8,6 +8,7 @@ import { IBitcoinApi } from './bitcoin/bitcoin-api.interface'; import loadingIndicators from './loading-indicators'; import bitcoinClient from './bitcoin/bitcoin-client'; import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; +import rbfCache from './rbf-cache'; class Mempool { private static WEBSOCKET_REFRESH_RATE_MS = 10000; @@ -200,6 +201,17 @@ class Mempool { logger.debug('Mempool updated in ' + time / 1000 + ' seconds'); } + public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) { + for (const rbfTransaction in rbfTransactions) { + if (this.mempoolCache[rbfTransaction]) { + // Store replaced transactions + rbfCache.add(rbfTransaction, rbfTransactions[rbfTransaction].txid); + // Erase the replaced transactions from the local mempool + delete this.mempoolCache[rbfTransaction]; + } + } + } + private updateTxPerSecond() { const nowMinusTimeSpan = new Date().getTime() - (1000 * config.STATISTICS.TX_PER_SECOND_SAMPLE_PERIOD); this.txPerSecondArray = this.txPerSecondArray.filter((unixTime) => unixTime > nowMinusTimeSpan); diff --git a/backend/src/api/mining.ts b/backend/src/api/mining.ts index 4423e5f16..afcc89220 100644 --- a/backend/src/api/mining.ts +++ b/backend/src/api/mining.ts @@ -1,5 +1,5 @@ import { PoolInfo, PoolStats } from '../mempool.interfaces'; -import BlocksRepository, { EmptyBlocks } from '../repositories/BlocksRepository'; +import BlocksRepository from '../repositories/BlocksRepository'; import PoolsRepository from '../repositories/PoolsRepository'; import HashratesRepository from '../repositories/HashratesRepository'; import bitcoinClient from './bitcoin/bitcoin-client'; @@ -20,25 +20,21 @@ class Mining { const poolsStatistics = {}; const poolsInfo: PoolInfo[] = await PoolsRepository.$getPoolsInfo(interval); - const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$getEmptyBlocks(null, interval); + const emptyBlocks: any[] = await BlocksRepository.$countEmptyBlocks(null, interval); const poolsStats: PoolStats[] = []; let rank = 1; poolsInfo.forEach((poolInfo: PoolInfo) => { + const emptyBlocksCount = emptyBlocks.filter((emptyCount) => emptyCount.poolId === poolInfo.poolId); const poolStat: PoolStats = { poolId: poolInfo.poolId, // mysql row id name: poolInfo.name, link: poolInfo.link, blockCount: poolInfo.blockCount, rank: rank++, - emptyBlocks: 0 + emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0 }; - for (let i = 0; i < emptyBlocks.length; ++i) { - if (emptyBlocks[i].poolId === poolInfo.poolId) { - poolStat.emptyBlocks++; - } - } poolsStats.push(poolStat); }); @@ -58,19 +54,19 @@ class Mining { /** * Get all mining pool stats for a pool */ - public async $getPoolStat(interval: string | null, poolId: number): Promise { + public async $getPoolStat(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); + const blockCount: number = await BlocksRepository.$blockCount(poolId); + const emptyBlocksCount = await BlocksRepository.$countEmptyBlocks(poolId); return { pool: pool, blockCount: blockCount, - emptyBlocks: emptyBlocks, + emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0, }; } @@ -97,8 +93,11 @@ class Mining { const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps(); const hashrates: any[] = []; const genesisTimestamp = 1231006505; // bitcoin-cli getblock 000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f - const lastMidnight = this.getDateMidnight(new Date()); - let toTimestamp = Math.round((lastMidnight.getTime() - 604800) / 1000); + + const now = new Date(); + const lastMonday = new Date(now.setDate(now.getDate() - (now.getDay() + 6) % 7)); + const lastMondayMidnight = this.getDateMidnight(lastMonday); + let toTimestamp = Math.round((lastMondayMidnight.getTime() - 604800) / 1000); const totalWeekIndexed = (await BlocksRepository.$blockCount(null, null)) / 1008; let indexedThisRun = 0; @@ -146,7 +145,7 @@ class Mining { hashrates.length = 0; const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); - if (elapsedSeconds > 5) { + if (elapsedSeconds > 1) { const weeksPerSeconds = (indexedThisRun / elapsedSeconds).toFixed(2); const formattedDate = new Date(fromTimestamp * 1000).toUTCString(); const weeksLeft = Math.round(totalWeekIndexed - totalIndexed); @@ -232,7 +231,7 @@ class Mining { } const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); - if (elapsedSeconds > 5) { + if (elapsedSeconds > 1) { const daysPerSeconds = (indexedThisRun / elapsedSeconds).toFixed(2); const formattedDate = new Date(fromTimestamp * 1000).toUTCString(); const daysLeft = Math.round(totalDayIndexed - totalIndexed); diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts new file mode 100644 index 000000000..3162ad263 --- /dev/null +++ b/backend/src/api/rbf-cache.ts @@ -0,0 +1,34 @@ +export interface CachedRbf { + txid: string; + expires: Date; +} + +class RbfCache { + private cache: { [txid: string]: CachedRbf; } = {}; + + constructor() { + setInterval(this.cleanup.bind(this), 1000 * 60 * 60); + } + + public add(replacedTxId: string, newTxId: string): void { + this.cache[replacedTxId] = { + expires: new Date(Date.now() + 1000 * 604800), // 1 week + txid: newTxId, + }; + } + + public get(txId: string): CachedRbf | undefined { + return this.cache[txId]; + } + + private cleanup(): void { + const currentDate = new Date(); + for (const c in this.cache) { + if (this.cache[c].expires < currentDate) { + delete this.cache[c]; + } + } + } +} + +export default new RbfCache(); diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index a2ae6d0c9..87d1fb59f 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -11,6 +11,7 @@ import { Common } from './common'; import loadingIndicators from './loading-indicators'; import config from '../config'; import transactionUtils from './transaction-utils'; +import rbfCache from './rbf-cache'; class WebsocketHandler { private wss: WebSocket.Server | undefined; @@ -48,29 +49,38 @@ class WebsocketHandler { if (parsedMessage && parsedMessage['track-tx']) { if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-tx'])) { client['track-tx'] = parsedMessage['track-tx']; - // Client is telling the transaction wasn't found but it might have appeared before we had the time to start watching for it + // Client is telling the transaction wasn't found if (parsedMessage['watch-mempool']) { - const tx = memPool.getMempool()[client['track-tx']]; - if (tx) { - if (config.MEMPOOL.BACKEND === 'esplora') { - response['tx'] = tx; + const rbfCacheTx = rbfCache.get(client['track-tx']); + if (rbfCacheTx) { + response['txReplaced'] = { + txid: rbfCacheTx.txid, + }; + client['track-tx'] = null; + } else { + // It might have appeared before we had the time to start watching for it + const tx = memPool.getMempool()[client['track-tx']]; + if (tx) { + if (config.MEMPOOL.BACKEND === 'esplora') { + response['tx'] = tx; + } else { + // tx.prevout is missing from transactions when in bitcoind mode + try { + const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true); + response['tx'] = fullTx; + } catch (e) { + logger.debug('Error finding transaction: ' + (e instanceof Error ? e.message : e)); + } + } } else { - // tx.prevouts is missing from transactions when in bitcoind mode try { - const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true); + const fullTx = await transactionUtils.$getTransactionExtended(client['track-tx'], true); response['tx'] = fullTx; } catch (e) { - logger.debug('Error finding transaction: ' + (e instanceof Error ? e.message : e)); + logger.debug('Error finding transaction. ' + (e instanceof Error ? e.message : e)); + client['track-mempool-tx'] = parsedMessage['track-tx']; } } - } else { - try { - const fullTx = await transactionUtils.$getTransactionExtended(client['track-tx'], true); - response['tx'] = fullTx; - } catch (e) { - logger.debug('Error finding transaction. ' + (e instanceof Error ? e.message : e)); - client['track-mempool-tx'] = parsedMessage['track-tx']; - } } } } else { @@ -221,14 +231,10 @@ class WebsocketHandler { mempoolBlocks.updateMempoolBlocks(newMempool); const mBlocks = mempoolBlocks.getMempoolBlocks(); - const mempool = memPool.getMempool(); const mempoolInfo = memPool.getMempoolInfo(); const vBytesPerSecond = memPool.getVBytesPerSecond(); const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions); - - for (const rbfTransaction in rbfTransactions) { - delete mempool[rbfTransaction]; - } + memPool.handleRbfTransactions(rbfTransactions); this.wss.clients.forEach(async (client: WebSocket) => { if (client.readyState !== WebSocket.OPEN) { @@ -332,27 +338,26 @@ class WebsocketHandler { } if (client['track-tx']) { - const utxoSpent = newTransactions.some((tx) => { - return tx.vin.some((vin) => vin.txid === client['track-tx']); - }); - if (utxoSpent) { - response['utxoSpent'] = true; + const outspends: object = {}; + newTransactions.forEach((tx) => tx.vin.forEach((vin, i) => { + if (vin.txid === client['track-tx']) { + outspends[vin.vout] = { + vin: i, + txid: tx.txid, + }; + } + })); + + if (Object.keys(outspends).length) { + response['utxoSpent'] = outspends; } if (rbfTransactions[client['track-tx']]) { for (const rbfTransaction in rbfTransactions) { if (client['track-tx'] === rbfTransaction) { - const rbfTx = rbfTransactions[rbfTransaction]; - if (config.MEMPOOL.BACKEND !== 'esplora') { - try { - const fullTx = await transactionUtils.$getTransactionExtended(rbfTransaction, true); - response['rbfTransaction'] = fullTx; - } catch (e) { - logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e)); - } - } else { - response['rbfTransaction'] = rbfTx; - } + response['rbfTransaction'] = { + txid: rbfTransactions[rbfTransaction].txid, + }; break; } } @@ -414,7 +419,6 @@ class WebsocketHandler { } if (client['track-tx'] && txIds.indexOf(client['track-tx']) > -1) { - client['track-tx'] = null; response['txConfirmed'] = true; } diff --git a/backend/src/index.ts b/backend/src/index.ts index 4ede865a6..c2de54521 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -299,6 +299,7 @@ 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/hashrate', routes.$getPoolHistoricalHashrate) .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) diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 810398cab..bda702ccb 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -79,7 +79,7 @@ export interface TransactionStripped { export interface BlockExtension { totalFees?: number; - medianFee?: number; + medianFee?: number; // Actually the median fee rate that we compute ourself feeRange?: number[]; reward?: number; coinbaseTx?: TransactionMinerInfo; @@ -87,7 +87,10 @@ export interface BlockExtension { pool?: { id: number; name: string; - } + }; + avgFee?: number; + avgFeeRate?: number; + coinbaseRaw?: string; } export interface BlockExtended extends IEsploraApi.Block { diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index 844f62bad..0cab3c0db 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -3,11 +3,6 @@ import { DB } from '../database'; import logger from '../logger'; import { Common } from '../api/common'; -export interface EmptyBlocks { - emptyBlocks: number; - poolId: number; -} - class BlocksRepository { /** * Save indexed block data in the database @@ -17,17 +12,17 @@ class BlocksRepository { try { const query = `INSERT INTO blocks( - height, hash, blockTimestamp, size, - weight, tx_count, coinbase_raw, difficulty, - pool_id, fees, fee_span, median_fee, - reward, version, bits, nonce, - merkle_root, previous_block_hash + height, hash, blockTimestamp, size, + weight, tx_count, coinbase_raw, difficulty, + pool_id, fees, fee_span, median_fee, + reward, version, bits, nonce, + merkle_root, previous_block_hash, avg_fee, avg_fee_rate ) VALUE ( ?, ?, FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ? + ?, ?, ?, ? )`; const params: any[] = [ @@ -37,21 +32,22 @@ class BlocksRepository { block.size, block.weight, block.tx_count, - '', + block.extras.coinbaseRaw, block.difficulty, block.extras.pool?.id, // Should always be set to something - 0, - '[]', - block.extras.medianFee ?? 0, - block.extras.reward ?? 0, + block.extras.totalFees, + JSON.stringify(block.extras.feeRange), + block.extras.medianFee, + block.extras.reward, block.version, block.bits, block.nonce, block.merkle_root, - block.previousblockhash + block.previousblockhash, + block.extras.avgFee, + block.extras.avgFeeRate, ]; - // logger.debug(query); await connection.query(query, params); connection.release(); } catch (e: any) { @@ -100,12 +96,13 @@ class BlocksRepository { /** * Get empty blocks for one or all pools */ - public async $getEmptyBlocks(poolId: number | null, interval: string | null = null): Promise { + public async $countEmptyBlocks(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 + let query = `SELECT count(height) as count, pools.id as poolId FROM blocks + JOIN pools on pools.id = blocks.pool_id WHERE tx_count = 1`; if (poolId) { @@ -117,13 +114,14 @@ class BlocksRepository { query += ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; } - // logger.debug(query); + query += ` GROUP by pools.id`; + const connection = await DB.pool.getConnection(); try { const [rows] = await connection.query(query, params); connection.release(); - return rows; + return rows; } catch (e) { connection.release(); logger.err('$getEmptyBlocks() error' + (e instanceof Error ? e.message : e)); @@ -134,7 +132,7 @@ class BlocksRepository { /** * Get blocks count for a period */ - public async $blockCount(poolId: number | null, interval: string | null): Promise { + public async $blockCount(poolId: number | null, interval: string | null = null): Promise { interval = Common.getSqlInterval(interval); const params: any[] = []; @@ -275,7 +273,7 @@ class BlocksRepository { /** * Get one block by height */ - public async $getBlockByHeight(height: number): Promise { + public async $getBlockByHeight(height: number): Promise { const connection = await DB.pool.getConnection(); try { const [rows]: any[] = await connection.query(` @@ -301,7 +299,7 @@ class BlocksRepository { /** * Return blocks difficulty */ - public async $getBlocksDifficulty(interval: string | null): Promise { + public async $getBlocksDifficulty(interval: string | null): Promise { interval = Common.getSqlInterval(interval); const connection = await DB.pool.getConnection(); diff --git a/backend/src/repositories/HashratesRepository.ts b/backend/src/repositories/HashratesRepository.ts index 3523004d5..749d3cb57 100644 --- a/backend/src/repositories/HashratesRepository.ts +++ b/backend/src/repositories/HashratesRepository.ts @@ -116,6 +116,52 @@ class HashratesRepository { } } + /** + * Returns a pool hashrate history + */ + public async $getPoolWeeklyHashrate(poolId: number): Promise { + const connection = await DB.pool.getConnection(); + + // Find hashrate boundaries + let query = `SELECT MIN(hashrate_timestamp) as firstTimestamp, MAX(hashrate_timestamp) as lastTimestamp + FROM hashrates + JOIN pools on pools.id = pool_id + WHERE hashrates.type = 'weekly' AND pool_id = ? AND avg_hashrate != 0 + ORDER by hashrate_timestamp LIMIT 1`; + + let boundaries = { + firstTimestamp: '1970-01-01', + lastTimestamp: '9999-01-01' + }; + try { + const [rows]: any[] = await connection.query(query, [poolId]); + boundaries = rows[0]; + connection.release(); + } catch (e) { + connection.release(); + logger.err('$getPoolWeeklyHashrate() error' + (e instanceof Error ? e.message : e)); + } + + // Get hashrates entries between boundaries + query = `SELECT UNIX_TIMESTAMP(hashrate_timestamp) as timestamp, avg_hashrate as avgHashrate, share, pools.name as poolName + FROM hashrates + JOIN pools on pools.id = pool_id + WHERE hashrates.type = 'weekly' AND hashrate_timestamp BETWEEN ? AND ? + AND pool_id = ? + ORDER by hashrate_timestamp`; + + try { + const [rows]: any[] = await connection.query(query, [boundaries.firstTimestamp, boundaries.lastTimestamp, poolId]); + connection.release(); + + return rows; + } catch (e) { + connection.release(); + logger.err('$getPoolWeeklyHashrate() error' + (e instanceof Error ? e.message : e)); + throw e; + } + } + public async $setLatestRunTimestamp(key: string, val: any = null) { const connection = await DB.pool.getConnection(); const query = `UPDATE state SET number = ? WHERE name = ?`; @@ -136,6 +182,9 @@ class HashratesRepository { const [rows] = await connection.query(query, [key]); connection.release(); + if (rows.length === 0) { + return 0; + } return rows[0]['number']; } catch (e) { connection.release(); diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 6b1a365b6..710cd8378 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -538,7 +538,7 @@ class Routes { public async $getPool(req: Request, res: Response) { try { - const stats = await mining.$getPoolStat(req.params.interval ?? null, parseInt(req.params.poolId, 10)); + const stats = await mining.$getPoolStat(parseInt(req.params.poolId, 10)); res.header('Pragma', 'public'); res.header('Cache-control', 'public'); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); @@ -603,6 +603,22 @@ class Routes { } } + public async $getPoolHistoricalHashrate(req: Request, res: Response) { + try { + const hashrates = await HashratesRepository.$getPoolWeeklyHashrate(parseInt(req.params.poolId, 10)); + const oldestIndexedBlockTimestamp = await BlocksRepository.$oldestBlockTimestamp(); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); + res.json({ + oldestIndexedBlockTimestamp: oldestIndexedBlockTimestamp, + hashrates: hashrates, + }); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + public async $getHistoricalHashrate(req: Request, res: Response) { try { const hashrates = await HashratesRepository.$getNetworkDailyHashrate(req.params.interval ?? null); @@ -665,7 +681,7 @@ class Routes { } let nextHash = startFromHash; - for (let i = 0; i < 10; i++) { + for (let i = 0; i < 10 && nextHash; i++) { const localBlock = blocks.getBlocks().find((b) => b.id === nextHash); if (localBlock) { returnBlocks.push(localBlock); diff --git a/contributors/bosch-0.txt b/contributors/bosch-0.txt new file mode 100644 index 000000000..366abbd7b --- /dev/null +++ b/contributors/bosch-0.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of January 25, 2022. + +Signed: Bosch-0 diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index dfb64c4bd..0dfc853cc 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -75,6 +75,7 @@ import { HashrateChartPoolsComponent } from './components/hashrates-chart-pools/ import { MiningStartComponent } from './components/mining-start/mining-start.component'; import { AmountShortenerPipe } from './shared/pipes/amount-shortener.pipe'; import { ShortenStringPipe } from './shared/pipes/shorten-string-pipe/shorten-string.pipe'; +import { DifficultyAdjustmentsTable } from './components/difficulty-adjustments-table/difficulty-adjustments-table.components'; @NgModule({ declarations: [ @@ -131,6 +132,7 @@ import { ShortenStringPipe } from './shared/pipes/shorten-string-pipe/shorten-st HashrateChartPoolsComponent, MiningStartComponent, AmountShortenerPipe, + DifficultyAdjustmentsTable, ], imports: [ BrowserModule.withServerTransition({ appId: 'serverApp' }), diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index 4e7b08373..512b6b411 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -217,12 +217,8 @@ export class BlockComponent implements OnInit, OnDestroy { this.blockSubsidy = 0; return; } - this.blockSubsidy = 50; - let halvenings = Math.floor(this.block.height / 210000); - while (halvenings > 0) { - this.blockSubsidy = this.blockSubsidy / 2; - halvenings--; - } + const halvings = Math.floor(this.block.height / 210000); + this.blockSubsidy = 50 * 2 ** -halvings; } pageChange(page: number, target: HTMLElement) { 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 7a0123a78..4352944c6 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -110,7 +110,7 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy { this.markBlockSubscription = this.stateService.markBlock$ .subscribe((state) => { this.markHeight = undefined; - if (state.blockHeight) { + if (state.blockHeight !== undefined) { this.markHeight = state.blockHeight; } this.moveArrowToPosition(false); @@ -127,7 +127,7 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy { } moveArrowToPosition(animate: boolean, newBlockFromLeft = false) { - if (!this.markHeight) { + if (this.markHeight === undefined) { this.arrowVisible = false; return; } diff --git a/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html b/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html new file mode 100644 index 000000000..a04c60f6a --- /dev/null +++ b/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html @@ -0,0 +1,33 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
HeightAdjustedDifficultyChange
{{ diffChange.height + }} + + {{ diffChange.difficultyShorten }} + {{ diffChange.change >= 0 ? '+' : '' }}{{ formatNumber(diffChange.change, locale, '1.2-2') }}% +
+
\ No newline at end of file diff --git a/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.scss b/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.scss new file mode 100644 index 000000000..c4a81f804 --- /dev/null +++ b/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.scss @@ -0,0 +1,40 @@ +.latest-transactions { + width: 100%; + text-align: left; + table-layout:fixed; + tr, td, th { + border: 0px; + } + td { + width: 25%; + } + .table-cell-satoshis { + display: none; + text-align: right; + @media (min-width: 576px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 1100px) { + display: table-cell; + } + } + .table-cell-fiat { + display: none; + text-align: right; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } + .table-cell-fees { + text-align: right; + } +} diff --git a/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.components.ts b/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.components.ts new file mode 100644 index 000000000..24c44fe05 --- /dev/null +++ b/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.components.ts @@ -0,0 +1,65 @@ +import { Component, Inject, LOCALE_ID, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ApiService } from 'src/app/services/api.service'; +import { formatNumber } from '@angular/common'; +import { selectPowerOfTen } from 'src/app/bitcoin.utils'; + +@Component({ + selector: 'app-difficulty-adjustments-table', + templateUrl: './difficulty-adjustments-table.component.html', + styleUrls: ['./difficulty-adjustments-table.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], +}) +export class DifficultyAdjustmentsTable implements OnInit { + hashrateObservable$: Observable; + isLoading = true; + formatNumber = formatNumber; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private apiService: ApiService, + ) { + } + + ngOnInit(): void { + this.hashrateObservable$ = this.apiService.getHistoricalHashrate$('1y') + .pipe( + map((data: any) => { + const availableTimespanDay = ( + (new Date().getTime() / 1000) - (data.oldestIndexedBlockTimestamp) + ) / 3600 / 24; + + const tableData = []; + for (let i = data.difficulty.length - 1; i > 0; --i) { + const selectedPowerOfTen: any = selectPowerOfTen(data.difficulty[i].difficulty); + const change = (data.difficulty[i].difficulty / data.difficulty[i - 1].difficulty - 1) * 100; + + tableData.push(Object.assign(data.difficulty[i], { + change: change, + difficultyShorten: formatNumber( + data.difficulty[i].difficulty / selectedPowerOfTen.divider, + this.locale, '1.2-2') + selectedPowerOfTen.unit + })); + } + this.isLoading = false; + + return { + availableTimespanDay: availableTimespanDay, + difficulty: tableData.slice(0, 5), + }; + }), + ); + } + + isMobile() { + return (window.innerWidth <= 767.98); + } +} diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html index eaa9fa809..2ca0e8376 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html @@ -1,6 +1,6 @@
-
+
-
-
- - - - - - - - - - - - - - - - - -
TimestampAdjustedDifficultyChange
‎{{ diffChange.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}{{ diffChange.difficultyShorten }}{{ formatNumber(diffChange.change, locale, '1.2-2') }}%
-
-
diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss index 4d9e0e5fa..0254d3175 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss @@ -48,8 +48,3 @@ } } } - -.compact td { - padding: 0 !important; - margin: 0.15rem !important; -} \ No newline at end of file diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts index a5f2b63b8..382071e37 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts @@ -1,7 +1,7 @@ -import { Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; import { EChartsOption, graphic } from 'echarts'; import { Observable } from 'rxjs'; -import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; +import { delay, map, retryWhen, 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'; @@ -15,11 +15,12 @@ import { selectPowerOfTen } from 'src/app/bitcoin.utils'; styles: [` .loadingGraphs { position: absolute; - top: 38%; + top: 50%; left: calc(50% - 15px); z-index: 100; } `], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class HashrateChartComponent implements OnInit { @Input() tableOnly = false; @@ -45,6 +46,7 @@ export class HashrateChartComponent implements OnInit { private seoService: SeoService, private apiService: ApiService, private formBuilder: FormBuilder, + private cd: ChangeDetectorRef, ) { this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); this.radioGroupForm.controls.dateSpan.setValue('1y'); @@ -92,9 +94,15 @@ export class HashrateChartComponent implements OnInit { this.prepareChartOptions({ hashrates: data.hashrates.map(val => [val.timestamp * 1000, val.avgHashrate]), - difficulty: diffFixed.map(val => [val.timestamp * 1000, val.difficulty]) + difficulty: diffFixed.map(val => [val.timestamp * 1000, val.difficulty]), + timestamp: data.oldestIndexedBlockTimestamp, }); this.isLoading = false; + + if (data.hashrates.length === 0) { + this.cd.markForCheck(); + throw new Error(); + } }), map((data: any) => { const availableTimespanDay = ( @@ -115,9 +123,12 @@ export class HashrateChartComponent implements OnInit { } return { availableTimespanDay: availableTimespanDay, - difficulty: this.tableOnly ? (this.isMobile() ? tableData.slice(0, 12) : tableData.slice(0, 9)) : tableData + difficulty: this.tableOnly ? tableData.slice(0, 5) : tableData, }; }), + retryWhen((errors) => errors.pipe( + delay(60000) + )) ); }), share() @@ -125,7 +136,25 @@ export class HashrateChartComponent implements OnInit { } prepareChartOptions(data) { + let title: object; + if (data.hashrates.length === 0) { + const lastBlock = new Date(data.timestamp * 1000); + const dd = String(lastBlock.getDate()).padStart(2, '0'); + const mm = String(lastBlock.getMonth() + 1).padStart(2, '0'); // January is 0! + const yyyy = lastBlock.getFullYear(); + title = { + textStyle: { + color: 'grey', + fontSize: 15 + }, + text: `Indexing in progess - ${yyyy}-${mm}-${dd}`, + left: 'center', + top: 'center' + }; + } + this.chartOptions = { + title: title, color: [ new graphic.LinearGradient(0, 0, 0, 0.65, [ { offset: 0, color: '#F4511E' }, @@ -168,18 +197,19 @@ export class HashrateChartComponent implements OnInit { difficulty = Math.round(data[1].data[1] / difficultyPowerOfTen.divider); } + const date = new Date(data[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); return ` - ${data[0].axisValueLabel}
+ ${date}
${data[0].marker} ${data[0].seriesName}: ${formatNumber(hashrate, this.locale, '1.0-0')} ${hashratePowerOfTen.unit}H/s
${data[1].marker} ${data[1].seriesName}: ${formatNumber(difficulty, this.locale, '1.2-2')} ${difficultyPowerOfTen.unit} `; }.bind(this) }, - xAxis: { + xAxis: data.hashrates.length === 0 ? undefined : { type: 'time', splitNumber: (this.isMobile() || this.widget) ? 5 : 10, }, - legend: { + legend: data.hashrates.length === 0 ? undefined : { data: [ { name: 'Hashrate', @@ -205,7 +235,7 @@ export class HashrateChartComponent implements OnInit { }, ], }, - yAxis: [ + yAxis: data.hashrates.length === 0 ? undefined : [ { min: function (value) { return value.min * 0.9; @@ -244,7 +274,7 @@ export class HashrateChartComponent implements OnInit { } } ], - series: [ + series: data.hashrates.length === 0 ? [] : [ { name: 'Hashrate', showSymbol: false, diff --git a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.html b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.html index 8750caa56..69da008d2 100644 --- a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.html +++ b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.html @@ -25,7 +25,7 @@
-
diff --git a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts index 7e3f081a6..3257465a1 100644 --- a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts +++ b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts @@ -1,7 +1,7 @@ -import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, 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 { delay, map, retryWhen, share, startWith, switchMap, tap } from 'rxjs/operators'; import { ApiService } from 'src/app/services/api.service'; import { SeoService } from 'src/app/services/seo.service'; import { FormBuilder, FormGroup } from '@angular/forms'; @@ -22,7 +22,7 @@ import { poolsColor } from 'src/app/app.constants'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class HashrateChartPoolsComponent implements OnInit { - @Input() widget: boolean = false; + @Input() widget = false; @Input() right: number | string = 40; @Input() left: number | string = 25; @@ -43,6 +43,7 @@ export class HashrateChartPoolsComponent implements OnInit { private seoService: SeoService, private apiService: ApiService, private formBuilder: FormBuilder, + private cd: ChangeDetectorRef, ) { this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); this.radioGroupForm.controls.dateSpan.setValue('1y'); @@ -105,9 +106,15 @@ export class HashrateChartPoolsComponent implements OnInit { this.prepareChartOptions({ legends: legends, - series: series + series: series, + timestamp: data.oldestIndexedBlockTimestamp, }); this.isLoading = false; + + if (series.length === 0) { + this.cd.markForCheck(); + throw new Error(); + } }), map((data: any) => { const availableTimespanDay = ( @@ -117,6 +124,9 @@ export class HashrateChartPoolsComponent implements OnInit { availableTimespanDay: availableTimespanDay, }; }), + retryWhen((errors) => errors.pipe( + delay(60000) + )) ); }), share() @@ -124,7 +134,25 @@ export class HashrateChartPoolsComponent implements OnInit { } prepareChartOptions(data) { + let title: object; + if (data.series.length === 0) { + const lastBlock = new Date(data.timestamp * 1000); + const dd = String(lastBlock.getDate()).padStart(2, '0'); + const mm = String(lastBlock.getMonth() + 1).padStart(2, '0'); // January is 0! + const yyyy = lastBlock.getFullYear(); + title = { + textStyle: { + color: 'grey', + fontSize: 15 + }, + text: `Indexing in progess - ${yyyy}-${mm}-${dd}`, + left: 'center', + top: 'center', + }; + } + this.chartOptions = { + title: title, grid: { right: this.right, left: this.left, @@ -146,7 +174,8 @@ export class HashrateChartPoolsComponent implements OnInit { }, borderColor: '#000', formatter: function (data) { - let tooltip = `${data[0].axisValueLabel}
`; + const date = new Date(data[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); + let tooltip = `${date}
`; data.sort((a, b) => b.data[1] - a.data[1]); for (const pool of data) { if (pool.data[1] > 0) { @@ -156,14 +185,14 @@ export class HashrateChartPoolsComponent implements OnInit { return tooltip; }.bind(this) }, - xAxis: { + xAxis: data.series.length === 0 ? undefined : { type: 'time', splitNumber: (this.isMobile() || this.widget) ? 5 : 10, }, - legend: (this.isMobile() || this.widget) ? undefined : { + legend: (this.isMobile() || this.widget || data.series.length === 0) ? undefined : { data: data.legends }, - yAxis: { + yAxis: data.series.length === 0 ? undefined : { position: 'right', axisLabel: { color: 'rgb(110, 112, 121)', diff --git a/frontend/src/app/components/latest-blocks/latest-blocks.component.ts b/frontend/src/app/components/latest-blocks/latest-blocks.component.ts index cef512388..5bdb55ece 100644 --- a/frontend/src/app/components/latest-blocks/latest-blocks.component.ts +++ b/frontend/src/app/components/latest-blocks/latest-blocks.component.ts @@ -109,8 +109,12 @@ export class LatestBlocksComponent implements OnInit, OnDestroy { if (this.isLoading) { return; } + const height = this.blocks[this.blocks.length - 1].height - 1; + if (height < 0) { + return; + } this.isLoading = true; - this.electrsApiService.listBlocks$(this.blocks[this.blocks.length - 1].height - 1) + this.electrsApiService.listBlocks$(height) .subscribe((blocks) => { this.blocks = this.blocks.concat(blocks); this.isLoading = false; diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html index 112b35df2..007916cfd 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html @@ -26,7 +26,7 @@ - +
diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts index 05735c0be..c03325cfe 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -33,6 +33,7 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy { networkSubscription: Subscription; network = ''; now = new Date().getTime(); + timeOffset = 0; showMiningInfo = false; blockWidth = 125; @@ -146,6 +147,15 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy { timeAvgMins += Math.abs(timeAvgDiff); } + // testnet difficulty is set to 1 after 20 minutes of no blockSize + // therefore the time between blocks will always be below 20 minutes (1200s) + if (this.stateService.network === 'testnet' && now - block.timestamp + timeAvgMins * 60 > 1200) { + this.timeOffset = -Math.min(now - block.timestamp, 1200) * 1000; + timeAvgMins = 20; + } else { + this.timeOffset = 0; + } + return timeAvgMins * 60 * 1000; }) ); diff --git a/frontend/src/app/components/mining-dashboard/mining-dashboard.component.html b/frontend/src/app/components/mining-dashboard/mining-dashboard.component.html index 6dbb541c3..68209fed0 100644 --- a/frontend/src/app/components/mining-dashboard/mining-dashboard.component.html +++ b/frontend/src/app/components/mining-dashboard/mining-dashboard.component.html @@ -2,11 +2,12 @@
+
Reward stats
-
+
Miners Reward
@@ -17,7 +18,7 @@
Reward Per Tx
- {{ rewardStats.rewardPerTx }} + {{ rewardStats.rewardPerTx | amountShortener }} sats/tx
in the last 8 blocks
@@ -25,7 +26,7 @@
Average Fee
- {{ rewardStats.feePerTx }} + {{ rewardStats.feePerTx | amountShortener}} sats/tx
in the last 8 blocks
@@ -34,6 +35,31 @@
+ +
+
+
Miners Reward
+
+
+
+
+
+
+
Reward Per Tx
+
+
+
+
+
+
+
Average Fee
+
+
+
+
+
+
+
@@ -86,9 +112,9 @@
- Adjusments + Adjustments
- +
@@ -96,4 +122,4 @@
-
\ No newline at end of file +
diff --git a/frontend/src/app/components/mining-dashboard/mining-dashboard.component.scss b/frontend/src/app/components/mining-dashboard/mining-dashboard.component.scss index 828ee7ed0..fb2663812 100644 --- a/frontend/src/app/components/mining-dashboard/mining-dashboard.component.scss +++ b/frontend/src/app/components/mining-dashboard/mining-dashboard.component.scss @@ -44,7 +44,7 @@ .fade-border { -webkit-mask-image: linear-gradient(to right, transparent 0%, black 10%, black 80%, transparent 100%) } - + .main-title { position: relative; color: #ffffff91; @@ -56,39 +56,22 @@ padding-bottom: 3px; } -.general-stats { - min-height: 56px; - display: block; - @media (min-width: 485px) { - display: flex; +.fee-estimation-container { + display: flex; + justify-content: space-between; + @media (min-width: 376px) { flex-direction: row; } - h5 { - margin-bottom: 10px; - } .item { - width: 50%; - margin: 0px auto 10px; - display: inline-block; - @media (min-width: 485px) { - margin: 0px auto 10px; + max-width: 150px; + margin: 0; + width: -webkit-fill-available; + @media (min-width: 376px) { + margin: 0 auto 0px; } - @media (min-width: 785px) { - margin: 0px auto 0px; - } - &:last-child { - margin: 0px auto 0px; - } - &:nth-child(2) { - order: 2; + &:first-child{ + display: none; @media (min-width: 485px) { - order: 3; - } - } - &:nth-child(3) { - order: 3; - @media (min-width: 485px) { - order: 2; display: block; } @media (min-width: 768px) { @@ -98,48 +81,37 @@ display: block; } } - .card-title { - font-size: 1rem; - color: #4a68b9; + &:last-child { + margin-bottom: 0; } - .card-text { - font-size: 18px; - span { - color: #ffffff66; - font-size: 12px; - } + .card-text span { + color: #ffffff66; + font-size: 12px; + top: 0px; + } + .fee-text{ + border-bottom: 1px solid #ffffff1c; + width: fit-content; + margin: auto; + line-height: 1.45; + padding: 0px 2px; + } + .fiat { + display: block; + font-size: 14px !important; } } } -.difficulty-adjustment-container { - display: flex; - flex-direction: row; - justify-content: space-around; - height: 76px; - .shared-block { - color: #ffffff66; - font-size: 12px; +.skeleton-loader { + width: 100%; + display: block; + &:first-child { + max-width: 90px; + margin: 15px auto 3px; } - .item { - padding: 0 5px; - width: 100%; - &:nth-child(1) { - display: none; - @media (min-width: 485px) { - display: table-cell; - } - @media (min-width: 768px) { - display: none; - } - @media (min-width: 992px) { - display: table-cell; - } - } - } - .card-text { - font-size: 22px; - margin-top: -9px; - position: relative; + &:last-child { + margin: 10px auto 3px; + max-width: 55px; } } diff --git a/frontend/src/app/components/mining-dashboard/mining-dashboard.component.ts b/frontend/src/app/components/mining-dashboard/mining-dashboard.component.ts index 3931cbc36..606bac5f1 100644 --- a/frontend/src/app/components/mining-dashboard/mining-dashboard.component.ts +++ b/frontend/src/app/components/mining-dashboard/mining-dashboard.component.ts @@ -2,8 +2,6 @@ import { ChangeDetectionStrategy, Component, Inject, LOCALE_ID, OnDestroy, OnIni import { map } from 'rxjs/operators'; import { SeoService } from 'src/app/services/seo.service'; import { StateService } from 'src/app/services/state.service'; -import { formatNumber } from '@angular/common'; -import { WebsocketService } from 'src/app/services/websocket.service'; import { Observable } from 'rxjs'; @Component({ @@ -22,7 +20,6 @@ export class MiningDashboardComponent implements OnInit { constructor(private seoService: SeoService, public stateService: StateService, - private websocketService: WebsocketService, @Inject(LOCALE_ID) private locale: string, ) { this.seoService.setTitle($localize`:@@mining.mining-dashboard:Mining Dashboard`); @@ -39,8 +36,8 @@ export class MiningDashboardComponent implements OnInit { return { 'totalReward': totalReward, - 'rewardPerTx': formatNumber(totalReward / totalTx, this.locale, '1.0-0'), - 'feePerTx': formatNumber(totalFee / totalTx, this.locale, '1.0-0'), + 'rewardPerTx': totalReward / totalTx, + 'feePerTx': totalFee / totalTx, } }) ); diff --git a/frontend/src/app/components/pool-ranking/pool-ranking.component.html b/frontend/src/app/components/pool-ranking/pool-ranking.component.html index 6bba39df4..9a1dc01b2 100644 --- a/frontend/src/app/components/pool-ranking/pool-ranking.component.html +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.html @@ -1,23 +1,25 @@
-
-
-
Pools luck (1w)
-

- {{ miningStats['minersLuck'] }}% -

-
-
-
Blocks (1w)
-

- {{ miningStats.blockCount }} -

-
-
-
Pools count (1w)
-

- {{ miningStats.pools.length }} -

+
+
+
+
Pools luck (1w)
+

+ {{ miningStats['minersLuck'] }}% +

+
+
+
Blocks (1w)
+

+ {{ miningStats.blockCount }} +

+
+
+
Pools count (1w)
+

+ {{ miningStats.pools.length }} +

+
@@ -96,3 +98,27 @@
+ + + +
+
+
Pools luck (1w)
+

+ +

+
+
+
Blocks (1w)
+

+ +

+
+
+
Pools count (1w)
+

+ +

+
+
+
\ No newline at end of file diff --git a/frontend/src/app/components/pool-ranking/pool-ranking.component.scss b/frontend/src/app/components/pool-ranking/pool-ranking.component.scss index f73486395..d25148df1 100644 --- a/frontend/src/app/components/pool-ranking/pool-ranking.component.scss +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.scss @@ -102,4 +102,11 @@ } } } -} \ No newline at end of file +} + +.skeleton-loader { + width: 100%; + display: block; + max-width: 80px; + margin: 15px auto 3px; +} diff --git a/frontend/src/app/components/pool-ranking/pool-ranking.component.ts b/frontend/src/app/components/pool-ranking/pool-ranking.component.ts index 64641c31d..58c383266 100644 --- a/frontend/src/app/components/pool-ranking/pool-ranking.component.ts +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, NgZone, OnInit } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { Router } from '@angular/router'; import { EChartsOption, PieSeriesOption } from 'echarts'; @@ -41,6 +41,7 @@ export class PoolRankingComponent implements OnInit { private miningService: MiningService, private seoService: SeoService, private router: Router, + private zone: NgZone, ) { } @@ -263,8 +264,8 @@ export class PoolRankingComponent implements OnInit { fontSize: 14, }, itemStyle: { - borderRadius: 2, - borderWidth: 2, + borderRadius: 1, + borderWidth: 1, borderColor: '#000', }, emphasis: { @@ -293,7 +294,9 @@ export class PoolRankingComponent implements OnInit { if (e.data.data === 9999) { // "Other" return; } - this.router.navigate(['/mining/pool/', e.data.data]); + this.zone.run(() => { + this.router.navigate(['/mining/pool/', e.data.data]); + }); }); } diff --git a/frontend/src/app/components/pool/pool.component.html b/frontend/src/app/components/pool/pool.component.html index 43bc647e8..af71351ad 100644 --- a/frontend/src/app/components/pool/pool.component.html +++ b/frontend/src/app/components/pool/pool.component.html @@ -1,49 +1,107 @@
-
-

- +
+

+ {{ poolStats.pool.name }}

-
-
-
-
- - - - - - - - - - -
-
+
+
+
+ + + + + + + + + + + + + + +
Tags +
+ {{ poolStats.pool.regexes }} +
+
Addresses + + ~
+
+
+ + + + + + + + + + + +
Mined Blocks{{ formatNumber(poolStats.blockCount, this.locale, '1.0-0') }}
Empty Blocks{{ formatNumber(poolStats.emptyBlocks, this.locale, '1.0-0') }}
+
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
HeightTimestampMinedReward + TransactionsSize
{{ block.height }}‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} + + + + {{ block.tx_count | number }} +
+
+
+
+
+ +
+ + +
+

+ +
+

@@ -52,16 +110,20 @@ Addresses - + - ~ + + ~ + Coinbase Tags - {{ poolStats.pool.regexes }} + +
+ @@ -71,11 +133,15 @@ Mined Blocks - {{ poolStats.blockCount }} + +
+ Empty Blocks - {{ poolStats.emptyBlocks.length }} + +
+ @@ -83,31 +149,4 @@
- - - - - - - - - - - - - - - - - - - - -
HeightTimestampMinedRewardTransactionsSize
{{ block.height }}‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}{{ block.tx_count | number }} -
-
-
-
-
- -
\ No newline at end of file + \ 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 index 271696a39..291177f73 100644 --- a/frontend/src/app/components/pool/pool.component.scss +++ b/frontend/src/app/components/pool/pool.component.scss @@ -18,9 +18,8 @@ display: flex; flex-direction: column; @media (min-width: 830px) { - margin-left: 2%; flex-direction: row; - float: left; + float: right; margin-top: 0px; } .btn-sm { @@ -37,5 +36,31 @@ div.scrollable { margin: 0; padding: 0; overflow: auto; - max-height: 100px; + max-height: 75px; +} + +.skeleton-loader { + width: 100%; + max-width: 90px; +} + +.table { + margin: 0px auto; + max-width: 900px; +} + +.box { + padding-bottom: 0px; +} + +.label { + max-width: 50px; + width: 30%; +} + +.data { + text-align: center; + @media (max-width: 767.98px) { + text-align: right; + } } \ 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 index 9d094dce0..da05f0403 100644 --- a/frontend/src/app/components/pool/pool.component.ts +++ b/frontend/src/app/components/pool/pool.component.ts @@ -1,51 +1,78 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; -import { distinctUntilChanged, map, startWith, switchMap, tap } from 'rxjs/operators'; +import { EChartsOption, graphic } from 'echarts'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { distinctUntilChanged, map, switchMap, tap, toArray } 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'; +import { selectPowerOfTen } from 'src/app/bitcoin.utils'; +import { formatNumber } from '@angular/common'; @Component({ selector: 'app-pool', templateUrl: './pool.component.html', styleUrls: ['./pool.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], changeDetection: ChangeDetectionStrategy.OnPush }) export class PoolComponent implements OnInit { + @Input() right: number | string = 45; + @Input() left: number | string = 75; + + formatNumber = formatNumber; poolStats$: Observable; blocks$: Observable; + isLoading = true; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + width: 'auto', + height: 'auto', + }; fromHeight: number = -1; fromHeightSubject: BehaviorSubject = new BehaviorSubject(this.fromHeight); blocks: BlockExtended[] = []; poolId: number = undefined; - radioGroupForm: FormGroup; constructor( + @Inject(LOCALE_ID) public locale: string, 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')), - ]) + this.poolStats$ = this.route.params.pipe(map((params) => params.poolId)) .pipe( - switchMap((params: any) => { - this.poolId = params[0]; + switchMap((poolId: any) => { + this.isLoading = true; + this.poolId = poolId; + return this.apiService.getPoolHashrate$(this.poolId) + .pipe( + switchMap((data) => { + this.isLoading = false; + this.prepareChartOptions(data.hashrates.map(val => [val.timestamp * 1000, val.avgHashrate])); + return poolId; + }), + ) + }), + switchMap(() => { if (this.blocks.length === 0) { this.fromHeightSubject.next(undefined); } - return this.apiService.getPoolStats$(this.poolId, params[1] ?? '1w'); + return this.apiService.getPoolStats$(this.poolId); }), map((poolStats) => { let regexes = '"'; @@ -74,6 +101,96 @@ export class PoolComponent implements OnInit { ) } + prepareChartOptions(data) { + this.chartOptions = { + animation: false, + color: [ + new graphic.LinearGradient(0, 0, 0, 0.65, [ + { offset: 0, color: '#F4511E' }, + { offset: 0.25, color: '#FB8C00' }, + { offset: 0.5, color: '#FFB300' }, + { offset: 0.75, color: '#FDD835' }, + { offset: 1, color: '#7CB342' } + ]), + '#D81B60', + ], + grid: { + right: this.right, + left: this.left, + bottom: 60, + }, + tooltip: { + show: !this.isMobile(), + trigger: 'axis', + axisPointer: { + type: 'line' + }, + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + align: 'left', + }, + borderColor: '#000', + formatter: function (data) { + let hashratePowerOfTen: any = selectPowerOfTen(1); + let hashrate = data[0].data[1]; + + if (this.isMobile()) { + hashratePowerOfTen = selectPowerOfTen(data[0].data[1]); + hashrate = Math.round(data[0].data[1] / hashratePowerOfTen.divider); + } + + return ` + ${data[0].axisValueLabel}
+ ${data[0].marker} ${data[0].seriesName}: ${formatNumber(hashrate, this.locale, '1.0-0')} ${hashratePowerOfTen.unit}H/s
+ `; + }.bind(this) + }, + xAxis: { + type: 'time', + splitNumber: (this.isMobile()) ? 5 : 10, + }, + yAxis: [ + { + min: function (value) { + return value.min * 0.9; + }, + type: 'value', + name: 'Hashrate', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + const selectedPowerOfTen: any = selectPowerOfTen(val); + const newVal = Math.round(val / selectedPowerOfTen.divider); + return `${newVal} ${selectedPowerOfTen.unit}H/s` + } + }, + splitLine: { + show: false, + } + }, + ], + series: [ + { + name: 'Hashrate', + showSymbol: false, + symbol: 'none', + data: data, + type: 'line', + lineStyle: { + width: 2, + }, + }, + ], + }; + } + + isMobile() { + return (window.innerWidth <= 767.98); + } + loadMore() { this.fromHeightSubject.next(this.blocks[this.blocks.length - 1]?.height); } diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 381c59d29..a0c92cbb4 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -3,13 +3,13 @@
- +

Transaction

diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 8b8526336..4788b5657 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -37,6 +37,8 @@ export class TransactionComponent implements OnInit, OnDestroy { transactionTime = -1; subscription: Subscription; fetchCpfpSubscription: Subscription; + txReplacedSubscription: Subscription; + blocksSubscription: Subscription; rbfTransaction: undefined | Transaction; cpfpInfo: CpfpInfo | null; showCpfpDetails = false; @@ -217,7 +219,7 @@ export class TransactionComponent implements OnInit, OnDestroy { } ); - this.stateService.blocks$.subscribe(([block, txConfirmed]) => { + this.blocksSubscription = this.stateService.blocks$.subscribe(([block, txConfirmed]) => { this.latestBlock = block; if (txConfirmed && this.tx) { @@ -232,9 +234,13 @@ export class TransactionComponent implements OnInit, OnDestroy { } }); - this.stateService.txReplaced$.subscribe( - (rbfTransaction) => (this.rbfTransaction = rbfTransaction) - ); + this.txReplacedSubscription = this.stateService.txReplaced$.subscribe((rbfTransaction) => { + if (!this.tx) { + this.error = new Error(); + this.waitingForTransaction = false; + } + this.rbfTransaction = rbfTransaction; + }); } handleLoadElectrsTransactionError(error: any): Observable { @@ -302,6 +308,8 @@ export class TransactionComponent implements OnInit, OnDestroy { ngOnDestroy() { this.subscription.unsubscribe(); this.fetchCpfpSubscription.unsubscribe(); + this.txReplacedSubscription.unsubscribe(); + this.blocksSubscription.unsubscribe(); this.leaveTransaction(); } } diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index 200356ffd..1470e6211 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -186,16 +186,16 @@ - - + + - + - + diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index fd3cf0241..7d3c5d0e4 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -1,11 +1,11 @@ -import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter } from '@angular/core'; +import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core'; import { StateService } from '../../services/state.service'; -import { Observable, forkJoin, ReplaySubject, BehaviorSubject, merge } from 'rxjs'; +import { Observable, forkJoin, ReplaySubject, BehaviorSubject, merge, of, Subject, Subscription } from 'rxjs'; import { Outspend, Transaction } from '../../interfaces/electrs.interface'; import { ElectrsApiService } from '../../services/electrs-api.service'; import { environment } from 'src/environments/environment'; import { AssetsService } from 'src/app/services/assets.service'; -import { map, share, switchMap } from 'rxjs/operators'; +import { map, share, switchMap, tap } from 'rxjs/operators'; import { BlockExtended } from 'src/app/interfaces/node-api.interface'; @Component({ @@ -27,41 +27,18 @@ export class TransactionsListComponent implements OnInit, OnChanges { @Output() loadMore = new EventEmitter(); latestBlock$: Observable; - outspends$: Observable; + outspendsSubscription: Subscription; refreshOutspends$: ReplaySubject = new ReplaySubject(); showDetails$ = new BehaviorSubject(false); - _outspends: Outspend[] = []; + outspends: Outspend[][] = []; assetsMinimal: any; constructor( public stateService: StateService, private electrsApiService: ElectrsApiService, private assetsService: AssetsService, - ) { - this.outspends$ = merge( - this.refreshOutspends$, - this.stateService.utxoSpent$ - .pipe( - map(() => { - this._outspends = []; - return { 0: this.electrsApiService.getOutspends$(this.transactions[0].txid) }; - }), - ) - ).pipe( - switchMap((observableObject) => forkJoin(observableObject)), - map((outspends: any) => { - const newOutspends = []; - for (const i in outspends) { - if (outspends.hasOwnProperty(i)) { - newOutspends.push(outspends[i]); - } - } - this._outspends = this._outspends.concat(newOutspends); - return this._outspends; - }), - share(), - ); - } + private ref: ChangeDetectorRef, + ) { } ngOnInit() { this.latestBlock$ = this.stateService.blocks$.pipe(map(([block]) => block)); @@ -72,6 +49,34 @@ export class TransactionsListComponent implements OnInit, OnChanges { this.assetsMinimal = assets; }); } + + this.outspendsSubscription = merge( + this.refreshOutspends$ + .pipe( + switchMap((observableObject) => forkJoin(observableObject)), + map((outspends: any) => { + const newOutspends: Outspend[] = []; + for (const i in outspends) { + if (outspends.hasOwnProperty(i)) { + newOutspends.push(outspends[i]); + } + } + this.outspends = this.outspends.concat(newOutspends); + }), + ), + this.stateService.utxoSpent$ + .pipe( + map((utxoSpent) => { + for (const i in utxoSpent) { + this.outspends[0][i] = { + spent: true, + txid: utxoSpent[i].txid, + vin: utxoSpent[i].vin, + }; + } + }), + ) + ).subscribe(() => this.ref.markForCheck()); } ngOnChanges() { @@ -90,7 +95,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { this.transactions.forEach((tx, i) => { tx['@voutLimit'] = true; tx['@vinLimit'] = true; - if (this._outspends[i]) { + if (this.outspends[i]) { return; } observableObject[i] = this.electrsApiService.getOutspends$(tx.txid); @@ -149,4 +154,8 @@ export class TransactionsListComponent implements OnInit, OnChanges { this.showDetails$.next(true); } } + + ngOnDestroy() { + this.outspendsSubscription.unsubscribe(); + } } diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts index 33fb5ea91..610f45765 100644 --- a/frontend/src/app/dashboard/dashboard.component.ts +++ b/frontend/src/app/dashboard/dashboard.component.ts @@ -144,6 +144,9 @@ export class DashboardComponent implements OnInit { this.latestBlockHeight = block.height; }), scan((acc, [block]) => { + if (acc.find((b) => b.height == block.height)) { + return acc; + } acc.unshift(block); acc = acc.slice(0, 6); return acc; @@ -153,6 +156,9 @@ export class DashboardComponent implements OnInit { this.transactions$ = this.stateService.transactions$ .pipe( scan((acc, tx) => { + if (acc.find((t) => t.txid == tx.txid)) { + return acc; + } acc.unshift(tx); acc = acc.slice(0, 6); return acc; diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index a05671257..faa78560a 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -90,7 +90,7 @@ export interface PoolInfo { export interface PoolStat { pool: PoolInfo; blockCount: number; - emptyBlocks: BlockExtended[]; + emptyBlocks: number; } export interface BlockExtension { diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index cb867ffb3..b0ae27f73 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -15,8 +15,9 @@ export interface WebsocketResponse { action?: string; data?: string[]; tx?: Transaction; - rbfTransaction?: Transaction; - utxoSpent?: boolean; + rbfTransaction?: ReplacedTransaction; + txReplaced?: ReplacedTransaction; + utxoSpent?: object; transactions?: TransactionStripped[]; loadingIndicators?: ILoadingIndicators; backendInfo?: IBackendInfo; @@ -27,6 +28,9 @@ export interface WebsocketResponse { 'track-bisq-market'?: string; } +export interface ReplacedTransaction extends Transaction { + txid: string; +} export interface MempoolBlock { blink?: boolean; height?: number; diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 5548780b1..858da3273 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -136,11 +136,12 @@ export class ApiService { ); } - getPoolStats$(poolId: number, interval: string | undefined): Observable { - return this.httpClient.get( - this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}` + - (interval !== undefined ? `/${interval}` : '') - ); + getPoolStats$(poolId: number): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}`); + } + + getPoolHashrate$(poolId: number): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}/hashrate`); } getPoolBlocks$(poolId: number, fromHeight: number): Observable { diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 238cf227c..5e181063b 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; import { Transaction } from '../interfaces/electrs.interface'; -import { IBackendInfo, MempoolBlock, MempoolInfo, TransactionStripped } from '../interfaces/websocket.interface'; +import { IBackendInfo, MempoolBlock, MempoolInfo, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; import { BlockExtended, OptimizedMempoolStats } from '../interfaces/node-api.interface'; import { Router, NavigationStart } from '@angular/router'; import { isPlatformBrowser } from '@angular/common'; @@ -71,7 +71,7 @@ export class StateService { network = ''; blockVSize: number; env: Env; - latestBlockHeight = 0; + latestBlockHeight = -1; networkChanged$ = new ReplaySubject(1); blocks$: ReplaySubject<[BlockExtended, boolean]>; @@ -80,8 +80,8 @@ export class StateService { bsqPrice$ = new ReplaySubject(1); mempoolInfo$ = new ReplaySubject(1); mempoolBlocks$ = new ReplaySubject(1); - txReplaced$ = new Subject(); - utxoSpent$ = new Subject(); + txReplaced$ = new Subject(); + utxoSpent$ = new Subject(); mempoolTransactions$ = new Subject(); blockTransactions$ = new Subject(); isLoadingWebSocket$ = new ReplaySubject(1); diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index 4d294ae2d..1eb9a56e6 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -68,7 +68,7 @@ export class WebsocketService { clearTimeout(this.onlineCheckTimeout); clearTimeout(this.onlineCheckTimeoutTwo); - this.stateService.latestBlockHeight = 0; + this.stateService.latestBlockHeight = -1; this.websocketSubject.complete(); this.subscription.unsubscribe(); @@ -239,6 +239,10 @@ export class WebsocketService { this.stateService.txReplaced$.next(response.rbfTransaction); } + if (response.txReplaced) { + this.stateService.txReplaced$.next(response.txReplaced); + } + if (response['mempool-blocks']) { this.stateService.mempoolBlocks$.next(response['mempool-blocks']); } @@ -252,7 +256,7 @@ export class WebsocketService { } if (response.utxoSpent) { - this.stateService.utxoSpent$.next(); + this.stateService.utxoSpent$.next(response.utxoSpent); } if (response.backendInfo) { diff --git a/frontend/src/resources/profile/zeus.png b/frontend/src/resources/profile/zeus.png index 0beb70f76..de7f54f55 100644 Binary files a/frontend/src/resources/profile/zeus.png and b/frontend/src/resources/profile/zeus.png differ diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 66b44518d..d47e1d545 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -50,6 +50,7 @@ $dropdown-link-active-bg: #11131f; html, body { height: 100%; + overflow-y: scroll; } body { diff --git a/production/nginx-cache-warmer b/production/nginx-cache-warmer index 2eab09dd1..38db73215 100755 --- a/production/nginx-cache-warmer +++ b/production/nginx-cache-warmer @@ -38,5 +38,13 @@ do for url in / \ curl -s "https://${hostname}${url}" >/dev/null done + counter=1 + while [ $counter -le 134 ] + do + curl -s "https://${hostname}/api/v1/mining/pool/${counter}/hashrate" >/dev/null + curl -s "https://${hostname}/api/v1/mining/pool/${counter}" >/dev/null + ((counter++)) + done + sleep 10 done