diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index e754b5d65..603a15a70 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -89,19 +89,19 @@ class BitcoinApi implements AbstractBitcoinApi { } $getAddressPrefix(prefix: string): string[] { - const found: string[] = []; + const found: { [address: string]: string } = {}; const mp = mempool.getMempool(); for (const tx in mp) { for (const vout of mp[tx].vout) { if (vout.scriptpubkey_address.indexOf(prefix) === 0) { - found.push(vout.scriptpubkey_address); - if (found.length >= 10) { - return found; + found[vout.scriptpubkey_address] = ''; + if (Object.keys(found).length >= 10) { + return Object.keys(found); } } } } - return found; + return Object.keys(found); } $sendRawTransaction(rawTransaction: string): Promise { diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index c25d24eb3..2da1ffea3 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -171,7 +171,7 @@ class Blocks { } /** - * Index all blocks metadata for the mining dashboard + * [INDEXING] Index all blocks metadata for the mining dashboard */ public async $generateBlockDatabase() { if (this.blockIndexingStarted) { @@ -218,31 +218,28 @@ class Blocks { if (blockHeight < lastBlockToIndex) { break; } - try { - ++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, true); - const blockExtended = await this.$getBlockExtended(block, transactions); - await blocksRepository.$saveBlockInDatabase(blockExtended); - } catch (e) { - logger.err(`Something went wrong while indexing blocks.` + e); + ++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, true); + const blockExtended = await this.$getBlockExtended(block, transactions); + await blocksRepository.$saveBlockInDatabase(blockExtended); } currentBlockHeight -= chunkSize; } logger.info('Block indexing completed'); } catch (e) { - logger.err('An error occured in $generateBlockDatabase(). Skipping block indexing. ' + e); - console.log(e); + logger.err('An error occured in $generateBlockDatabase(). Trying again later. ' + e); + this.blockIndexingStarted = false; + return; } this.blockIndexingCompleted = true; diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 49e9ef9c4..36cb68e07 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 = 8; + private static currentVersion = 9; private queryTimeout = 120000; private statisticsAddedIndexed = false; @@ -133,6 +133,17 @@ class DatabaseMigration { await this.$executeQuery(connection, 'ALTER TABLE `hashrates` ADD `type` enum("daily", "weekly") DEFAULT "daily"'); } + if (databaseSchemaVersion < 9 && isBitcoin === true) { + 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`)'); + } + connection.release(); } catch (e) { connection.release(); @@ -276,6 +287,10 @@ class DatabaseMigration { queries.push(`INSERT INTO state(name, number, string) VALUES ('last_hashrates_indexing', 0, NULL)`); } + if (version < 9) { + queries.push(`INSERT INTO state(name, number, string) VALUES ('last_weekly_hashrates_indexing', 0, NULL)`); + } + return queries; } 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 c0970bf24..4423e5f16 100644 --- a/backend/src/api/mining.ts +++ b/backend/src/api/mining.ts @@ -8,6 +8,7 @@ import blocks from './blocks'; class Mining { hashrateIndexingStarted = false; + weeklyHashrateIndexingStarted = false; constructor() { } @@ -74,140 +75,205 @@ class Mining { } /** - * Return the historical difficulty adjustments and oldest indexed block timestamp + * [INDEXING] Generate weekly mining pool hashrate history */ - public async $getHistoricalDifficulty(interval: string | null): Promise { - return await BlocksRepository.$getBlocksDifficulty(interval); - } + public async $generatePoolHashrateHistory(): Promise { + if (!blocks.blockIndexingCompleted || this.weeklyHashrateIndexingStarted) { + return; + } - /** - * Return the historical hashrates and oldest indexed block timestamp - */ - public async $getNetworkHistoricalHashrates(interval: string | null): Promise { - return await HashratesRepository.$getNetworkDailyHashrate(interval); - } - - /** - * Return the historical hashrates and oldest indexed block timestamp for one or all pools - */ - public async $getPoolsHistoricalHashrates(interval: string | null, poolId: number): Promise { - return await HashratesRepository.$getPoolsWeeklyHashrate(interval); - } - - /** - * Generate daily hashrate data - */ - public async $generateNetworkHashrateHistory(): Promise { - // We only run this once a day - const latestTimestamp = await HashratesRepository.$getLatestRunTimestamp(); + // We only run this once a week + const latestTimestamp = await HashratesRepository.$getLatestRunTimestamp('last_weekly_hashrates_indexing'); const now = new Date().getTime() / 1000; - if (now - latestTimestamp < 86400) { + if (now - latestTimestamp < 604800) { return; } - if (!blocks.blockIndexingCompleted || this.hashrateIndexingStarted) { - return; - } - this.hashrateIndexingStarted = true; + try { + this.weeklyHashrateIndexingStarted = true; - logger.info(`Indexing hashrates`); + logger.info(`Indexing mining pools weekly hashrates`); - const totalDayIndexed = (await BlocksRepository.$blockCount(null, null)) / 144; - const indexedTimestamp = (await HashratesRepository.$getNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp); - let startedAt = new Date().getTime() / 1000; - const genesisTimestamp = 1231006505; // bitcoin-cli getblock 000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f - const lastMidnight = new Date(); - lastMidnight.setUTCHours(0); lastMidnight.setUTCMinutes(0); lastMidnight.setUTCSeconds(0); lastMidnight.setUTCMilliseconds(0); - let toTimestamp = Math.round(lastMidnight.getTime() / 1000); - let indexedThisRun = 0; - let totalIndexed = 0; + 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 hashrates: any[] = []; + const totalWeekIndexed = (await BlocksRepository.$blockCount(null, null)) / 1008; + let indexedThisRun = 0; + let totalIndexed = 0; + let startedAt = new Date().getTime() / 1000; - while (toTimestamp > genesisTimestamp) { - const fromTimestamp = toTimestamp - 86400; - if (indexedTimestamp.includes(fromTimestamp)) { - toTimestamp -= 86400; - ++totalIndexed; - continue; - } + while (toTimestamp > genesisTimestamp) { + const fromTimestamp = toTimestamp - 604800; - const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp( - null, fromTimestamp, toTimestamp); - if (blockStats.blockCount === 0) { // We are done indexing, no blocks left - break; - } + // Skip already indexed weeks + if (indexedTimestamp.includes(toTimestamp + 1)) { + toTimestamp -= 604800; + ++totalIndexed; + continue; + } - let lastBlockHashrate = 0; - lastBlockHashrate = await bitcoinClient.getNetworkHashPs(blockStats.blockCount, - blockStats.lastBlockHeight); + const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp( + null, fromTimestamp, toTimestamp); + if (blockStats.blockCount === 0) { // We are done indexing, no blocks left + break; + } - if (totalIndexed % 7 === 0 && !indexedTimestamp.includes(fromTimestamp + 1)) { // Save weekly pools hashrate - logger.debug("Indexing weekly hashrates for mining pools"); - let pools = await PoolsRepository.$getPoolsInfoBetween(fromTimestamp - 604800, fromTimestamp); + const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(blockStats.blockCount, + blockStats.lastBlockHeight); + + let pools = await PoolsRepository.$getPoolsInfoBetween(fromTimestamp, toTimestamp); const totalBlocks = pools.reduce((acc, pool) => acc + pool.blockCount, 0); pools = pools.map((pool: any) => { pool.hashrate = (pool.blockCount / totalBlocks) * lastBlockHashrate; pool.share = (pool.blockCount / totalBlocks); return pool; }); - + for (const pool of pools) { hashrates.push({ - hashrateTimestamp: fromTimestamp + 1, + hashrateTimestamp: toTimestamp + 1, avgHashrate: pool['hashrate'], poolId: pool.poolId, share: pool['share'], type: 'weekly', }); } - } - hashrates.push({ - hashrateTimestamp: fromTimestamp, - avgHashrate: lastBlockHashrate, - poolId: null, - share: 1, - type: 'daily', - }); - - if (hashrates.length > 10) { await HashratesRepository.$saveHashrates(hashrates); hashrates.length = 0; - } - const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); - if (elapsedSeconds > 5) { - const daysPerSeconds = (indexedThisRun / elapsedSeconds).toFixed(2); - const formattedDate = new Date(fromTimestamp * 1000).toUTCString(); - const daysLeft = Math.round(totalDayIndexed - totalIndexed); - logger.debug(`Getting hashrate for ${formattedDate} | ~${daysPerSeconds} days/sec | ~${daysLeft} days left to index`); - startedAt = new Date().getTime() / 1000; - indexedThisRun = 0; - } + const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); + if (elapsedSeconds > 5) { + const weeksPerSeconds = (indexedThisRun / elapsedSeconds).toFixed(2); + const formattedDate = new Date(fromTimestamp * 1000).toUTCString(); + const weeksLeft = Math.round(totalWeekIndexed - totalIndexed); + logger.debug(`Getting weekly pool hashrate for ${formattedDate} | ~${weeksPerSeconds} weeks/sec | ~${weeksLeft} weeks left to index`); + startedAt = new Date().getTime() / 1000; + indexedThisRun = 0; + } - toTimestamp -= 86400; - ++indexedThisRun; - ++totalIndexed; + toTimestamp -= 604800; + ++indexedThisRun; + ++totalIndexed; + } + this.weeklyHashrateIndexingStarted = false; + await HashratesRepository.$setLatestRunTimestamp('last_weekly_hashrates_indexing'); + logger.info(`Weekly pools hashrate indexing completed`); + } catch (e) { + this.weeklyHashrateIndexingStarted = false; + throw e; + } + } + + /** + * [INDEXING] Generate daily hashrate data + */ + public async $generateNetworkHashrateHistory(): Promise { + if (!blocks.blockIndexingCompleted || this.hashrateIndexingStarted) { + return; } - // Add genesis block manually - if (toTimestamp <= genesisTimestamp && !indexedTimestamp.includes(genesisTimestamp)) { - hashrates.push({ - hashrateTimestamp: genesisTimestamp, - avgHashrate: await bitcoinClient.getNetworkHashPs(1, 1), - poolId: null, - type: 'daily', - }); + // We only run this once a day + const latestTimestamp = await HashratesRepository.$getLatestRunTimestamp('last_hashrates_indexing'); + const now = new Date().getTime() / 1000; + if (now - latestTimestamp < 86400) { + return; } - if (hashrates.length > 0) { + try { + this.hashrateIndexingStarted = true; + + logger.info(`Indexing network daily hashrate`); + + const indexedTimestamp = (await HashratesRepository.$getNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp); + const genesisTimestamp = 1231006505; // bitcoin-cli getblock 000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f + const lastMidnight = this.getDateMidnight(new Date()); + let toTimestamp = Math.round(lastMidnight.getTime() / 1000); + const hashrates: any[] = []; + + const totalDayIndexed = (await BlocksRepository.$blockCount(null, null)) / 144; + let indexedThisRun = 0; + let totalIndexed = 0; + let startedAt = new Date().getTime() / 1000; + + while (toTimestamp > genesisTimestamp) { + const fromTimestamp = toTimestamp - 86400; + + // Skip already indexed weeks + if (indexedTimestamp.includes(fromTimestamp)) { + toTimestamp -= 86400; + ++totalIndexed; + continue; + } + + const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp( + null, fromTimestamp, toTimestamp); + if (blockStats.blockCount === 0) { // We are done indexing, no blocks left + break; + } + + const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(blockStats.blockCount, + blockStats.lastBlockHeight); + + hashrates.push({ + hashrateTimestamp: toTimestamp, + avgHashrate: lastBlockHashrate, + poolId: null, + share: 1, + type: 'daily', + }); + + if (hashrates.length > 10) { + await HashratesRepository.$saveHashrates(hashrates); + hashrates.length = 0; + } + + const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); + if (elapsedSeconds > 5) { + const daysPerSeconds = (indexedThisRun / elapsedSeconds).toFixed(2); + const formattedDate = new Date(fromTimestamp * 1000).toUTCString(); + const daysLeft = Math.round(totalDayIndexed - totalIndexed); + logger.debug(`Getting network daily hashrate for ${formattedDate} | ~${daysPerSeconds} days/sec | ~${daysLeft} days left to index`); + startedAt = new Date().getTime() / 1000; + indexedThisRun = 0; + } + + toTimestamp -= 86400; + ++indexedThisRun; + ++totalIndexed; + } + + // Add genesis block manually + if (toTimestamp <= genesisTimestamp && !indexedTimestamp.includes(genesisTimestamp)) { + hashrates.push({ + hashrateTimestamp: genesisTimestamp, + avgHashrate: await bitcoinClient.getNetworkHashPs(1, 1), + poolId: null, + type: 'daily', + }); + } + await HashratesRepository.$saveHashrates(hashrates); - } - await HashratesRepository.$setLatestRunTimestamp(); - this.hashrateIndexingStarted = false; - logger.info(`Hashrates indexing completed`); + await HashratesRepository.$setLatestRunTimestamp('last_hashrates_indexing'); + this.hashrateIndexingStarted = false; + logger.info(`Daily network hashrate indexing completed`); + } catch (e) { + this.hashrateIndexingStarted = false; + throw e; + } + } + + private getDateMidnight(date: Date): Date { + date.setUTCHours(0); + date.setUTCMinutes(0); + date.setUTCSeconds(0); + date.setUTCMilliseconds(0); + + return date; } } 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 53d74925d..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) { @@ -331,21 +337,29 @@ class WebsocketHandler { } } - if (client['track-tx'] && 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; + if (client['track-tx']) { + 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) { + response['rbfTransaction'] = { + txid: rbfTransactions[rbfTransaction].txid, + }; + break; } - break; } } } @@ -405,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/database.ts b/backend/src/database.ts index b0d39b301..9f2655016 100644 --- a/backend/src/database.ts +++ b/backend/src/database.ts @@ -11,6 +11,7 @@ export class DB { password: config.DATABASE.PASSWORD, connectionLimit: 10, supportBigNumbers: true, + timezone: '+00:00', }); } diff --git a/backend/src/index.ts b/backend/src/index.ts index 4305d6dac..4ede865a6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -96,8 +96,8 @@ class Server { await Common.sleep(5000); await databaseMigration.$truncateIndexedData(tables); } - await this.$resetHashratesIndexingState(); await databaseMigration.$initializeOrMigrateDatabase(); + await this.$resetHashratesIndexingState(); await poolsParser.migratePoolsJson(); } catch (e) { throw new Error(e instanceof Error ? e.message : 'Error'); @@ -167,7 +167,8 @@ class Server { } async $resetHashratesIndexingState() { - return await HashratesRepository.$setLatestRunTimestamp(0); + await HashratesRepository.$setLatestRunTimestamp('last_hashrates_indexing', 0); + await HashratesRepository.$setLatestRunTimestamp('last_weekly_hashrates_indexing', 0); } async $runIndexingWhenReady() { @@ -176,8 +177,9 @@ class Server { } try { - await blocks.$generateBlockDatabase(); - await mining.$generateNetworkHashrateHistory(); + blocks.$generateBlockDatabase(); + mining.$generateNetworkHashrateHistory(); + mining.$generatePoolHashrateHistory(); } catch (e) { logger.err(`Unable to run indexing right now, trying again later. ` + e); } diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index d91777880..844f62bad 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -53,15 +53,17 @@ class BlocksRepository { // logger.debug(query); await connection.query(query, params); + connection.release(); } catch (e: any) { + connection.release(); if (e.errno === 1062) { // ER_DUP_ENTRY logger.debug(`$saveBlockInDatabase() - Block ${block.height} has already been indexed, ignoring`); } else { + connection.release(); logger.err('$saveBlockInDatabase() error' + (e instanceof Error ? e.message : e)); + throw e; } } - - connection.release(); } /** @@ -73,20 +75,26 @@ class BlocksRepository { } const connection = await DB.pool.getConnection(); - const [rows]: any[] = await connection.query(` - SELECT height - FROM blocks - WHERE height <= ? AND height >= ? - ORDER BY height DESC; - `, [startHeight, endHeight]); - connection.release(); + try { + const [rows]: any[] = await connection.query(` + SELECT height + FROM blocks + 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 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); - return missingBlocksHeights; + return missingBlocksHeights; + } catch (e) { + connection.release(); + logger.err('$getMissingBlocksBetweenHeights() error' + (e instanceof Error ? e.message : e)); + throw e; + } } /** @@ -111,10 +119,16 @@ class BlocksRepository { // logger.debug(query); const connection = await DB.pool.getConnection(); - const [rows] = await connection.query(query, params); - connection.release(); + 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)); + throw e; + } } /** @@ -143,10 +157,16 @@ class BlocksRepository { // logger.debug(query); const connection = await DB.pool.getConnection(); - const [rows] = await connection.query(query, params); - connection.release(); + try { + const [rows] = await connection.query(query, params); + connection.release(); - return rows[0].blockCount; + return rows[0].blockCount; + } catch (e) { + connection.release(); + logger.err('$blockCount() error' + (e instanceof Error ? e.message : e)); + throw e; + } } /** @@ -177,10 +197,16 @@ class BlocksRepository { // logger.debug(query); const connection = await DB.pool.getConnection(); - const [rows] = await connection.query(query, params); - connection.release(); + try { + const [rows] = await connection.query(query, params); + connection.release(); - return rows[0]; + return rows[0]; + } catch (e) { + connection.release(); + logger.err('$blockCountBetweenTimestamp() error' + (e instanceof Error ? e.message : e)); + throw e; + } } /** @@ -194,23 +220,26 @@ class BlocksRepository { // logger.debug(query); const connection = await DB.pool.getConnection(); - const [rows]: any[] = await connection.query(query); - connection.release(); + try { + const [rows]: any[] = await connection.query(query); + connection.release(); - if (rows.length <= 0) { - return -1; + if (rows.length <= 0) { + return -1; + } + + return rows[0].blockTimestamp; + } catch (e) { + connection.release(); + logger.err('$oldestBlockTimestamp() error' + (e instanceof Error ? e.message : e)); + throw e; } - - return rows[0].blockTimestamp; } /** * Get blocks mined by a specific mining pool */ - public async $getBlocksByPool( - poolId: number, - startHeight: number | null = null - ): Promise { + 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 @@ -227,14 +256,20 @@ class BlocksRepository { // logger.debug(query); const connection = await DB.pool.getConnection(); - const [rows] = await connection.query(query, params); - connection.release(); + try { + const [rows] = await connection.query(query, params); + connection.release(); - for (const block of rows) { - delete block['blockTimestamp']; + for (const block of rows) { + delete block['blockTimestamp']; + } + + return rows; + } catch (e) { + connection.release(); + logger.err('$getBlocksByPool() error' + (e instanceof Error ? e.message : e)); + throw e; } - - return rows; } /** @@ -242,19 +277,25 @@ class BlocksRepository { */ 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(); + try { + 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; + if (rows.length <= 0) { + return null; + } + + return rows[0]; + } catch (e) { + connection.release(); + logger.err('$getBlockByHeight() error' + (e instanceof Error ? e.message : e)); + throw e; } - - return rows[0]; } /** @@ -297,21 +338,34 @@ class BlocksRepository { ORDER BY t.height `; - const [rows]: any[] = await connection.query(query); - connection.release(); + try { + const [rows]: any[] = await connection.query(query); + connection.release(); - for (let row of rows) { - delete row['rn']; + for (let row of rows) { + delete row['rn']; + } + + return rows; + } catch (e) { + connection.release(); + logger.err('$getBlocksDifficulty() error' + (e instanceof Error ? e.message : e)); + throw e; } - - return rows; } public async $getOldestIndexedBlockHeight(): Promise { const connection = await DB.pool.getConnection(); - const [rows]: any[] = await connection.query(`SELECT MIN(height) as minHeight FROM blocks`); - connection.release(); - return rows[0].minHeight; + try { + const [rows]: any[] = await connection.query(`SELECT MIN(height) as minHeight FROM blocks`); + connection.release(); + + return rows[0].minHeight; + } catch (e) { + connection.release(); + logger.err('$getOldestIndexedBlockHeight() error' + (e instanceof Error ? e.message : e)); + throw e; + } } } diff --git a/backend/src/repositories/HashratesRepository.ts b/backend/src/repositories/HashratesRepository.ts index 212e58327..3523004d5 100644 --- a/backend/src/repositories/HashratesRepository.ts +++ b/backend/src/repositories/HashratesRepository.ts @@ -8,6 +8,10 @@ class HashratesRepository { * Save indexed block data in the database */ public async $saveHashrates(hashrates: any) { + if (hashrates.length === 0) { + return; + } + let query = `INSERT INTO hashrates(hashrate_timestamp, avg_hashrate, pool_id, share, type) VALUES`; @@ -20,11 +24,12 @@ class HashratesRepository { try { // logger.debug(query); await connection.query(query); + connection.release(); } catch (e: any) { + connection.release(); logger.err('$saveHashrateInDatabase() error' + (e instanceof Error ? e.message : e)); + throw e; } - - connection.release(); } public async $getNetworkDailyHashrate(interval: string | null): Promise { @@ -46,10 +51,33 @@ class HashratesRepository { query += ` ORDER by hashrate_timestamp`; - const [rows]: any[] = await connection.query(query); - connection.release(); + try { + const [rows]: any[] = await connection.query(query); + connection.release(); - return rows; + return rows; + } catch (e) { + connection.release(); + logger.err('$getNetworkDailyHashrate() error' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $getWeeklyHashrateTimestamps(): Promise { + const connection = await DB.pool.getConnection(); + + const query = `SELECT UNIX_TIMESTAMP(hashrate_timestamp) as timestamp FROM hashrates where type = 'weekly' GROUP BY hashrate_timestamp`; + + try { + const [rows]: any[] = await connection.query(query); + connection.release(); + + return rows.map(row => row.timestamp); + } catch (e) { + connection.release(); + logger.err('$getWeeklyHashrateTimestamps() error' + (e instanceof Error ? e.message : e)); + throw e; + } } /** @@ -76,26 +104,44 @@ class HashratesRepository { query += ` ORDER by hashrate_timestamp, FIELD(pool_id, ${topPoolsId})`; - const [rows]: any[] = await connection.query(query); - connection.release(); + try { + const [rows]: any[] = await connection.query(query); + connection.release(); - return rows; + return rows; + } catch (e) { + connection.release(); + logger.err('$getPoolsWeeklyHashrate() error' + (e instanceof Error ? e.message : e)); + throw e; + } } - public async $setLatestRunTimestamp(val: any = null) { + public async $setLatestRunTimestamp(key: string, val: any = null) { const connection = await DB.pool.getConnection(); - const query = `UPDATE state SET number = ? WHERE name = 'last_hashrates_indexing'`; + const query = `UPDATE state SET number = ? WHERE name = ?`; - await connection.query(query, (val === null) ? [Math.round(new Date().getTime() / 1000)] : [val]); - connection.release(); + try { + await connection.query(query, (val === null) ? [Math.round(new Date().getTime() / 1000), key] : [val, key]); + connection.release(); + } catch (e) { + connection.release(); + } } - public async $getLatestRunTimestamp(): Promise { + public async $getLatestRunTimestamp(key: string): Promise { const connection = await DB.pool.getConnection(); - const query = `SELECT number FROM state WHERE name = 'last_hashrates_indexing'`; - const [rows] = await connection.query(query); - connection.release(); - return rows[0]['number']; + const query = `SELECT number FROM state WHERE name = ?`; + + try { + const [rows] = await connection.query(query, [key]); + connection.release(); + + return rows[0]['number']; + } catch (e) { + connection.release(); + logger.err('$setLatestRunTimestamp() error' + (e instanceof Error ? e.message : e)); + throw e; + } } } diff --git a/backend/src/repositories/PoolsRepository.ts b/backend/src/repositories/PoolsRepository.ts index ea617322a..3f904888d 100644 --- a/backend/src/repositories/PoolsRepository.ts +++ b/backend/src/repositories/PoolsRepository.ts @@ -43,26 +43,38 @@ class PoolsRepository { // logger.debug(query); const connection = await DB.pool.getConnection(); - const [rows] = await connection.query(query); - connection.release(); + try { + const [rows] = await connection.query(query); + connection.release(); - return rows; + return rows; + } catch (e) { + connection.release(); + logger.err('$getPoolsInfo() error' + (e instanceof Error ? e.message : e)); + throw e; + } } /** * Get basic pool info and block count between two timestamp */ public async $getPoolsInfoBetween(from: number, to: number): Promise { - let query = `SELECT COUNT(height) as blockCount, pools.id as poolId, pools.name as poolName + const query = `SELECT COUNT(height) as blockCount, pools.id as poolId, pools.name as poolName FROM pools LEFT JOIN blocks on pools.id = blocks.pool_id AND blocks.blockTimestamp BETWEEN FROM_UNIXTIME(?) AND FROM_UNIXTIME(?) GROUP BY pools.id`; const connection = await DB.pool.getConnection(); - const [rows] = await connection.query(query, [from, to]); - connection.release(); + try { + const [rows] = await connection.query(query, [from, to]); + connection.release(); - return rows; + return rows; + } catch (e) { + connection.release(); + logger.err('$getPoolsInfoBetween() error' + (e instanceof Error ? e.message : e)); + throw e; + } } /** @@ -76,13 +88,19 @@ class PoolsRepository { // logger.debug(query); const connection = await DB.pool.getConnection(); - const [rows] = await connection.query(query, [poolId]); - connection.release(); + try { + 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); + rows[0].regexes = JSON.parse(rows[0].regexes); + rows[0].addresses = JSON.parse(rows[0].addresses); - return rows[0]; + return rows[0]; + } catch (e) { + connection.release(); + logger.err('$getPool() error' + (e instanceof Error ? e.message : e)); + throw e; + } } } diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 98af261ad..d36037d48 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -24,6 +24,7 @@ import miningStats from './api/mining'; import axios from 'axios'; import mining from './api/mining'; import BlocksRepository from './repositories/BlocksRepository'; +import HashratesRepository from './repositories/HashratesRepository'; class Routes { constructor() {} @@ -576,7 +577,7 @@ class Routes { public async $getHistoricalDifficulty(req: Request, res: Response) { try { - const stats = await mining.$getHistoricalDifficulty(req.params.interval ?? null); + const stats = await BlocksRepository.$getBlocksDifficulty(req.params.interval ?? null); res.header('Pragma', 'public'); res.header('Cache-control', 'public'); res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); @@ -588,7 +589,7 @@ class Routes { public async $getPoolsHistoricalHashrate(req: Request, res: Response) { try { - const hashrates = await mining.$getPoolsHistoricalHashrates(req.params.interval ?? null, parseInt(req.params.poolId, 10)); + const hashrates = await HashratesRepository.$getPoolsWeeklyHashrate(req.params.interval ?? null); const oldestIndexedBlockTimestamp = await BlocksRepository.$oldestBlockTimestamp(); res.header('Pragma', 'public'); res.header('Cache-control', 'public'); @@ -604,8 +605,8 @@ class Routes { public async $getHistoricalHashrate(req: Request, res: Response) { try { - const hashrates = await mining.$getNetworkHistoricalHashrates(req.params.interval ?? null); - const difficulty = await mining.$getHistoricalDifficulty(req.params.interval ?? null); + const hashrates = await HashratesRepository.$getNetworkDailyHashrate(req.params.interval ?? null); + const difficulty = await BlocksRepository.$getBlocksDifficulty(req.params.interval ?? null); const oldestIndexedBlockTimestamp = await BlocksRepository.$oldestBlockTimestamp(); res.header('Pragma', 'public'); res.header('Cache-control', 'public'); 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 956990f94..dfb64c4bd 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -74,6 +74,7 @@ import { HashrateChartComponent } from './components/hashrate-chart/hashrate-cha import { HashrateChartPoolsComponent } from './components/hashrates-chart-pools/hashrate-chart-pools.component'; 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'; @NgModule({ declarations: [ @@ -154,6 +155,7 @@ import { AmountShortenerPipe } from './shared/pipes/amount-shortener.pipe'; SeoService, StorageService, LanguageService, + ShortenStringPipe, { provide: HTTP_INTERCEPTORS, useClass: HttpCacheInterceptor, multi: true } ], bootstrap: [AppComponent] diff --git a/frontend/src/app/components/difficulty/difficulty.component.html b/frontend/src/app/components/difficulty/difficulty.component.html index fc3030286..3684b8de4 100644 --- a/frontend/src/app/components/difficulty/difficulty.component.html +++ b/frontend/src/app/components/difficulty/difficulty.component.html @@ -1,4 +1,4 @@ -
Difficulty Adjustment
+
Difficulty Adjustment
@@ -47,7 +47,7 @@
-
Next halving
+
Next Halving
{{ i }} blocks diff --git a/frontend/src/app/components/difficulty/difficulty.component.ts b/frontend/src/app/components/difficulty/difficulty.component.ts index ff44e5aeb..b22001ef1 100644 --- a/frontend/src/app/components/difficulty/difficulty.component.ts +++ b/frontend/src/app/components/difficulty/difficulty.component.ts @@ -28,8 +28,9 @@ export class DifficultyComponent implements OnInit { isLoadingWebSocket$: Observable; difficultyEpoch$: Observable; - @Input() showProgress: boolean = true; - @Input() showHalving: boolean = false; + @Input() showProgress = true; + @Input() showHalving = false; + @Input() showTitle = true; constructor( public stateService: StateService, @@ -97,7 +98,7 @@ export class DifficultyComponent implements OnInit { colorPreviousAdjustments = '#ffffff66'; } - const blocksUntilHalving = block.height % 210000; + const blocksUntilHalving = 210000 - (block.height % 210000); const timeUntilHalving = (blocksUntilHalving * timeAvgMins * 60 * 1000) + (now * 1000); return { 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 ea5c5a2a7..79d7a9f1b 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 @@
-
+
-
- + + + + + + + + + + + + + + + + + +
HeightAdjustedDifficultyChange
{{ diffChange.height }}{{ 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 316f0fc47..c68e2f406 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss @@ -29,7 +29,7 @@ .chart-widget { width: 100%; height: 100%; - max-height: 275px; + max-height: 293px; } .formRadioGroup { @@ -48,3 +48,44 @@ } } } + +.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/hashrate-chart/hashrate-chart.component.ts b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts index 7efb83098..948ffed41 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts @@ -15,14 +15,15 @@ import { selectPowerOfTen } from 'src/app/bitcoin.utils'; styles: [` .loadingGraphs { position: absolute; - top: 38%; + top: 50%; left: calc(50% - 15px); z-index: 100; } `], }) export class HashrateChartComponent implements OnInit { - @Input() widget: boolean = false; + @Input() tableOnly = false; + @Input() widget = false; @Input() right: number | string = 45; @Input() left: number | string = 75; @@ -114,7 +115,7 @@ export class HashrateChartComponent implements OnInit { } return { availableTimespanDay: availableTimespanDay, - difficulty: tableData + difficulty: this.tableOnly ? tableData.slice(0, 5) : tableData }; }), ); @@ -141,6 +142,7 @@ export class HashrateChartComponent implements OnInit { bottom: this.widget ? 30 : 60, }, tooltip: { + show: !this.isMobile() || !this.widget, trigger: 'axis', axisPointer: { type: 'line' 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 93cec63ca..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,9 +25,9 @@
-
-
+
diff --git a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.scss b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.scss index 06a3eeb25..4f15e51d6 100644 --- a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.scss +++ b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.scss @@ -29,7 +29,7 @@ .chart-widget { width: 100%; height: 100%; - max-height: 275px; + max-height: 293px; } .formRadioGroup { 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 3d7935e3d..7e3f081a6 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 @@ -78,7 +78,7 @@ export class HashrateChartPoolsComponent implements OnInit { name: name, showSymbol: false, symbol: 'none', - data: grouped[name].map((val) => [val.timestamp * 1000, (val.share * 100).toFixed(2)]), + data: grouped[name].map((val) => [val.timestamp * 1000, val.share * 100]), type: 'line', lineStyle: { width: 0 }, areaStyle: { opacity: 1 }, @@ -132,6 +132,7 @@ export class HashrateChartPoolsComponent implements OnInit { top: this.widget ? 10 : 40, }, tooltip: { + show: !this.isMobile() || !this.widget, trigger: 'axis', axisPointer: { type: 'line' @@ -149,7 +150,7 @@ export class HashrateChartPoolsComponent implements OnInit { data.sort((a, b) => b.data[1] - a.data[1]); for (const pool of data) { if (pool.data[1] > 0) { - tooltip += `${pool.marker} ${pool.seriesName}: ${pool.data[1]}%
` + tooltip += `${pool.marker} ${pool.seriesName}: ${pool.data[1].toFixed(2)}%
`; } } return tooltip; 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 4d811fc9a..dff5436f0 100644 --- a/frontend/src/app/components/mining-dashboard/mining-dashboard.component.html +++ b/frontend/src/app/components/mining-dashboard/mining-dashboard.component.html @@ -2,36 +2,124 @@
+
-
-
- -
- - Mining Pools Share (1w) - -
- +
Reward stats
+
+
+
+
+
Miners Reward
+
+ +
in the last 8 blocks
+
+
+
+
Reward Per Tx
+
+ {{ rewardStats.rewardPerTx | amountShortener }} + sats/tx +
in the last 8 blocks
+
+
+
+
Average Fee
+
+ {{ rewardStats.feePerTx | amountShortener}} + sats/tx +
in the last 8 blocks
+
+
+
+
+
+
+ +
+
+
Miners Reward
+
+
+
+
+
+
+
Reward Per Tx
+
+
+
+
+
+
+
Average Fee
+
+
+
+
+
+
+
- + +
+
Difficulty Adjustment
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+
+
+ Hashrate (1y) +
+ + +
+
+
+ + +
+
+
+
+ Mining Pools Dominance (1y) +
+
-
+
-
- - Hashrate (1y) - + Adjustments
- + +
-
+
-
\ 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 4c75e9ea6..fb2663812 100644 --- a/frontend/src/app/components/mining-dashboard/mining-dashboard.component.scss +++ b/frontend/src/app/components/mining-dashboard/mining-dashboard.component.scss @@ -12,14 +12,11 @@ .card { background-color: #1d1f31; - height: 340px; -} -.card.double { - height: 620px; } .card-title { font-size: 1rem; + color: #4a68b9; } .card-title > a { color: #4a68b9; @@ -47,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; @@ -58,3 +55,63 @@ text-align: center; padding-bottom: 3px; } + +.fee-estimation-container { + display: flex; + justify-content: space-between; + @media (min-width: 376px) { + flex-direction: row; + } + .item { + max-width: 150px; + margin: 0; + width: -webkit-fill-available; + @media (min-width: 376px) { + margin: 0 auto 0px; + } + &:first-child{ + display: none; + @media (min-width: 485px) { + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + &:last-child { + margin-bottom: 0; + } + .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; + } + } +} + +.skeleton-loader { + width: 100%; + display: block; + &:first-child { + max-width: 90px; + margin: 15px auto 3px; + } + &: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 aac546ca1..606bac5f1 100644 --- a/frontend/src/app/components/mining-dashboard/mining-dashboard.component.ts +++ b/frontend/src/app/components/mining-dashboard/mining-dashboard.component.ts @@ -1,5 +1,8 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Inject, LOCALE_ID, OnDestroy, OnInit } from '@angular/core'; +import { map } from 'rxjs/operators'; import { SeoService } from 'src/app/services/seo.service'; +import { StateService } from 'src/app/services/state.service'; +import { Observable } from 'rxjs'; @Component({ selector: 'app-mining-dashboard', @@ -8,12 +11,35 @@ import { SeoService } from 'src/app/services/seo.service'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class MiningDashboardComponent implements OnInit { + private blocks = []; - constructor(private seoService: SeoService) { + public $rewardStats: Observable; + public totalReward = 0; + public rewardPerTx = '~'; + public feePerTx = '~'; + + constructor(private seoService: SeoService, + public stateService: StateService, + @Inject(LOCALE_ID) private locale: string, + ) { this.seoService.setTitle($localize`:@@mining.mining-dashboard:Mining Dashboard`); } ngOnInit(): void { - } + this.$rewardStats = this.stateService.blocks$.pipe( + map(([block]) => { + this.blocks.unshift(block); + this.blocks = this.blocks.slice(0, 8); + const totalTx = this.blocks.reduce((acc, block) => acc + block.tx_count, 0); + const totalFee = this.blocks.reduce((acc, block) => acc + block.extras?.totalFees ?? 0, 0); + const totalReward = this.blocks.reduce((acc, block) => acc + block.extras?.reward ?? 0, 0); + return { + 'totalReward': totalReward, + '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 067c61646..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,8 +1,31 @@
+
+
+
+
Pools luck (1w)
+

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

+
+
+
Blocks (1w)
+

+ {{ miningStats.blockCount }} +

+
+
+
Pools count (1w)
+

+ {{ miningStats.pools.length }} +

+
+
+
+
-
+
@@ -59,7 +82,7 @@ {{ pool.rank }} {{ pool.name }} - {{ pool.lastEstimatedHashrate }} {{ miningStats.miningUnits.hashrateUnit }} + {{ pool.lastEstimatedHashrate }} {{ miningStats.miningUnits.hashrateUnit }} {{ pool['blockText'] }} {{ pool.emptyBlocks }} ({{ pool.emptyBlockRatio }}%) @@ -75,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 a41891a8f..d25148df1 100644 --- a/frontend/src/app/components/pool-ranking/pool-ranking.component.scss +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.scss @@ -1,13 +1,16 @@ .chart { max-height: 400px; @media (max-width: 767.98px) { - max-height: 300px; + max-height: 270px; } } .chart-widget { width: 100%; height: 100%; - max-height: 275px; + max-height: 270px; + @media (max-width: 767.98px) { + max-height: 200px; + } } .formRadioGroup { @@ -44,3 +47,66 @@ .loadingGraphs.widget { top: 25%; } + +.pool-distribution { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + 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; + } + @media (min-width: 785px) { + margin: 0px auto 0px; + } + &:last-child { + margin: 0px auto 0px; + } + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-title { + font-size: 1rem; + color: #4a68b9; + } + .card-text { + font-size: 18px; + span { + color: #ffffff66; + font-size: 12px; + } + } + } +} + +.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 cef04cca5..01b36ab74 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, ) { } @@ -49,7 +50,7 @@ export class PoolRankingComponent implements OnInit { this.poolsWindowPreference = '1w'; } else { this.seoService.setTitle($localize`:@@mining.mining-pools:Mining Pools`); - this.poolsWindowPreference = this.storageService.getValue('poolsWindowPreference') ? this.storageService.getValue('poolsWindowPreference') : '1w'; + this.poolsWindowPreference = this.storageService.getValue('poolsWindowPreference') ? this.storageService.getValue('poolsWindowPreference') : '1w'; } this.radioGroupForm = this.formBuilder.group({ dateSpan: this.poolsWindowPreference }); this.radioGroupForm.controls.dateSpan.setValue(this.poolsWindowPreference); @@ -85,6 +86,7 @@ export class PoolRankingComponent implements OnInit { }), map(data => { data.pools = data.pools.map((pool: SinglePoolStats) => this.formatPoolUI(pool)); + data['minersLuck'] = (100 * (data.blockCount / 1008)).toFixed(2); // luck 1w return data; }), tap(data => { @@ -105,24 +107,40 @@ export class PoolRankingComponent implements OnInit { } generatePoolsChartSerieData(miningStats) { - const poolShareThreshold = this.isMobile() ? 1 : 0.5; // Do not draw pools which hashrate share is lower than that + const poolShareThreshold = this.isMobile() ? 2 : 1; // Do not draw pools which hashrate share is lower than that const data: object[] = []; + let totalShareOther = 0; + let totalBlockOther = 0; + let totalEstimatedHashrateOther = 0; + + let edgeDistance: any = '20%'; + if (this.isMobile() && this.widget) { + edgeDistance = 0; + } else if (this.isMobile() && !this.widget || this.widget) { + edgeDistance = 35; + } miningStats.pools.forEach((pool) => { if (parseFloat(pool.share) < poolShareThreshold) { + totalShareOther += parseFloat(pool.share); + totalBlockOther += pool.blockCount; + totalEstimatedHashrateOther += pool.lastEstimatedHashrate; return; } data.push({ itemStyle: { - color: poolsColor[pool.name.replace(/[^a-zA-Z0-9]/g, "").toLowerCase()], + color: poolsColor[pool.name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase()], }, value: pool.share, - name: pool.name + (this.isMobile() ? `` : ` (${pool.share}%)`), + name: pool.name + ((this.isMobile() || this.widget) ? `` : ` (${pool.share}%)`), label: { + overflow: 'none', color: '#b1b1b1', - overflow: 'break', + alignTo: 'edge', + edgeDistance: edgeDistance, }, tooltip: { + show: !this.isMobile() || !this.widget, backgroundColor: 'rgba(17, 19, 31, 1)', borderRadius: 4, shadowColor: 'rgba(0, 0, 0, 0.5)', @@ -144,6 +162,42 @@ export class PoolRankingComponent implements OnInit { data: pool.poolId, } as PieSeriesOption); }); + + // 'Other' + data.push({ + itemStyle: { + color: 'grey', + }, + value: totalShareOther, + name: 'Other' + (this.isMobile() ? `` : ` (${totalShareOther.toFixed(2)}%)`), + label: { + overflow: 'none', + color: '#b1b1b1', + alignTo: 'edge', + edgeDistance: edgeDistance + }, + tooltip: { + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + }, + borderColor: '#000', + formatter: () => { + if (this.poolsWindowPreference === '24h') { + return `${'Other'} (${totalShareOther.toFixed(2)}%)
` + + totalEstimatedHashrateOther.toString() + ' PH/s' + + `
` + totalBlockOther.toString() + ` blocks`; + } else { + return `${'Other'} (${totalShareOther.toFixed(2)}%)
` + + totalBlockOther.toString() + ` blocks`; + } + } + }, + data: 9999 as any, + } as PieSeriesOption); + return data; } @@ -154,9 +208,22 @@ export class PoolRankingComponent implements OnInit { } network = network.charAt(0).toUpperCase() + network.slice(1); - let radius: any[] = ['20%', '70%']; - if (this.isMobile() || this.widget) { - radius = ['20%', '60%']; + let radius: any[] = ['20%', '80%']; + let top: any = undefined; let bottom = undefined; let height = undefined; + if (this.isMobile() && this.widget) { + top = -30; + height = 270; + radius = ['10%', '50%']; + } else if (this.isMobile() && !this.widget) { + top = 0; + height = 300; + radius = ['10%', '50%']; + } else if (this.widget) { + radius = ['15%', '60%']; + top = -20; + height = 330; + } else { + top = 35; } this.chartOptions = { @@ -180,14 +247,15 @@ export class PoolRankingComponent implements OnInit { }, series: [ { - top: this.widget ? 0 : 35, + minShowLabelAngle: 3.6, + top: top, + bottom: bottom, + height: height, name: 'Mining pool', type: 'pie', radius: radius, data: this.generatePoolsChartSerieData(miningStats), labelLine: { - length: this.isMobile() ? 10 : 15, - length2: this.isMobile() ? 0 : 15, lineStyle: { width: 2, }, @@ -223,14 +291,19 @@ export class PoolRankingComponent implements OnInit { this.chartInstance = ec; this.chartInstance.on('click', (e) => { - this.router.navigate(['/mining/pool/', e.data.data]); + if (e.data.data === 9999) { // "Other" + return; + } + this.zone.run(() => { + this.router.navigate(['/mining/pool/', e.data.data]); + }); }); } /** * Default mining stats if something goes wrong */ - getEmptyMiningStat() { + getEmptyMiningStat(): MiningStats { return { lastEstimatedHashrate: 'Error', blockCount: 0, diff --git a/frontend/src/app/components/search-form/search-form.component.html b/frontend/src/app/components/search-form/search-form.component.html index f3c70d40b..422dfaa62 100644 --- a/frontend/src/app/components/search-form/search-form.component.html +++ b/frontend/src/app/components/search-form/search-form.component.html @@ -1,7 +1,7 @@
- +
diff --git a/frontend/src/app/components/search-form/search-form.component.scss b/frontend/src/app/components/search-form/search-form.component.scss index 5a72339a3..f316c3aa7 100644 --- a/frontend/src/app/components/search-form/search-form.component.scss +++ b/frontend/src/app/components/search-form/search-form.component.scss @@ -1,8 +1,19 @@ -:host ::ng-deep .dropdown-item { - white-space: nowrap; - overflow: hidden; - width: 375px; - text-overflow: ellipsis; +:host ::ng-deep { + .dropdown-item { + white-space: nowrap; + width: calc(100% - 34px); + } + .dropdown-menu { + width: calc(100% - 34px); + } + @media (min-width: 768px) { + .dropdown-item { + width: 410px; + } + .dropdown-menu { + width: 410px; + } + } } form { diff --git a/frontend/src/app/components/search-form/search-form.component.ts b/frontend/src/app/components/search-form/search-form.component.ts index 24e8a5b23..d83975c50 100644 --- a/frontend/src/app/components/search-form/search-form.component.ts +++ b/frontend/src/app/components/search-form/search-form.component.ts @@ -8,6 +8,7 @@ import { debounceTime, distinctUntilChanged, switchMap, filter, catchError, map import { ElectrsApiService } from 'src/app/services/electrs-api.service'; import { NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; +import { ShortenStringPipe } from 'src/app/shared/pipes/shorten-string-pipe/shorten-string.pipe'; @Component({ selector: 'app-search-form', @@ -22,6 +23,7 @@ export class SearchFormComponent implements OnInit { typeaheadSearchFn: ((text: Observable) => Observable); searchForm: FormGroup; + isMobile = (window.innerWidth <= 767.98); @Output() searchTriggered = new EventEmitter(); regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100})$/; @@ -33,6 +35,8 @@ export class SearchFormComponent implements OnInit { focus$ = new Subject(); click$ = new Subject(); + formatterFn = (address: string) => this.shortenStringPipe.transform(address, this.isMobile ? 33 : 40); + constructor( private formBuilder: FormBuilder, private router: Router, @@ -40,6 +44,7 @@ export class SearchFormComponent implements OnInit { private stateService: StateService, private electrsApiService: ElectrsApiService, private relativeUrlPipe: RelativeUrlPipe, + private shortenStringPipe: ShortenStringPipe, ) { } ngOnInit() { 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 bd77c564f..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; @@ -185,15 +187,12 @@ export class TransactionComponent implements OnInit, OnDestroy { this.error = undefined; this.waitingForTransaction = false; this.setMempoolBlocksSubscription(); + this.websocketService.startTrackTransaction(tx.txid); - if (!tx.status.confirmed) { - this.websocketService.startTrackTransaction(tx.txid); - - if (tx.firstSeen) { - this.transactionTime = tx.firstSeen; - } else { - this.getTransactionTime(); - } + if (!tx.status.confirmed && tx.firstSeen) { + this.transactionTime = tx.firstSeen; + } else { + this.getTransactionTime(); } if (this.tx.status.confirmed) { @@ -220,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) { @@ -235,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 { @@ -305,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 a4c308929..1470e6211 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -73,16 +73,19 @@ -
+
+ + {{ vin.prevout.value }} {{ vin.prevout.asset | slice : 0 : 7 }} + - + @@ -204,7 +207,7 @@ - +
@@ -240,7 +243,7 @@
-
+
{{ tx.fee / (tx.weight / 4) | feeRounding }} sat/vB  – {{ tx.fee | number }} sat
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 2740075b8..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, ChangeDetectorRef, 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 } 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 } from 'rxjs/operators'; +import { map, share, switchMap, tap } from 'rxjs/operators'; import { BlockExtended } from 'src/app/interfaces/node-api.interface'; @Component({ @@ -17,7 +17,6 @@ import { BlockExtended } from 'src/app/interfaces/node-api.interface'; export class TransactionsListComponent implements OnInit, OnChanges { network = ''; nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId; - displayDetails = false; @Input() transactions: Transaction[]; @Input() showConfirmations = false; @@ -28,7 +27,10 @@ export class TransactionsListComponent implements OnInit, OnChanges { @Output() loadMore = new EventEmitter(); latestBlock$: Observable; - outspends: Outspend[] = []; + outspendsSubscription: Subscription; + refreshOutspends$: ReplaySubject = new ReplaySubject(); + showDetails$ = new BehaviorSubject(false); + outspends: Outspend[][] = []; assetsMinimal: any; constructor( @@ -47,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() { @@ -70,18 +100,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { } observableObject[i] = this.electrsApiService.getOutspends$(tx.txid); }); - - forkJoin(observableObject) - .subscribe((outspends: any) => { - const newOutspends = []; - for (const i in outspends) { - if (outspends.hasOwnProperty(i)) { - newOutspends.push(outspends[i]); - } - } - this.outspends = this.outspends.concat(newOutspends); - this.ref.markForCheck(); - }); + this.refreshOutspends$.next(observableObject); } onScroll() { @@ -129,7 +148,14 @@ export class TransactionsListComponent implements OnInit, OnChanges { } toggleDetails() { - this.displayDetails = !this.displayDetails; - this.ref.markForCheck(); + if (this.showDetails$.value === true) { + this.showDetails$.next(false); + } else { + this.showDetails$.next(true); + } + } + + ngOnDestroy() { + this.outspendsSubscription.unsubscribe(); } } diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index d8760d1f0..a05671257 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -64,7 +64,7 @@ export interface SinglePoolStats { blockCount: number; emptyBlocks: number; rank: number; - share: string; + share: number; lastEstimatedHashrate: string; emptyBlockRatio: string; logo: string; @@ -75,13 +75,6 @@ export interface PoolsStats { oldestIndexedBlockTimestamp: number; pools: SinglePoolStats[]; } -export interface MiningStats { - lastEstimatedHashrate: string; - blockCount: number; - totalEmptyBlock: number; - totalEmptyBlockRatio: string; - pools: SinglePoolStats[]; -} /** * Pool component diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 1dbaa90af..b0ae27f73 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -15,7 +15,9 @@ export interface WebsocketResponse { action?: string; data?: string[]; tx?: Transaction; - rbfTransaction?: Transaction; + rbfTransaction?: ReplacedTransaction; + txReplaced?: ReplacedTransaction; + utxoSpent?: object; transactions?: TransactionStripped[]; loadingIndicators?: ILoadingIndicators; backendInfo?: IBackendInfo; @@ -26,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/mining.service.ts b/frontend/src/app/services/mining.service.ts index c216515b0..68f7e9da1 100644 --- a/frontend/src/app/services/mining.service.ts +++ b/frontend/src/app/services/mining.service.ts @@ -73,7 +73,7 @@ export class MiningService { const totalEmptyBlockRatio = (totalEmptyBlock / stats.blockCount * 100).toFixed(2); const poolsStats = stats.pools.map((poolStat) => { return { - share: (poolStat.blockCount / stats.blockCount * 100).toFixed(2), + share: parseFloat((poolStat.blockCount / stats.blockCount * 100).toFixed(2)), lastEstimatedHashrate: (poolStat.blockCount / stats.blockCount * stats.lastEstimatedHashrate / hashrateDivider).toFixed(2), emptyBlockRatio: (poolStat.emptyBlocks / poolStat.blockCount * 100).toFixed(2), logo: `./resources/mining-pools/` + poolStat.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg', diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 50384ac76..1985cb5c0 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'; @@ -82,7 +82,8 @@ export class StateService { bsqPrice$ = new ReplaySubject(1); mempoolInfo$ = new ReplaySubject(1); mempoolBlocks$ = new ReplaySubject(1); - txReplaced$ = 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 2b95cc0ed..1eb9a56e6 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -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']); } @@ -251,6 +255,10 @@ export class WebsocketService { this.stateService.bsqPrice$.next(response['bsq-price']); } + if (response.utxoSpent) { + this.stateService.utxoSpent$.next(response.utxoSpent); + } + if (response.backendInfo) { this.stateService.backendInfo$.next(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