Merge branch 'master' into address-labels
This commit is contained in:
		
						commit
						3679f197ba
					
				| @ -4,6 +4,7 @@ export namespace IBitcoinApi { | ||||
|     size: number;                    //  (numeric) Current tx count
 | ||||
|     bytes: number;                   //  (numeric) Sum of all virtual transaction sizes as defined in BIP 141.
 | ||||
|     usage: number;                   //  (numeric) Total memory usage for the mempool
 | ||||
|     total_fee: number;               //  (numeric) Total fees of transactions in the mempool
 | ||||
|     maxmempool: number;              //  (numeric) Maximum memory usage for the mempool
 | ||||
|     mempoolminfee: number;           //  (numeric) Minimum fee rate in BTC/kB for tx to be accepted.
 | ||||
|     minrelaytxfee: number;           //  (numeric) Current minimum relay fee for transactions
 | ||||
|  | ||||
| @ -10,6 +10,7 @@ import bitcoinClient from './bitcoin/bitcoin-client'; | ||||
| import { IEsploraApi } from './bitcoin/esplora-api.interface'; | ||||
| import poolsRepository from '../repositories/PoolsRepository'; | ||||
| import blocksRepository from '../repositories/BlocksRepository'; | ||||
| import loadingIndicators from './loading-indicators'; | ||||
| 
 | ||||
| class Blocks { | ||||
|   private blocks: BlockExtended[] = []; | ||||
| @ -41,7 +42,12 @@ class Blocks { | ||||
|    * @param onlyCoinbase - Set to true if you only need the coinbase transaction | ||||
|    * @returns Promise<TransactionExtended[]> | ||||
|    */ | ||||
|   private async $getTransactionsExtended(blockHash: string, blockHeight: number, onlyCoinbase: boolean): Promise<TransactionExtended[]> { | ||||
|   private async $getTransactionsExtended( | ||||
|     blockHash: string, | ||||
|     blockHeight: number, | ||||
|     onlyCoinbase: boolean, | ||||
|     quiet: boolean = false, | ||||
|   ): Promise<TransactionExtended[]> { | ||||
|     const transactions: TransactionExtended[] = []; | ||||
|     const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash); | ||||
| 
 | ||||
| @ -55,9 +61,9 @@ class Blocks { | ||||
|         // optimize here by directly fetching txs in the "outdated" mempool
 | ||||
|         transactions.push(mempool[txIds[i]]); | ||||
|         transactionsFound++; | ||||
|       } else if (config.MEMPOOL.BACKEND === 'esplora' || memPool.isInSync() || i === 0) { | ||||
|       } else if (config.MEMPOOL.BACKEND === 'esplora' || !memPool.hasPriority() || i === 0) { | ||||
|         // Otherwise we fetch the tx data through backend services (esplora, electrum, core rpc...)
 | ||||
|         if (i % (Math.round((txIds.length) / 10)) === 0 || i + 1 === txIds.length) { // Avoid log spam
 | ||||
|         if (!quiet && (i % (Math.round((txIds.length) / 10)) === 0 || i + 1 === txIds.length)) { // Avoid log spam
 | ||||
|           logger.debug(`Indexing tx ${i + 1} of ${txIds.length} in block #${blockHeight}`); | ||||
|         } | ||||
|         try { | ||||
| @ -83,7 +89,9 @@ class Blocks { | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     logger.debug(`${transactionsFound} of ${txIds.length} found in mempool. ${transactionsFetched} fetched through backend service.`); | ||||
|     if (!quiet) { | ||||
|       logger.debug(`${transactionsFound} of ${txIds.length} found in mempool. ${transactionsFetched} fetched through backend service.`); | ||||
|     } | ||||
| 
 | ||||
|     return transactions; | ||||
|   } | ||||
| @ -94,13 +102,10 @@ class Blocks { | ||||
|    * @param transactions | ||||
|    * @returns BlockExtended | ||||
|    */ | ||||
|   private getBlockExtended(block: IEsploraApi.Block, transactions: TransactionExtended[]): BlockExtended { | ||||
|     const blockExtended: BlockExtended = Object.assign({}, block); | ||||
| 
 | ||||
|     blockExtended.extras = { | ||||
|       reward: transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0), | ||||
|       coinbaseTx: transactionUtils.stripCoinbaseTransaction(transactions[0]), | ||||
|     }; | ||||
|    private async $getBlockExtended(block: IEsploraApi.Block, transactions: TransactionExtended[]): Promise<BlockExtended> { | ||||
|     const blockExtended: BlockExtended = Object.assign({extras: {}}, block); | ||||
|     blockExtended.extras.reward = transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0); | ||||
|     blockExtended.extras.coinbaseTx = transactionUtils.stripCoinbaseTransaction(transactions[0]); | ||||
| 
 | ||||
|     const transactionsTmp = [...transactions]; | ||||
|     transactionsTmp.shift(); | ||||
| @ -111,6 +116,19 @@ class Blocks { | ||||
|     blockExtended.extras.feeRange = transactionsTmp.length > 0 ? | ||||
|       Common.getFeesInRange(transactionsTmp, 8) : [0, 0]; | ||||
| 
 | ||||
|     if (Common.indexingEnabled()) { | ||||
|       let pool: PoolTag; | ||||
|       if (blockExtended.extras?.coinbaseTx !== undefined) { | ||||
|         pool = await this.$findBlockMiner(blockExtended.extras?.coinbaseTx); | ||||
|       } else { | ||||
|         pool = await poolsRepository.$getUnknownPool(); | ||||
|       } | ||||
|       blockExtended.extras.pool = { | ||||
|         id: pool.id, | ||||
|         name: pool.name | ||||
|       }; | ||||
|     } | ||||
| 
 | ||||
|     return blockExtended; | ||||
|   } | ||||
| 
 | ||||
| @ -152,20 +170,20 @@ class Blocks { | ||||
|    * Index all blocks metadata for the mining dashboard | ||||
|    */ | ||||
|   public async $generateBlockDatabase() { | ||||
|     if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false || // Bitcoin only
 | ||||
|       config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === 0 || // Indexing must be enabled
 | ||||
|       !memPool.isInSync() || // We sync the mempool first
 | ||||
|       this.blockIndexingStarted === true // Indexing must not already be in progress
 | ||||
|     if (this.blockIndexingStarted === true || | ||||
|       !Common.indexingEnabled() || | ||||
|       memPool.hasPriority() | ||||
|     ) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const blockchainInfo = await bitcoinClient.getBlockchainInfo(); | ||||
|     if (blockchainInfo.blocks !== blockchainInfo.headers) { | ||||
|     if (blockchainInfo.blocks !== blockchainInfo.headers) { // Wait for node to sync
 | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.blockIndexingStarted = true; | ||||
|     const startedAt = new Date().getTime() / 1000; | ||||
| 
 | ||||
|     try { | ||||
|       let currentBlockHeight = blockchainInfo.blocks; | ||||
| @ -180,6 +198,8 @@ class Blocks { | ||||
|       logger.info(`Indexing blocks from #${currentBlockHeight} to #${lastBlockToIndex}`); | ||||
| 
 | ||||
|       const chunkSize = 10000; | ||||
|       let totaIndexed = await blocksRepository.$blockCount(null, null); | ||||
|       let indexedThisRun = 0; | ||||
|       while (currentBlockHeight >= lastBlockToIndex) { | ||||
|         const endBlock = Math.max(0, lastBlockToIndex, currentBlockHeight - chunkSize + 1); | ||||
| 
 | ||||
| @ -198,21 +218,19 @@ class Blocks { | ||||
|             break; | ||||
|           } | ||||
|           try { | ||||
|             logger.debug(`Indexing block #${blockHeight}`); | ||||
|             ++indexedThisRun; | ||||
|             if (++totaIndexed % 100 === 0 || blockHeight === lastBlockToIndex) { | ||||
|               const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); | ||||
|               const blockPerSeconds = Math.max(1, Math.round(indexedThisRun / elapsedSeconds)); | ||||
|               const progress = Math.round(totaIndexed / indexingBlockAmount * 100); | ||||
|               const timeLeft = Math.round((indexingBlockAmount - totaIndexed) / blockPerSeconds); | ||||
|               logger.debug(`Indexing block #${blockHeight} | ~${blockPerSeconds} blocks/sec | total: ${totaIndexed}/${indexingBlockAmount} (${progress}%) | elapsed: ${elapsedSeconds} seconds | left: ~${timeLeft} seconds`); | ||||
|             } | ||||
|             const blockHash = await bitcoinApi.$getBlockHash(blockHeight); | ||||
|             const block = await bitcoinApi.$getBlock(blockHash); | ||||
|             const transactions = await this.$getTransactionsExtended(blockHash, block.height, true); | ||||
|             const blockExtended = this.getBlockExtended(block, transactions); | ||||
| 
 | ||||
|             let miner: PoolTag; | ||||
|             if (blockExtended?.extras?.coinbaseTx) { | ||||
|               miner = await this.$findBlockMiner(blockExtended.extras.coinbaseTx); | ||||
|             } else { | ||||
|               miner = await poolsRepository.$getUnknownPool(); | ||||
|             } | ||||
| 
 | ||||
|             const coinbase: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(transactions[0].txid, true); | ||||
|             await blocksRepository.$saveBlockInDatabase(blockExtended, blockHash, coinbase.hex, miner); | ||||
|             const transactions = await this.$getTransactionsExtended(blockHash, block.height, true, true); | ||||
|             const blockExtended = await this.$getBlockExtended(block, transactions); | ||||
|             await blocksRepository.$saveBlockInDatabase(blockExtended); | ||||
|           } catch (e) { | ||||
|             logger.err(`Something went wrong while indexing blocks.` + e); | ||||
|           } | ||||
| @ -271,17 +289,10 @@ class Blocks { | ||||
|       const block = await bitcoinApi.$getBlock(blockHash); | ||||
|       const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash); | ||||
|       const transactions = await this.$getTransactionsExtended(blockHash, block.height, false); | ||||
|       const blockExtended: BlockExtended = this.getBlockExtended(block, transactions); | ||||
|       const coinbase: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(transactions[0].txid, true); | ||||
|       const blockExtended: BlockExtended = await this.$getBlockExtended(block, transactions); | ||||
| 
 | ||||
|       if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === true) { | ||||
|         let miner: PoolTag; | ||||
|         if (blockExtended?.extras?.coinbaseTx) { | ||||
|           miner = await this.$findBlockMiner(blockExtended.extras.coinbaseTx); | ||||
|         } else { | ||||
|           miner = await poolsRepository.$getUnknownPool(); | ||||
|         } | ||||
|         await blocksRepository.$saveBlockInDatabase(blockExtended, blockHash, coinbase.hex, miner); | ||||
|       if (Common.indexingEnabled()) { | ||||
|         await blocksRepository.$saveBlockInDatabase(blockExtended); | ||||
|       } | ||||
| 
 | ||||
|       if (block.height % 2016 === 0) { | ||||
| @ -298,12 +309,98 @@ class Blocks { | ||||
|       if (this.newBlockCallbacks.length) { | ||||
|         this.newBlockCallbacks.forEach((cb) => cb(blockExtended, txIds, transactions)); | ||||
|       } | ||||
|       if (memPool.isInSync()) { | ||||
|       if (!memPool.hasPriority()) { | ||||
|         diskCache.$saveCacheToDisk(); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Index a block if it's missing from the database. Returns the block after indexing | ||||
|    */ | ||||
|    public async $indexBlock(height: number): Promise<BlockExtended> { | ||||
|     const dbBlock = await blocksRepository.$getBlockByHeight(height); | ||||
|     if (dbBlock != null) { | ||||
|       return this.prepareBlock(dbBlock); | ||||
|     } | ||||
| 
 | ||||
|     const blockHash = await bitcoinApi.$getBlockHash(height); | ||||
|     const block = await bitcoinApi.$getBlock(blockHash); | ||||
|     const transactions = await this.$getTransactionsExtended(blockHash, block.height, true); | ||||
|     const blockExtended = await this.$getBlockExtended(block, transactions); | ||||
| 
 | ||||
|     await blocksRepository.$saveBlockInDatabase(blockExtended); | ||||
| 
 | ||||
|     return blockExtended; | ||||
|   } | ||||
| 
 | ||||
|   public async $getBlocksExtras(fromHeight: number): Promise<BlockExtended[]> { | ||||
|     try { | ||||
|       loadingIndicators.setProgress('blocks', 0); | ||||
| 
 | ||||
|       let currentHeight = fromHeight ? fromHeight : this.getCurrentBlockHeight(); | ||||
|       const returnBlocks: BlockExtended[] = []; | ||||
| 
 | ||||
|       if (currentHeight < 0) { | ||||
|         return returnBlocks; | ||||
|       } | ||||
| 
 | ||||
|       // Check if block height exist in local cache to skip the hash lookup
 | ||||
|       const blockByHeight = this.getBlocks().find((b) => b.height === currentHeight); | ||||
|       let startFromHash: string | null = null; | ||||
|       if (blockByHeight) { | ||||
|         startFromHash = blockByHeight.id; | ||||
|       } else { | ||||
|         startFromHash = await bitcoinApi.$getBlockHash(currentHeight); | ||||
|       } | ||||
| 
 | ||||
|       let nextHash = startFromHash; | ||||
|       for (let i = 0; i < 10 && currentHeight >= 0; i++) { | ||||
|         let block = this.getBlocks().find((b) => b.height === currentHeight); | ||||
|         if (!block && Common.indexingEnabled()) { | ||||
|           block = this.prepareBlock(await this.$indexBlock(currentHeight)); | ||||
|         } else if (!block) { | ||||
|           block = this.prepareBlock(await bitcoinApi.$getBlock(nextHash)); | ||||
|         } | ||||
|         returnBlocks.push(block); | ||||
|         nextHash = block.previousblockhash; | ||||
|         loadingIndicators.setProgress('blocks', i / 10 * 100); | ||||
|         currentHeight--; | ||||
|       } | ||||
| 
 | ||||
|       return returnBlocks; | ||||
|     } catch (e) { | ||||
|       loadingIndicators.setProgress('blocks', 100); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private prepareBlock(block: any): BlockExtended { | ||||
|     return <BlockExtended>{ | ||||
|       id: block.id ?? block.hash, // hash for indexed block
 | ||||
|       timestamp: block?.timestamp ?? block?.blockTimestamp, // blockTimestamp for indexed block
 | ||||
|       height: block?.height, | ||||
|       version: block?.version, | ||||
|       bits: block?.bits, | ||||
|       nonce: block?.nonce, | ||||
|       difficulty: block?.difficulty, | ||||
|       merkle_root: block?.merkle_root, | ||||
|       tx_count: block?.tx_count, | ||||
|       size: block?.size, | ||||
|       weight: block?.weight, | ||||
|       previousblockhash: block?.previousblockhash, | ||||
|       extras: { | ||||
|         medianFee: block?.medianFee, | ||||
|         feeRange: block?.feeRange ?? [], // TODO
 | ||||
|         reward: block?.reward, | ||||
|         pool: block?.extras?.pool ?? (block?.pool_id ? { | ||||
|           id: block?.pool_id, | ||||
|           name: block?.pool_name, | ||||
|         } : undefined), | ||||
|       } | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   public getLastDifficultyAdjustmentTime(): number { | ||||
|     return this.lastDifficultyAdjustmentTime; | ||||
|   } | ||||
|  | ||||
| @ -154,4 +154,27 @@ export class Common { | ||||
|     }); | ||||
|     return parents; | ||||
|   } | ||||
| 
 | ||||
|   static getSqlInterval(interval: string | null): string | null { | ||||
|     switch (interval) { | ||||
|       case '24h': return '1 DAY'; | ||||
|       case '3d': return '3 DAY'; | ||||
|       case '1w': return '1 WEEK'; | ||||
|       case '1m': return '1 MONTH'; | ||||
|       case '3m': return '3 MONTH'; | ||||
|       case '6m': return '6 MONTH'; | ||||
|       case '1y': return '1 YEAR'; | ||||
|       case '2y': return '2 YEAR'; | ||||
|       case '3y': return '3 YEAR'; | ||||
|       default: return null; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   static indexingEnabled(): boolean { | ||||
|     return ( | ||||
|       ['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) && | ||||
|       config.DATABASE.ENABLED === true && | ||||
|       config.MEMPOOL.INDEXING_BLOCKS_AMOUNT != 0 | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -6,7 +6,7 @@ import logger from '../logger'; | ||||
| const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); | ||||
| 
 | ||||
| class DatabaseMigration { | ||||
|   private static currentVersion = 4; | ||||
|   private static currentVersion = 6; | ||||
|   private queryTimeout = 120000; | ||||
|   private statisticsAddedIndexed = false; | ||||
| 
 | ||||
| @ -76,6 +76,7 @@ class DatabaseMigration { | ||||
|   private async $createMissingTablesAndIndexes(databaseSchemaVersion: number) { | ||||
|     await this.$setStatisticsAddedIndexedFlag(databaseSchemaVersion); | ||||
| 
 | ||||
|     const isBitcoin = ['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK); | ||||
|     const connection = await DB.pool.getConnection(); | ||||
|     try { | ||||
|       await this.$executeQuery(connection, this.getCreateElementsTableQuery(), await this.$checkIfTableExists('elements_pegs')); | ||||
| @ -90,6 +91,31 @@ class DatabaseMigration { | ||||
|         await this.$executeQuery(connection, 'DROP table IF EXISTS blocks;'); | ||||
|         await this.$executeQuery(connection, this.getCreateBlocksTableQuery(), await this.$checkIfTableExists('blocks')); | ||||
|       } | ||||
|       if (databaseSchemaVersion < 5 && isBitcoin === true) { | ||||
|         await this.$executeQuery(connection, 'TRUNCATE blocks;'); // Need to re-index
 | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `reward` double unsigned NOT NULL DEFAULT "0"'); | ||||
|       } | ||||
| 
 | ||||
|       if (databaseSchemaVersion < 6 && isBitcoin === true) { | ||||
|         await this.$executeQuery(connection, 'TRUNCATE blocks;');  // Need to re-index
 | ||||
|         // Cleanup original blocks fields type
 | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `height` integer unsigned NOT NULL DEFAULT "0"'); | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `tx_count` smallint unsigned NOT NULL DEFAULT "0"'); | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `size` integer unsigned NOT NULL DEFAULT "0"'); | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `weight` integer unsigned NOT NULL DEFAULT "0"'); | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `difficulty` double NOT NULL DEFAULT "0"'); | ||||
|         // We also fix the pools.id type so we need to drop/re-create the foreign key
 | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE blocks DROP FOREIGN KEY IF EXISTS `blocks_ibfk_1`'); | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE pools MODIFY `id` smallint unsigned AUTO_INCREMENT'); | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `pool_id` smallint unsigned NULL'); | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE blocks ADD FOREIGN KEY (`pool_id`) REFERENCES `pools` (`id`)'); | ||||
|         // Add new block indexing fields
 | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `version` integer unsigned NOT NULL DEFAULT "0"'); | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `bits` integer unsigned NOT NULL DEFAULT "0"'); | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `nonce` bigint unsigned NOT NULL DEFAULT "0"'); | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `merkle_root` varchar(65) NOT NULL DEFAULT ""'); | ||||
|         await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `previous_block_hash` varchar(65) NULL'); | ||||
|       } | ||||
|       connection.release(); | ||||
|     } catch (e) { | ||||
|       connection.release(); | ||||
|  | ||||
| @ -13,8 +13,9 @@ class Mempool { | ||||
|   private static WEBSOCKET_REFRESH_RATE_MS = 10000; | ||||
|   private static LAZY_DELETE_AFTER_SECONDS = 30; | ||||
|   private inSync: boolean = false; | ||||
|   private mempoolCacheDelta: number = -1; | ||||
|   private mempoolCache: { [txId: string]: TransactionExtended } = {}; | ||||
|   private mempoolInfo: IBitcoinApi.MempoolInfo = { loaded: false, size: 0, bytes: 0, usage: 0, | ||||
|   private mempoolInfo: IBitcoinApi.MempoolInfo = { loaded: false, size: 0, bytes: 0, usage: 0, total_fee: 0, | ||||
|                                                     maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 }; | ||||
|   private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[], | ||||
|     deletedTransactions: TransactionExtended[]) => void) | undefined; | ||||
| @ -32,6 +33,17 @@ class Mempool { | ||||
|     setInterval(this.deleteExpiredTransactions.bind(this), 20000); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Return true if we should leave resources available for mempool tx caching | ||||
|    */ | ||||
|   public hasPriority(): boolean { | ||||
|     if (this.inSync) { | ||||
|       return false; | ||||
|     } else { | ||||
|       return this.mempoolCacheDelta == -1 || this.mempoolCacheDelta > 25; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public isInSync(): boolean { | ||||
|     return this.inSync; | ||||
|   } | ||||
| @ -100,6 +112,8 @@ class Mempool { | ||||
|     const diff = transactions.length - currentMempoolSize; | ||||
|     const newTransactions: TransactionExtended[] = []; | ||||
| 
 | ||||
|     this.mempoolCacheDelta = Math.abs(diff); | ||||
| 
 | ||||
|     if (!this.inSync) { | ||||
|       loadingIndicators.setProgress('mempool', Object.keys(this.mempoolCache).length / transactions.length * 100); | ||||
|     } | ||||
| @ -168,13 +182,14 @@ class Mempool { | ||||
|     const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx)); | ||||
|     this.latestTransactions = newTransactionsStripped.concat(this.latestTransactions).slice(0, 6); | ||||
| 
 | ||||
|     const syncedThreshold = 0.99; // If we synced 99% of the mempool tx count, consider we're synced
 | ||||
|     if (!this.inSync && Object.keys(this.mempoolCache).length >= transactions.length * syncedThreshold) { | ||||
|     if (!this.inSync && transactions.length === Object.keys(this.mempoolCache).length) { | ||||
|       this.inSync = true; | ||||
|       logger.notice('The mempool is now in sync!'); | ||||
|       loadingIndicators.setProgress('mempool', 100); | ||||
|     } | ||||
| 
 | ||||
|     this.mempoolCacheDelta = Math.abs(transactions.length - Object.keys(this.mempoolCache).length); | ||||
| 
 | ||||
|     if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) { | ||||
|       this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions); | ||||
|     } | ||||
|  | ||||
| @ -11,24 +11,10 @@ class Mining { | ||||
|    * Generate high level overview of the pool ranks and general stats | ||||
|    */ | ||||
|   public async $getPoolsStats(interval: string | null) : Promise<object> { | ||||
|     let sqlInterval: string | null = null; | ||||
|     switch (interval) { | ||||
|       case '24h': sqlInterval = '1 DAY'; break; | ||||
|       case '3d': sqlInterval = '3 DAY'; break; | ||||
|       case '1w': sqlInterval = '1 WEEK'; break; | ||||
|       case '1m': sqlInterval = '1 MONTH'; break; | ||||
|       case '3m': sqlInterval = '3 MONTH'; break; | ||||
|       case '6m': sqlInterval = '6 MONTH'; break; | ||||
|       case '1y': sqlInterval = '1 YEAR'; break; | ||||
|       case '2y': sqlInterval = '2 YEAR'; break; | ||||
|       case '3y': sqlInterval = '3 YEAR'; break; | ||||
|       default: sqlInterval = null; break; | ||||
|     } | ||||
| 
 | ||||
|     const poolsStatistics = {}; | ||||
| 
 | ||||
|     const poolsInfo: PoolInfo[] = await PoolsRepository.$getPoolsInfo(sqlInterval); | ||||
|     const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$countEmptyBlocks(sqlInterval); | ||||
|     const poolsInfo: PoolInfo[] = await PoolsRepository.$getPoolsInfo(interval); | ||||
|     const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$getEmptyBlocks(null, interval); | ||||
| 
 | ||||
|     const poolsStats: PoolStats[] = []; | ||||
|     let rank = 1; | ||||
| @ -55,7 +41,7 @@ class Mining { | ||||
|     const oldestBlock = new Date(await BlocksRepository.$oldestBlockTimestamp()); | ||||
|     poolsStatistics['oldestIndexedBlockTimestamp'] = oldestBlock.getTime(); | ||||
| 
 | ||||
|     const blockCount: number = await BlocksRepository.$blockCount(sqlInterval); | ||||
|     const blockCount: number = await BlocksRepository.$blockCount(null, interval); | ||||
|     poolsStatistics['blockCount'] = blockCount; | ||||
| 
 | ||||
|     const blockHeightTip = await bitcoinClient.getBlockCount(); | ||||
| @ -64,6 +50,38 @@ class Mining { | ||||
| 
 | ||||
|     return poolsStatistics; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get all mining pool stats for a pool | ||||
|    */ | ||||
|   public async $getPoolStat(interval: string | null, poolId: number): Promise<object> { | ||||
|     const pool = await PoolsRepository.$getPool(poolId); | ||||
|     if (!pool) { | ||||
|       throw new Error(`This mining pool does not exist`); | ||||
|     } | ||||
| 
 | ||||
|     const blockCount: number = await BlocksRepository.$blockCount(poolId, interval); | ||||
|     const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$getEmptyBlocks(poolId, interval); | ||||
| 
 | ||||
|     return { | ||||
|       pool: pool, | ||||
|       blockCount: blockCount, | ||||
|       emptyBlocks: emptyBlocks, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Return the historical difficulty adjustments and oldest indexed block timestamp | ||||
|    */ | ||||
|   public async $getHistoricalDifficulty(interval: string | null): Promise<object> { | ||||
|     const difficultyAdjustments = await BlocksRepository.$getBlocksDifficulty(interval); | ||||
|     const oldestBlock = new Date(await BlocksRepository.$oldestBlockTimestamp()); | ||||
| 
 | ||||
|     return { | ||||
|       adjustments: difficultyAdjustments, | ||||
|       oldestIndexedBlockTimestamp: oldestBlock.getTime(), | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new Mining(); | ||||
|  | ||||
| @ -256,6 +256,11 @@ class Server { | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1y', routes.$getStatisticsByTime.bind(routes, '1y')) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2y', routes.$getStatisticsByTime.bind(routes, '2y')) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3y', routes.$getStatisticsByTime.bind(routes, '3y')) | ||||
|         ; | ||||
|     } | ||||
| 
 | ||||
|     if (Common.indexingEnabled()) { | ||||
|       this.app | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/24h', routes.$getPools.bind(routes, '24h')) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/3d', routes.$getPools.bind(routes, '3d')) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/1w', routes.$getPools.bind(routes, '1w')) | ||||
| @ -266,7 +271,12 @@ class Server { | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/2y', routes.$getPools.bind(routes, '2y')) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/3y', routes.$getPools.bind(routes, '3y')) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/all', routes.$getPools.bind(routes, 'all')) | ||||
|         ; | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/blocks', routes.$getPoolBlocks) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/blocks/:height', routes.$getPoolBlocks) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId', routes.$getPool) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/:interval', routes.$getPool) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty', routes.$getHistoricalDifficulty) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty/:interval', routes.$getHistoricalDifficulty); | ||||
|     } | ||||
| 
 | ||||
|     if (config.BISQ.ENABLED) { | ||||
| @ -290,6 +300,10 @@ class Server { | ||||
|         ; | ||||
|     } | ||||
| 
 | ||||
|     this.app | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-extras', routes.getBlocksExtras) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-extras/:height', routes.getBlocksExtras); | ||||
| 
 | ||||
|     if (config.MEMPOOL.BACKEND !== 'esplora') { | ||||
|       this.app | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'mempool', routes.getMempool) | ||||
|  | ||||
| @ -1,23 +1,23 @@ | ||||
| import { IEsploraApi } from './api/bitcoin/esplora-api.interface'; | ||||
| 
 | ||||
| export interface PoolTag { | ||||
|   id: number | null, // mysql row id
 | ||||
|   name: string, | ||||
|   link: string, | ||||
|   regexes: string, // JSON array
 | ||||
|   addresses: string, // JSON array
 | ||||
|   id: number; // mysql row id
 | ||||
|   name: string; | ||||
|   link: string; | ||||
|   regexes: string; // JSON array
 | ||||
|   addresses: string; // JSON array
 | ||||
| } | ||||
| 
 | ||||
| export interface PoolInfo { | ||||
|   poolId: number, // mysql row id
 | ||||
|   name: string, | ||||
|   link: string, | ||||
|   blockCount: number, | ||||
|   poolId: number; // mysql row id
 | ||||
|   name: string; | ||||
|   link: string; | ||||
|   blockCount: number; | ||||
| } | ||||
| 
 | ||||
| export interface PoolStats extends PoolInfo { | ||||
|   rank: number, | ||||
|   emptyBlocks: number, | ||||
|   rank: number; | ||||
|   emptyBlocks: number; | ||||
| } | ||||
| 
 | ||||
| export interface MempoolBlock { | ||||
| @ -83,10 +83,14 @@ export interface BlockExtension { | ||||
|   reward?: number; | ||||
|   coinbaseTx?: TransactionMinerInfo; | ||||
|   matchRate?: number; | ||||
|   pool?: { | ||||
|     id: number; | ||||
|     name: string; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export interface BlockExtended extends IEsploraApi.Block { | ||||
|   extras?: BlockExtension; | ||||
|   extras: BlockExtension; | ||||
| } | ||||
| 
 | ||||
| export interface TransactionMinerInfo { | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| import { BlockExtended, PoolTag } from '../mempool.interfaces'; | ||||
| import { DB } from '../database'; | ||||
| import logger from '../logger'; | ||||
| import { Common } from '../api/common'; | ||||
| 
 | ||||
| export interface EmptyBlocks { | ||||
|   emptyBlocks: number; | ||||
| @ -11,40 +12,46 @@ class BlocksRepository { | ||||
|   /** | ||||
|    * Save indexed block data in the database | ||||
|    */ | ||||
|   public async $saveBlockInDatabase( | ||||
|     block: BlockExtended, | ||||
|     blockHash: string, | ||||
|     coinbaseHex: string | undefined, | ||||
|     poolTag: PoolTag | ||||
|   ) { | ||||
|   public async $saveBlockInDatabase(block: BlockExtended) { | ||||
|     const connection = await DB.pool.getConnection(); | ||||
| 
 | ||||
|     try { | ||||
|       const query = `INSERT INTO blocks(
 | ||||
|         height,  hash,     blockTimestamp, size, | ||||
|         weight,  tx_count, coinbase_raw,   difficulty, | ||||
|         pool_id, fees,     fee_span,       median_fee | ||||
|         pool_id, fees,     fee_span,       median_fee, | ||||
|         reward,  version,  bits,           nonce, | ||||
|         merkle_root,       previous_block_hash | ||||
|       ) VALUE ( | ||||
|         ?, ?, FROM_UNIXTIME(?), ?, | ||||
|         ?, ?, ?, ?, | ||||
|         ?, ?, ?, ? | ||||
|         ?, ?, ?, ?, | ||||
|         ?, ?, ?, ?, | ||||
|         ?,    ? | ||||
|       )`;
 | ||||
| 
 | ||||
|       const params: any[] = [ | ||||
|         block.height, | ||||
|         blockHash, | ||||
|         block.id, | ||||
|         block.timestamp, | ||||
|         block.size, | ||||
|         block.weight, | ||||
|         block.tx_count, | ||||
|         coinbaseHex ? coinbaseHex : '', | ||||
|         '', | ||||
|         block.difficulty, | ||||
|         poolTag.id, | ||||
|         block.extras.pool?.id, // Should always be set to something
 | ||||
|         0, | ||||
|         '[]', | ||||
|         block.extras ? block.extras.medianFee : 0, | ||||
|         block.extras.medianFee ?? 0, | ||||
|         block.extras.reward ?? 0, | ||||
|         block.version, | ||||
|         block.bits, | ||||
|         block.nonce, | ||||
|         block.merkle_root, | ||||
|         block.previousblockhash | ||||
|       ]; | ||||
| 
 | ||||
|       // logger.debug(query);
 | ||||
|       await connection.query(query, params); | ||||
|     } catch (e: any) { | ||||
|       if (e.errno === 1062) { // ER_DUP_ENTRY
 | ||||
| @ -66,35 +73,45 @@ class BlocksRepository { | ||||
|     } | ||||
| 
 | ||||
|     const connection = await DB.pool.getConnection(); | ||||
|     const [rows] : any[] = await connection.query(` | ||||
|     const [rows]: any[] = await connection.query(` | ||||
|       SELECT height | ||||
|       FROM blocks | ||||
|       WHERE height <= ${startHeight} AND height >= ${endHeight} | ||||
|       WHERE height <= ? AND height >= ? | ||||
|       ORDER BY height DESC; | ||||
|     `);
 | ||||
|     `, [startHeight, endHeight]);
 | ||||
|     connection.release(); | ||||
| 
 | ||||
|     const indexedBlockHeights: number[] = []; | ||||
|     rows.forEach((row: any) => { indexedBlockHeights.push(row.height); }); | ||||
|     const seekedBlocks: number[] = Array.from(Array(startHeight - endHeight + 1).keys(), n => n + endHeight).reverse(); | ||||
|     const missingBlocksHeights =  seekedBlocks.filter(x => indexedBlockHeights.indexOf(x) === -1); | ||||
|     const missingBlocksHeights = seekedBlocks.filter(x => indexedBlockHeights.indexOf(x) === -1); | ||||
| 
 | ||||
|     return missingBlocksHeights; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Count empty blocks for all pools | ||||
|    * Get empty blocks for one or all pools | ||||
|    */ | ||||
|   public async $countEmptyBlocks(interval: string | null): Promise<EmptyBlocks[]> { | ||||
|     const query = ` | ||||
|       SELECT pool_id as poolId | ||||
|       FROM blocks | ||||
|       WHERE tx_count = 1` +
 | ||||
|       (interval != null ? ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()` : ``) | ||||
|     ; | ||||
|   public async $getEmptyBlocks(poolId: number | null, interval: string | null = null): Promise<EmptyBlocks[]> { | ||||
|     interval = Common.getSqlInterval(interval); | ||||
| 
 | ||||
|     const params: any[] = []; | ||||
|     let query = `SELECT height, hash, tx_count, size, pool_id, weight, UNIX_TIMESTAMP(blockTimestamp) as timestamp
 | ||||
|       FROM blocks | ||||
|       WHERE tx_count = 1`;
 | ||||
| 
 | ||||
|     if (poolId) { | ||||
|       query += ` AND pool_id = ?`; | ||||
|       params.push(poolId); | ||||
|     } | ||||
| 
 | ||||
|     if (interval) { | ||||
|       query += ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; | ||||
|     } | ||||
| 
 | ||||
|     // logger.debug(query);
 | ||||
|     const connection = await DB.pool.getConnection(); | ||||
|     const [rows] = await connection.query(query); | ||||
|     const [rows] = await connection.query(query, params); | ||||
|     connection.release(); | ||||
| 
 | ||||
|     return <EmptyBlocks[]>rows; | ||||
| @ -103,15 +120,30 @@ class BlocksRepository { | ||||
|   /** | ||||
|    * Get blocks count for a period | ||||
|    */ | ||||
|    public async $blockCount(interval: string | null): Promise<number> { | ||||
|     const query = ` | ||||
|       SELECT count(height) as blockCount | ||||
|       FROM blocks` +
 | ||||
|       (interval != null ? ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()` : ``) | ||||
|     ; | ||||
|   public async $blockCount(poolId: number | null, interval: string | null): Promise<number> { | ||||
|     interval = Common.getSqlInterval(interval); | ||||
| 
 | ||||
|     const params: any[] = []; | ||||
|     let query = `SELECT count(height) as blockCount
 | ||||
|       FROM blocks`;
 | ||||
| 
 | ||||
|     if (poolId) { | ||||
|       query += ` WHERE pool_id = ?`; | ||||
|       params.push(poolId); | ||||
|     } | ||||
| 
 | ||||
|     if (interval) { | ||||
|       if (poolId) { | ||||
|         query += ` AND`; | ||||
|       } else { | ||||
|         query += ` WHERE`; | ||||
|       } | ||||
|       query += ` blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; | ||||
|     } | ||||
| 
 | ||||
|     // logger.debug(query);
 | ||||
|     const connection = await DB.pool.getConnection(); | ||||
|     const [rows] = await connection.query(query); | ||||
|     const [rows] = await connection.query(query, params); | ||||
|     connection.release(); | ||||
| 
 | ||||
|     return <number>rows[0].blockCount; | ||||
| @ -121,13 +153,15 @@ class BlocksRepository { | ||||
|    * Get the oldest indexed block | ||||
|    */ | ||||
|   public async $oldestBlockTimestamp(): Promise<number> { | ||||
|     const connection = await DB.pool.getConnection(); | ||||
|     const [rows]: any[] = await connection.query(` | ||||
|       SELECT blockTimestamp | ||||
|     const query = `SELECT blockTimestamp
 | ||||
|       FROM blocks | ||||
|       ORDER BY height | ||||
|       LIMIT 1; | ||||
|     `);
 | ||||
|       LIMIT 1;`;
 | ||||
| 
 | ||||
| 
 | ||||
|     // logger.debug(query);
 | ||||
|     const connection = await DB.pool.getConnection(); | ||||
|     const [rows]: any[] = await connection.query(query); | ||||
|     connection.release(); | ||||
| 
 | ||||
|     if (rows.length <= 0) { | ||||
| @ -136,6 +170,83 @@ class BlocksRepository { | ||||
| 
 | ||||
|     return <number>rows[0].blockTimestamp; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get blocks mined by a specific mining pool | ||||
|    */ | ||||
|   public async $getBlocksByPool( | ||||
|     poolId: number, | ||||
|     startHeight: number | null = null | ||||
|   ): Promise<object[]> { | ||||
|     const params: any[] = []; | ||||
|     let query = `SELECT height, hash as id, tx_count, size, weight, pool_id, UNIX_TIMESTAMP(blockTimestamp) as timestamp, reward
 | ||||
|       FROM blocks | ||||
|       WHERE pool_id = ?`;
 | ||||
|     params.push(poolId); | ||||
| 
 | ||||
|     if (startHeight) { | ||||
|       query += ` AND height < ?`; | ||||
|       params.push(startHeight); | ||||
|     } | ||||
| 
 | ||||
|     query += ` ORDER BY height DESC
 | ||||
|       LIMIT 10`;
 | ||||
| 
 | ||||
|     // logger.debug(query);
 | ||||
|     const connection = await DB.pool.getConnection(); | ||||
|     const [rows] = await connection.query(query, params); | ||||
|     connection.release(); | ||||
| 
 | ||||
|     for (const block of <object[]>rows) { | ||||
|       delete block['blockTimestamp']; | ||||
|     } | ||||
| 
 | ||||
|     return <object[]>rows; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get one block by height | ||||
|    */ | ||||
|    public async $getBlockByHeight(height: number): Promise<object | null> { | ||||
|     const connection = await DB.pool.getConnection(); | ||||
|     const [rows]: any[] = await connection.query(` | ||||
|       SELECT *, UNIX_TIMESTAMP(blocks.blockTimestamp) as blockTimestamp, pools.id as pool_id, pools.name as pool_name, pools.link as pool_link, pools.addresses as pool_addresses, pools.regexes as pool_regexes | ||||
|       FROM blocks | ||||
|       JOIN pools ON blocks.pool_id = pools.id | ||||
|       WHERE height = ${height}; | ||||
|     `);
 | ||||
|     connection.release(); | ||||
| 
 | ||||
|     if (rows.length <= 0) { | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     return rows[0]; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Return blocks difficulty | ||||
|    */ | ||||
|    public async $getBlocksDifficulty(interval: string | null): Promise<object[]> { | ||||
|     interval = Common.getSqlInterval(interval); | ||||
| 
 | ||||
|     const connection = await DB.pool.getConnection(); | ||||
| 
 | ||||
|     let query = `SELECT MIN(UNIX_TIMESTAMP(blockTimestamp)) as timestamp, difficulty, height
 | ||||
|       FROM blocks`;
 | ||||
| 
 | ||||
|     if (interval) { | ||||
|       query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; | ||||
|     } | ||||
| 
 | ||||
|     query += ` GROUP BY difficulty
 | ||||
|       ORDER BY blockTimestamp DESC`;
 | ||||
| 
 | ||||
|     const [rows]: any[] = await connection.query(query); | ||||
|     connection.release(); | ||||
| 
 | ||||
|     return rows; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new BlocksRepository(); | ||||
| export default new BlocksRepository(); | ||||
|  | ||||
| @ -1,4 +1,6 @@ | ||||
| import { Common } from '../api/common'; | ||||
| import { DB } from '../database'; | ||||
| import logger from '../logger'; | ||||
| import { PoolInfo, PoolTag } from '../mempool.interfaces'; | ||||
| 
 | ||||
| class PoolsRepository { | ||||
| @ -7,7 +9,7 @@ class PoolsRepository { | ||||
|    */ | ||||
|   public async $getPools(): Promise<PoolTag[]> { | ||||
|     const connection = await DB.pool.getConnection(); | ||||
|     const [rows] = await connection.query('SELECT * FROM pools;'); | ||||
|     const [rows] = await connection.query('SELECT id, name, addresses, regexes FROM pools;'); | ||||
|     connection.release(); | ||||
|     return <PoolTag[]>rows; | ||||
|   } | ||||
| @ -17,7 +19,7 @@ class PoolsRepository { | ||||
|    */ | ||||
|   public async $getUnknownPool(): Promise<PoolTag> { | ||||
|     const connection = await DB.pool.getConnection(); | ||||
|     const [rows] = await connection.query('SELECT * FROM pools where name = "Unknown"'); | ||||
|     const [rows] = await connection.query('SELECT id, name FROM pools where name = "Unknown"'); | ||||
|     connection.release(); | ||||
|     return <PoolTag>rows[0]; | ||||
|   } | ||||
| @ -25,22 +27,47 @@ class PoolsRepository { | ||||
|   /** | ||||
|    * Get basic pool info and block count | ||||
|    */ | ||||
|   public async $getPoolsInfo(interval: string | null): Promise<PoolInfo[]> { | ||||
|     const query = ` | ||||
|       SELECT COUNT(height) as blockCount, pool_id as poolId, pools.name as name, pools.link as link | ||||
|       FROM blocks | ||||
|       JOIN pools on pools.id = pool_id` +
 | ||||
|       (interval != null ? ` WHERE blocks.blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()` : ``) + | ||||
|       ` GROUP BY pool_id
 | ||||
|       ORDER BY COUNT(height) DESC | ||||
|     `;
 | ||||
|   public async $getPoolsInfo(interval: string | null = null): Promise<PoolInfo[]> { | ||||
|     interval = Common.getSqlInterval(interval); | ||||
| 
 | ||||
|     let query = `SELECT COUNT(height) as blockCount, pool_id as poolId, pools.name as name, pools.link as link
 | ||||
|       FROM blocks | ||||
|       JOIN pools on pools.id = pool_id`;
 | ||||
| 
 | ||||
|     if (interval) { | ||||
|       query += ` WHERE blocks.blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; | ||||
|     } | ||||
| 
 | ||||
|     query += ` GROUP BY pool_id
 | ||||
|       ORDER BY COUNT(height) DESC`;
 | ||||
| 
 | ||||
|     // logger.debug(query);
 | ||||
|     const connection = await DB.pool.getConnection(); | ||||
|     const [rows] = await connection.query(query); | ||||
|     connection.release(); | ||||
| 
 | ||||
|     return <PoolInfo[]>rows; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get mining pool statistics for one pool | ||||
|    */ | ||||
|    public async $getPool(poolId: any): Promise<object> { | ||||
|     const query = ` | ||||
|       SELECT * | ||||
|       FROM pools | ||||
|       WHERE pools.id = ?`;
 | ||||
| 
 | ||||
|     // logger.debug(query);
 | ||||
|     const connection = await DB.pool.getConnection(); | ||||
|     const [rows] = await connection.query(query, [poolId]); | ||||
|     connection.release(); | ||||
| 
 | ||||
|     rows[0].regexes = JSON.parse(rows[0].regexes); | ||||
|     rows[0].addresses = JSON.parse(rows[0].addresses); | ||||
| 
 | ||||
|     return rows[0]; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new PoolsRepository(); | ||||
|  | ||||
| @ -22,6 +22,9 @@ import elementsParser from './api/liquid/elements-parser'; | ||||
| import icons from './api/liquid/icons'; | ||||
| import miningStats from './api/mining'; | ||||
| import axios from 'axios'; | ||||
| import PoolsRepository from './repositories/PoolsRepository'; | ||||
| import mining from './api/mining'; | ||||
| import BlocksRepository from './repositories/BlocksRepository'; | ||||
| 
 | ||||
| class Routes { | ||||
|   constructor() {} | ||||
| @ -533,9 +536,9 @@ class Routes { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getPools(interval: string, req: Request, res: Response) { | ||||
|   public async $getPool(req: Request, res: Response) { | ||||
|     try { | ||||
|       let stats = await miningStats.$getPoolsStats(interval); | ||||
|       const stats = await mining.$getPoolStat(req.params.interval ?? null, parseInt(req.params.poolId, 10)); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); | ||||
| @ -545,6 +548,45 @@ class Routes { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getPoolBlocks(req: Request, res: Response) { | ||||
|     try { | ||||
|       const poolBlocks = await BlocksRepository.$getBlocksByPool( | ||||
|         parseInt(req.params.poolId, 10), | ||||
|         parseInt(req.params.height, 10) ?? null, | ||||
|       ); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); | ||||
|       res.json(poolBlocks); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getPools(interval: string, req: Request, res: Response) { | ||||
|     try { | ||||
|       const stats = await miningStats.$getPoolsStats(interval); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); | ||||
|       res.json(stats); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getHistoricalDifficulty(req: Request, res: Response) { | ||||
|     try { | ||||
|       const stats = await mining.$getHistoricalDifficulty(req.params.interval ?? null); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); | ||||
|       res.json(stats); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async getBlock(req: Request, res: Response) { | ||||
|     try { | ||||
|       const result = await bitcoinApi.$getBlock(req.params.hash); | ||||
| @ -564,6 +606,14 @@ class Routes { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async getBlocksExtras(req: Request, res: Response) { | ||||
|     try { | ||||
|       res.json(await blocks.$getBlocksExtras(parseInt(req.params.height, 10))) | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   public async getBlocks(req: Request, res: Response) { | ||||
|     try { | ||||
|       loadingIndicators.setProgress('blocks', 0); | ||||
| @ -691,7 +741,13 @@ class Routes { | ||||
|   } | ||||
| 
 | ||||
|   public async getMempool(req: Request, res: Response) { | ||||
|     res.status(501).send('Not implemented'); | ||||
|     const info = mempool.getMempoolInfo(); | ||||
|     res.json({ | ||||
|       count: info.size, | ||||
|       vsize: info.bytes, | ||||
|       total_fee: info.total_fee * 1e8, | ||||
|       fee_histogram: [] | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   public async getMempoolTxIds(req: Request, res: Response) { | ||||
|  | ||||
| @ -15,7 +15,8 @@ | ||||
|     "PRICE_FEED_UPDATE_INTERVAL": __MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__, | ||||
|     "USE_SECOND_NODE_FOR_MINFEE": __MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__, | ||||
|     "EXTERNAL_ASSETS": __MEMPOOL_EXTERNAL_ASSETS__, | ||||
|     "STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__" | ||||
|     "STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__", | ||||
|     "INDEXING_BLOCKS_AMOUNT": __MEMPOOL_INDEXING_BLOCKS_AMOUNT__ | ||||
|   }, | ||||
|   "CORE_RPC": { | ||||
|     "HOST": "__CORE_RPC_HOST__", | ||||
|  | ||||
| @ -274,113 +274,19 @@ describe('Mainnet', () => { | ||||
|             }); | ||||
|           }); | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
| 
 | ||||
|     it('loads skeleton when changes between networks', () => { | ||||
|       cy.visit('/'); | ||||
|       cy.waitForSkeletonGone(); | ||||
| 
 | ||||
|       cy.changeNetwork("testnet"); | ||||
|       cy.changeNetwork("signet"); | ||||
|       cy.changeNetwork("mainnet"); | ||||
|     }); | ||||
| 
 | ||||
|     it.skip('loads the dashboard with the skeleton blocks', () => { | ||||
|       cy.mockMempoolSocket(); | ||||
|       cy.visit("/"); | ||||
|       cy.get(':nth-child(1) > #bitcoin-block-0').should('be.visible'); | ||||
|       cy.get(':nth-child(2) > #bitcoin-block-0').should('be.visible'); | ||||
|       cy.get(':nth-child(3) > #bitcoin-block-0').should('be.visible'); | ||||
|       cy.get('#mempool-block-0').should('be.visible'); | ||||
|       cy.get('#mempool-block-1').should('be.visible'); | ||||
|       cy.get('#mempool-block-2').should('be.visible'); | ||||
| 
 | ||||
|       emitMempoolInfo({ | ||||
|         'params': { | ||||
|           command: 'init' | ||||
|         } | ||||
|       }); | ||||
| 
 | ||||
|       cy.get(':nth-child(1) > #bitcoin-block-0').should('not.exist'); | ||||
|       cy.get(':nth-child(2) > #bitcoin-block-0').should('not.exist'); | ||||
|       cy.get(':nth-child(3) > #bitcoin-block-0').should('not.exist'); | ||||
|     }); | ||||
| 
 | ||||
|     it('loads the pools screen', () => { | ||||
|       cy.visit('/'); | ||||
|       cy.waitForSkeletonGone(); | ||||
|       cy.get('#btn-pools').click().then(() => { | ||||
|         cy.waitForPageIdle(); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('loads the graphs screen', () => { | ||||
|       cy.visit('/'); | ||||
|       cy.waitForSkeletonGone(); | ||||
|       cy.get('#btn-graphs').click().then(() => { | ||||
|         cy.wait(1000); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('graphs page', () => { | ||||
|       it('check buttons - mobile', () => { | ||||
|         cy.viewport('iphone-6'); | ||||
|         cy.visit('/graphs'); | ||||
|         cy.waitForSkeletonGone(); | ||||
|         cy.get('.small-buttons > :nth-child(2)').should('be.visible'); | ||||
|         cy.get('#dropdownFees').should('be.visible'); | ||||
|         cy.get('.btn-group').should('be.visible'); | ||||
|       }); | ||||
|       it('check buttons - tablet', () => { | ||||
|         cy.viewport('ipad-2'); | ||||
|         cy.visit('/graphs'); | ||||
|         cy.waitForSkeletonGone(); | ||||
|         cy.get('.small-buttons > :nth-child(2)').should('be.visible'); | ||||
|         cy.get('#dropdownFees').should('be.visible'); | ||||
|         cy.get('.btn-group').should('be.visible'); | ||||
|       }); | ||||
|       it('check buttons - desktop', () => { | ||||
|         cy.viewport('macbook-16'); | ||||
|         cy.visit('/graphs'); | ||||
|         cy.waitForSkeletonGone(); | ||||
|         cy.get('.small-buttons > :nth-child(2)').should('be.visible'); | ||||
|         cy.get('#dropdownFees').should('be.visible'); | ||||
|         cy.get('.btn-group').should('be.visible'); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('loads the tv screen - desktop', () => { | ||||
|       cy.viewport('macbook-16'); | ||||
|       cy.visit('/'); | ||||
|       cy.waitForSkeletonGone(); | ||||
|       cy.get('#btn-tv').click().then(() => { | ||||
|         cy.viewport('macbook-16'); | ||||
|         cy.get('.chart-holder'); | ||||
|         cy.get('.blockchain-wrapper').should('be.visible'); | ||||
|         cy.get('#mempool-block-0').should('be.visible'); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('loads the tv screen - mobile', () => { | ||||
|       cy.viewport('iphone-6'); | ||||
|       cy.visit('/tv'); | ||||
|       cy.waitForSkeletonGone(); | ||||
|       cy.get('.chart-holder'); | ||||
|       cy.get('.blockchain-wrapper').should('not.visible'); | ||||
|     }); | ||||
| 
 | ||||
|     it('loads genesis block and click on the arrow left', () => { | ||||
|       cy.viewport('macbook-16'); | ||||
|       cy.visit('/block/0'); | ||||
|       cy.waitForSkeletonGone(); | ||||
|       cy.waitForPageIdle(); | ||||
|       cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible'); | ||||
|       cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('not.exist'); | ||||
|       cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').click().then(() => { | ||||
|         cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible'); | ||||
|         cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible'); | ||||
|         it('loads genesis block and click on the arrow left', () => { | ||||
|           cy.viewport('macbook-16'); | ||||
|           cy.visit('/block/0'); | ||||
|           cy.waitForSkeletonGone(); | ||||
|           cy.waitForPageIdle(); | ||||
|           cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible'); | ||||
|           cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('not.exist'); | ||||
|           cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').click().then(() => { | ||||
|             cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible'); | ||||
|             cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible'); | ||||
|           }); | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|  | ||||
| @ -15,5 +15,6 @@ | ||||
|   "BASE_MODULE": "mempool", | ||||
|   "MEMPOOL_WEBSITE_URL": "https://mempool.space", | ||||
|   "LIQUID_WEBSITE_URL": "https://liquid.network", | ||||
|   "BISQ_WEBSITE_URL": "https://bisq.markets" | ||||
|   "BISQ_WEBSITE_URL": "https://bisq.markets", | ||||
|   "MINING_DASHBOARD": true | ||||
| } | ||||
|  | ||||
| @ -66,6 +66,7 @@ export function app(locale: string): express.Express { | ||||
|   server.get('/address/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/blocks', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/mining/pools', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/mining/pool/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/graphs', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid/tx/*', getLocalizedSSR(indexHtml)); | ||||
|  | ||||
| @ -26,6 +26,9 @@ import { PoolRankingComponent } from './components/pool-ranking/pool-ranking.com | ||||
| import { AssetGroupComponent } from './components/assets/asset-group/asset-group.component'; | ||||
| import { AssetsFeaturedComponent } from './components/assets/assets-featured/assets-featured.component'; | ||||
| import { AssetsComponent } from './components/assets/assets.component'; | ||||
| import { PoolComponent } from './components/pool/pool.component'; | ||||
| import { MiningDashboardComponent } from './components/mining-dashboard/mining-dashboard.component'; | ||||
| import { DifficultyChartComponent } from './components/difficulty-chart/difficulty-chart.component'; | ||||
| 
 | ||||
| let routes: Routes = [ | ||||
|   { | ||||
| @ -56,16 +59,28 @@ let routes: Routes = [ | ||||
|             path: 'mempool-block/:id', | ||||
|             component: MempoolBlockComponent | ||||
|           }, | ||||
|           { | ||||
|             path: 'mining', | ||||
|             component: MiningDashboardComponent, | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|       { | ||||
|         path: 'blocks', | ||||
|         component: LatestBlocksComponent, | ||||
|       }, | ||||
|       { | ||||
|         path: 'mining/difficulty', | ||||
|         component: DifficultyChartComponent, | ||||
|       }, | ||||
|       { | ||||
|         path: 'mining/pools', | ||||
|         component: PoolRankingComponent, | ||||
|       }, | ||||
|       { | ||||
|         path: 'mining/pool/:poolId', | ||||
|         component: PoolComponent, | ||||
|       }, | ||||
|       { | ||||
|         path: 'graphs', | ||||
|         component: StatisticsComponent, | ||||
| @ -144,16 +159,28 @@ let routes: Routes = [ | ||||
|                 path: 'mempool-block/:id', | ||||
|                 component: MempoolBlockComponent | ||||
|               }, | ||||
|               { | ||||
|                 path: 'mining', | ||||
|                 component: MiningDashboardComponent, | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
|           { | ||||
|             path: 'blocks', | ||||
|             component: LatestBlocksComponent, | ||||
|           }, | ||||
|           { | ||||
|             path: 'mining/difficulty', | ||||
|             component: DifficultyChartComponent, | ||||
|           }, | ||||
|           { | ||||
|             path: 'mining/pools', | ||||
|             component: PoolRankingComponent, | ||||
|           }, | ||||
|           { | ||||
|             path: 'mining/pool/:poolId', | ||||
|             component: PoolComponent, | ||||
|           }, | ||||
|           { | ||||
|             path: 'graphs', | ||||
|             component: StatisticsComponent, | ||||
| @ -226,16 +253,28 @@ let routes: Routes = [ | ||||
|                 path: 'mempool-block/:id', | ||||
|                 component: MempoolBlockComponent | ||||
|               }, | ||||
|               { | ||||
|                 path: 'mining', | ||||
|                 component: MiningDashboardComponent, | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
|           { | ||||
|             path: 'blocks', | ||||
|             component: LatestBlocksComponent, | ||||
|           }, | ||||
|           { | ||||
|             path: 'mining/difficulty', | ||||
|             component: DifficultyChartComponent, | ||||
|           }, | ||||
|           { | ||||
|             path: 'mining/pools', | ||||
|             component: PoolRankingComponent, | ||||
|           }, | ||||
|           { | ||||
|             path: 'mining/pool/:poolId', | ||||
|             component: PoolComponent, | ||||
|           }, | ||||
|           { | ||||
|             path: 'graphs', | ||||
|             component: StatisticsComponent, | ||||
|  | ||||
| @ -38,6 +38,7 @@ import { TimeSpanComponent } from './components/time-span/time-span.component'; | ||||
| import { SeoService } from './services/seo.service'; | ||||
| import { MempoolGraphComponent } from './components/mempool-graph/mempool-graph.component'; | ||||
| import { PoolRankingComponent } from './components/pool-ranking/pool-ranking.component'; | ||||
| import { PoolComponent } from './components/pool/pool.component'; | ||||
| import { LbtcPegsGraphComponent } from './components/lbtc-pegs-graph/lbtc-pegs-graph.component'; | ||||
| import { AssetComponent } from './components/asset/asset.component'; | ||||
| import { AssetsComponent } from './components/assets/assets.component'; | ||||
| @ -67,6 +68,8 @@ import { PushTransactionComponent } from './components/push-transaction/push-tra | ||||
| import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { AssetsFeaturedComponent } from './components/assets/assets-featured/assets-featured.component'; | ||||
| import { AssetGroupComponent } from './components/assets/asset-group/asset-group.component'; | ||||
| import { MiningDashboardComponent } from './components/mining-dashboard/mining-dashboard.component'; | ||||
| import { DifficultyChartComponent } from './components/difficulty-chart/difficulty-chart.component'; | ||||
| 
 | ||||
| @NgModule({ | ||||
|   declarations: [ | ||||
| @ -96,6 +99,7 @@ import { AssetGroupComponent } from './components/assets/asset-group/asset-group | ||||
|     IncomingTransactionsGraphComponent, | ||||
|     MempoolGraphComponent, | ||||
|     PoolRankingComponent, | ||||
|     PoolComponent, | ||||
|     LbtcPegsGraphComponent, | ||||
|     AssetComponent, | ||||
|     AssetsComponent, | ||||
| @ -116,6 +120,8 @@ import { AssetGroupComponent } from './components/assets/asset-group/asset-group | ||||
|     AssetsNavComponent, | ||||
|     AssetsFeaturedComponent, | ||||
|     AssetGroupComponent, | ||||
|     MiningDashboardComponent, | ||||
|     DifficultyChartComponent, | ||||
|   ], | ||||
|   imports: [ | ||||
|     BrowserModule.withServerTransition({ appId: 'serverApp' }), | ||||
|  | ||||
| @ -21,9 +21,13 @@ | ||||
|         </div> | ||||
|         <div class="time-difference"><app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since></div> | ||||
|       </div> | ||||
|     <div class="" *ngIf="showMiningInfo === true"> | ||||
|       <a class="badge badge-primary" [routerLink]="[('/mining/pool/' + block.extras.pool.id) | relativeUrl]"> | ||||
|         {{ block.extras.pool.name}}</a> | ||||
|     </div> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div [hidden]="!arrowVisible" id="arrow-up" [style.transition]="transition" [ngStyle]="{'left': arrowLeftPx + 'px' }"></div> | ||||
| <div [hidden]="!arrowVisible" id="arrow-up" [style.transition]="transition" [ngStyle]="{'left': arrowLeftPx + 'px' }"></div> | ||||
| </div> | ||||
| 
 | ||||
| <ng-template #loadingBlocksTemplate> | ||||
|  | ||||
| @ -124,3 +124,9 @@ | ||||
|   50% {opacity: 1.0;} | ||||
|   100% {opacity: 0.7;} | ||||
| } | ||||
| 
 | ||||
| .badge { | ||||
|   position: relative; | ||||
|   top: 15px; | ||||
|   z-index: 101; | ||||
| } | ||||
| @ -1,4 +1,4 @@ | ||||
| import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; | ||||
| import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, Input } from '@angular/core'; | ||||
| import { Observable, Subscription } from 'rxjs'; | ||||
| import { StateService } from 'src/app/services/state.service'; | ||||
| import { Router } from '@angular/router'; | ||||
| @ -12,6 +12,7 @@ import { BlockExtended } from 'src/app/interfaces/node-api.interface'; | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class BlockchainBlocksComponent implements OnInit, OnDestroy { | ||||
|   @Input() showMiningInfo: boolean = false; | ||||
|   specialBlocks = specialBlocks; | ||||
|   network = ''; | ||||
|   blocks: BlockExtended[] = []; | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| <div class="text-center" class="blockchain-wrapper"> | ||||
| <div class="text-center" class="blockchain-wrapper animate" #container> | ||||
|   <div class="position-container {{ network }}"> | ||||
|     <span> | ||||
|       <app-mempool-blocks></app-mempool-blocks> | ||||
|       <app-blockchain-blocks></app-blockchain-blocks> | ||||
|       <app-blockchain-blocks [showMiningInfo]="showMiningInfo"></app-blockchain-blocks> | ||||
|       <div id="divider"></div> | ||||
|     </span> | ||||
|   </div> | ||||
|  | ||||
| @ -16,7 +16,6 @@ | ||||
| } | ||||
| 
 | ||||
| .blockchain-wrapper { | ||||
|   overflow: hidden; | ||||
|   height: 250px; | ||||
| 
 | ||||
|   -webkit-user-select: none; /* Safari */         | ||||
| @ -60,4 +59,14 @@ | ||||
|   width: 300px; | ||||
|   left: -150px; | ||||
|   top: 0px; | ||||
| } | ||||
| } | ||||
| 
 | ||||
| .animate { | ||||
|   transition: all 1s ease-in-out; | ||||
| } | ||||
| .move-left { | ||||
|   transform: translate(-40%, 0); | ||||
| 	@media (max-width: 767.98px) { | ||||
|     transform: translate(-85%, 0); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -8,10 +8,11 @@ import { StateService } from 'src/app/services/state.service'; | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class BlockchainComponent implements OnInit { | ||||
|   showMiningInfo: boolean = false; | ||||
|   network: string; | ||||
| 
 | ||||
|   constructor( | ||||
|     private stateService: StateService, | ||||
|     public stateService: StateService, | ||||
|   ) {} | ||||
| 
 | ||||
|   ngOnInit() { | ||||
|  | ||||
| @ -0,0 +1,53 @@ | ||||
| <div [class]="widget === false ? 'container-xl' : ''"> | ||||
| 
 | ||||
|   <div *ngIf="difficultyObservable$ | async" class="" echarts [initOpts]="chartInitOptions" [options]="chartOptions"></div> | ||||
|   <div class="text-center loadingGraphs" *ngIf="isLoading"> | ||||
|     <div class="spinner-border text-light"></div> | ||||
|   </div> | ||||
| 
 | ||||
|   <div class="card-header mb-0 mb-lg-4" [style]="widget ? 'display:none' : ''"> | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(difficultyObservable$ | async) as diffChanges"> | ||||
|       <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" [routerLink]="['/mining/difficulty' | relativeUrl]" *ngIf="diffChanges.availableTimespanDay >= 90"> | ||||
|           <input ngbButton type="radio" [value]="'3m'" fragment="3m"> 3M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" [routerLink]="['/mining/difficulty' | relativeUrl]" *ngIf="diffChanges.availableTimespanDay >= 180"> | ||||
|           <input ngbButton type="radio" [value]="'6m'" fragment="6m"> 6M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" [routerLink]="['/mining/difficulty' | relativeUrl]" *ngIf="diffChanges.availableTimespanDay >= 365"> | ||||
|           <input ngbButton type="radio" [value]="'1y'" fragment="1y"> 1Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" [routerLink]="['/mining/difficulty' | relativeUrl]" *ngIf="diffChanges.availableTimespanDay >= 730"> | ||||
|           <input ngbButton type="radio" [value]="'2y'" fragment="2y"> 2Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" [routerLink]="['/mining/difficulty' | relativeUrl]" *ngIf="diffChanges.availableTimespanDay >= 1095"> | ||||
|           <input ngbButton type="radio" [value]="'3y'" fragment="3y"> 3Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|           <input ngbButton type="radio" [value]="'all'" [routerLink]="['/mining/difficulty' | relativeUrl]" fragment="all"> ALL | ||||
|         </label> | ||||
|       </div> | ||||
|     </form> | ||||
|   </div> | ||||
| 
 | ||||
|   <table class="table table-borderless table-sm text-center" *ngIf="!widget"> | ||||
|     <thead> | ||||
|       <tr> | ||||
|         <th i18n="mining.rank">Block</th> | ||||
|         <th i18n="block.timestamp">Timestamp</th> | ||||
|         <th i18n="mining.difficulty">Difficulty</th> | ||||
|         <th i18n="mining.change">Change</th> | ||||
|       </tr> | ||||
|     </thead> | ||||
|     <tbody *ngIf="(difficultyObservable$ | async) as diffChanges"> | ||||
|       <tr *ngFor="let diffChange of diffChanges.data"> | ||||
|         <td><a [routerLink]="['/block' | relativeUrl, diffChange.height]">{{ diffChange.height }}</a></td> | ||||
|         <td>‎{{ diffChange.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}</td> | ||||
|         <td class="d-none d-md-block">{{ formatNumber(diffChange.difficulty, locale, '1.2-2') }}</td> | ||||
|         <td class="d-block d-md-none">{{ diffChange.difficultyShorten }}</td> | ||||
|         <td [style]="diffChange.change >= 0 ? 'color: #42B747' : 'color: #B74242'">{{ formatNumber(diffChange.change, locale, '1.2-2') }}%</td> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   </table> | ||||
| 
 | ||||
| </div> | ||||
| @ -0,0 +1,10 @@ | ||||
| .main-title { | ||||
|   position: relative; | ||||
|   color: #ffffff91; | ||||
|   margin-top: -13px; | ||||
|   font-size: 10px; | ||||
|   text-transform: uppercase; | ||||
|   font-weight: 500; | ||||
|   text-align: center; | ||||
|   padding-bottom: 3px; | ||||
| } | ||||
| @ -0,0 +1,154 @@ | ||||
| import { Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; | ||||
| import { EChartsOption } from 'echarts'; | ||||
| import { Observable } from 'rxjs'; | ||||
| import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; | ||||
| import { ApiService } from 'src/app/services/api.service'; | ||||
| import { SeoService } from 'src/app/services/seo.service'; | ||||
| import { formatNumber } from '@angular/common'; | ||||
| import { FormBuilder, FormGroup } from '@angular/forms'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-difficulty-chart', | ||||
|   templateUrl: './difficulty-chart.component.html', | ||||
|   styleUrls: ['./difficulty-chart.component.scss'], | ||||
|   styles: [` | ||||
|     .loadingGraphs { | ||||
|       position: absolute; | ||||
|       top: 38%; | ||||
|       left: calc(50% - 15px); | ||||
|       z-index: 100; | ||||
|     } | ||||
|   `],
 | ||||
| }) | ||||
| export class DifficultyChartComponent implements OnInit { | ||||
|   @Input() widget: boolean = false; | ||||
| 
 | ||||
|   radioGroupForm: FormGroup; | ||||
| 
 | ||||
|   chartOptions: EChartsOption = {}; | ||||
|   chartInitOptions = { | ||||
|     renderer: 'svg' | ||||
|   }; | ||||
| 
 | ||||
|   difficultyObservable$: Observable<any>; | ||||
|   isLoading = true; | ||||
|   formatNumber = formatNumber; | ||||
| 
 | ||||
|   constructor( | ||||
|     @Inject(LOCALE_ID) public locale: string, | ||||
|     private seoService: SeoService, | ||||
|     private apiService: ApiService, | ||||
|     private formBuilder: FormBuilder, | ||||
|   ) { | ||||
|     this.seoService.setTitle($localize`:@@mining.difficulty:Difficulty`); | ||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); | ||||
|     this.radioGroupForm.controls.dateSpan.setValue('1y'); | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     const powerOfTen = { | ||||
|       terra: Math.pow(10, 12), | ||||
|       giga: Math.pow(10, 9), | ||||
|       mega: Math.pow(10, 6), | ||||
|       kilo: Math.pow(10, 3), | ||||
|     } | ||||
| 
 | ||||
|     this.difficultyObservable$ = this.radioGroupForm.get('dateSpan').valueChanges | ||||
|       .pipe( | ||||
|         startWith('1y'), | ||||
|         switchMap((timespan) => { | ||||
|           return this.apiService.getHistoricalDifficulty$(timespan) | ||||
|             .pipe( | ||||
|               tap(data => { | ||||
|                 this.prepareChartOptions(data.adjustments.map(val => [val.timestamp * 1000, val.difficulty])); | ||||
|                 this.isLoading = false; | ||||
|               }), | ||||
|               map(data => { | ||||
|                 const availableTimespanDay = ( | ||||
|                   (new Date().getTime() / 1000) - (data.oldestIndexedBlockTimestamp / 1000) | ||||
|                 ) / 3600 / 24; | ||||
| 
 | ||||
|                 const tableData = []; | ||||
|                 for (let i = 0; i < data.adjustments.length - 1; ++i) { | ||||
|                   const change = (data.adjustments[i].difficulty / data.adjustments[i + 1].difficulty - 1) * 100; | ||||
|                   let selectedPowerOfTen = { divider: powerOfTen.terra, unit: 'T' }; | ||||
|                   if (data.adjustments[i].difficulty < powerOfTen.mega) { | ||||
|                     selectedPowerOfTen = { divider: 1, unit: '' }; // no scaling
 | ||||
|                   } else if (data.adjustments[i].difficulty < powerOfTen.giga) { | ||||
|                     selectedPowerOfTen = { divider: powerOfTen.mega, unit: 'M' }; | ||||
|                   } else if (data.adjustments[i].difficulty < powerOfTen.terra) { | ||||
|                     selectedPowerOfTen = { divider: powerOfTen.giga, unit: 'G' }; | ||||
|                   } | ||||
| 
 | ||||
|                   tableData.push(Object.assign(data.adjustments[i], { | ||||
|                     change: change, | ||||
|                     difficultyShorten: formatNumber( | ||||
|                       data.adjustments[i].difficulty / selectedPowerOfTen.divider, | ||||
|                       this.locale, '1.2-2') + selectedPowerOfTen.unit | ||||
|                   })); | ||||
|                 } | ||||
|                 return { | ||||
|                   availableTimespanDay: availableTimespanDay, | ||||
|                   data: tableData | ||||
|                 }; | ||||
|               }), | ||||
|             ); | ||||
|           }), | ||||
|           share() | ||||
|         ); | ||||
|   } | ||||
| 
 | ||||
|   prepareChartOptions(data) { | ||||
|     this.chartOptions = { | ||||
|       title: { | ||||
|         text: this.widget? '' : $localize`:@@mining.difficulty:Difficulty`, | ||||
|         left: 'center', | ||||
|         textStyle: { | ||||
|           color: '#FFF', | ||||
|         }, | ||||
|       }, | ||||
|       tooltip: { | ||||
|         show: true, | ||||
|         trigger: 'axis', | ||||
|       }, | ||||
|       axisPointer: { | ||||
|         type: 'line', | ||||
|       }, | ||||
|       xAxis: { | ||||
|         type: 'time', | ||||
|         splitNumber: this.isMobile() ? 5 : 10, | ||||
|       }, | ||||
|       yAxis: { | ||||
|         type: 'value', | ||||
|         axisLabel: { | ||||
|           formatter: (val) => { | ||||
|             const diff = val / Math.pow(10, 12); // terra
 | ||||
|             return diff.toString() + 'T'; | ||||
|           } | ||||
|         }, | ||||
|         splitLine: { | ||||
|           lineStyle: { | ||||
|             type: 'dotted', | ||||
|             color: '#ffffff66', | ||||
|             opacity: 0.25, | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|       series: [ | ||||
|         { | ||||
|           data: data, | ||||
|           type: 'line', | ||||
|           smooth: false, | ||||
|           lineStyle: { | ||||
|             width: 3, | ||||
|           }, | ||||
|           areaStyle: {} | ||||
|         }, | ||||
|       ], | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   isMobile() { | ||||
|     return (window.innerWidth <= 767.98); | ||||
|   } | ||||
| } | ||||
| @ -31,8 +31,11 @@ | ||||
|       <li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-home"> | ||||
|         <a class="nav-link" [routerLink]="['/' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'tachometer-alt']" [fixedWidth]="true" i18n-title="master-page.dashboard" title="Dashboard"></fa-icon></a> | ||||
|       </li> | ||||
|       <li class="nav-item" routerLinkActive="active" id="btn-pools"> | ||||
|         <a class="nav-link" [routerLink]="['/mining/pools' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'hammer']" [fixedWidth]="true" i18n-title="master-page.mining-pools" title="Mining Pools"></fa-icon></a> | ||||
|       <li class="nav-item" routerLinkActive="active" id="btn-pools" *ngIf="stateService.env.MINING_DASHBOARD"> | ||||
|         <a class="nav-link" [routerLink]="['/mining' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'hammer']" [fixedWidth]="true" i18n-title="master-page.mining-dashboard" title="Mining Dashboard"></fa-icon></a> | ||||
|       </li> | ||||
|       <li class="nav-item" routerLinkActive="active" id="btn-blocks" *ngIf="!stateService.env.MINING_DASHBOARD"> | ||||
|         <a class="nav-link" [routerLink]="['/blocks' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'cubes']" [fixedWidth]="true" i18n-title="master-page.blocks" title="Blocks"></fa-icon></a> | ||||
|       </li> | ||||
|       <li class="nav-item" routerLinkActive="active" id="btn-graphs"> | ||||
|         <a class="nav-link" [routerLink]="['/graphs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'chart-area']" [fixedWidth]="true" i18n-title="master-page.graphs" title="Graphs"></fa-icon></a> | ||||
|  | ||||
| @ -18,7 +18,7 @@ export class MasterPageComponent implements OnInit { | ||||
|   urlLanguage: string; | ||||
| 
 | ||||
|   constructor( | ||||
|     private stateService: StateService, | ||||
|     public stateService: StateService, | ||||
|     private languageService: LanguageService, | ||||
|   ) { } | ||||
| 
 | ||||
|  | ||||
| @ -0,0 +1,30 @@ | ||||
| <div class="container-xl dashboard-container"> | ||||
|    | ||||
|   <div class="row row-cols-1 row-cols-md-2"> | ||||
| 
 | ||||
|     <!-- pool distribution --> | ||||
|     <div class="col"> | ||||
|       <div class="main-title" i18n="mining.pool-share">Mining Pools Share (1w)</div> | ||||
|       <div class="card"> | ||||
|         <div class="card-body"> | ||||
|           <app-pool-ranking [widget]=true></app-pool-ranking> | ||||
|           <div class="text-center"><a href="" [routerLink]="['/mining/pools' | relativeUrl]" i18n="dashboard.view-more">View more | ||||
|               »</a></div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- difficulty --> | ||||
|     <div class="col"> | ||||
|       <div class="main-title" i18n="mining.difficulty">Difficulty (1y)</div> | ||||
|       <div class="card"> | ||||
|         <div class="card-body"> | ||||
|           <app-difficulty-chart [widget]=true></app-difficulty-chart> | ||||
|           <div class="text-center"><a href="" [routerLink]="['/mining/difficulty' | relativeUrl]" i18n="dashboard.view-more">View more | ||||
|               »</a></div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|   </div> | ||||
| </div> | ||||
| @ -0,0 +1,57 @@ | ||||
| .dashboard-container { | ||||
|   padding-bottom: 60px; | ||||
|   text-align: center; | ||||
|   margin-top: 0.5rem; | ||||
|   @media (min-width: 992px) { | ||||
|     padding-bottom: 0px; | ||||
|   } | ||||
|   .col { | ||||
|     margin-bottom: 1.5rem; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .card { | ||||
|   background-color: #1d1f31; | ||||
|   height: 100%; | ||||
| } | ||||
| 
 | ||||
| .card-wrapper { | ||||
|   .card { | ||||
|     height: auto !important; | ||||
|   } | ||||
|   .card-body { | ||||
|     display: flex; | ||||
|     flex: inherit; | ||||
|     text-align: center; | ||||
|     flex-direction: column; | ||||
|     justify-content: space-around; | ||||
|     padding: 22px 20px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| #blockchain-container { | ||||
|   position: relative; | ||||
|   overflow-x: scroll; | ||||
|   overflow-y: hidden; | ||||
|   scrollbar-width: none; | ||||
|   -ms-overflow-style: none; | ||||
| } | ||||
| 
 | ||||
| #blockchain-container::-webkit-scrollbar { | ||||
|   display: none; | ||||
| } | ||||
| 
 | ||||
| .fade-border { | ||||
|   -webkit-mask-image: linear-gradient(to right, transparent 0%, black 10%, black 80%, transparent 100%) | ||||
| } | ||||
|    | ||||
| .main-title { | ||||
|   position: relative; | ||||
|   color: #ffffff91; | ||||
|   margin-top: -13px; | ||||
|   font-size: 10px; | ||||
|   text-transform: uppercase; | ||||
|   font-weight: 500; | ||||
|   text-align: center; | ||||
|   padding-bottom: 3px; | ||||
| } | ||||
| @ -0,0 +1,16 @@ | ||||
| import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-mining-dashboard', | ||||
|   templateUrl: './mining-dashboard.component.html', | ||||
|   styleUrls: ['./mining-dashboard.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class MiningDashboardComponent implements OnInit { | ||||
| 
 | ||||
|   constructor() { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| @ -1,49 +1,48 @@ | ||||
| <div class="container-xl"> | ||||
|   <!-- <app-difficulty [showProgress]=false [showHalving]=true></app-difficulty>  --> | ||||
| <div [class]="widget === false ? 'container-xl' : ''"> | ||||
| 
 | ||||
|   <div class="hashrate-pie" echarts [initOpts]="chartInitOptions" [options]="chartOptions"></div> | ||||
|   <div class="hashrate-pie" echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)"></div> | ||||
|   <div class="text-center loadingGraphs" *ngIf="isLoading"> | ||||
|     <div class="spinner-border text-light"></div> | ||||
|   </div> | ||||
| 
 | ||||
|   <div class="card-header mb-0 mb-lg-4"> | ||||
|   <div class="card-header mb-0 mb-lg-4" [style]="widget === true ? 'display:none' : ''"> | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(miningStatsObservable$ | async) as miningStats"> | ||||
|       <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 1"> | ||||
|           <input ngbButton type="radio" [value]="'24h'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="24h"> 24h | ||||
|           <input ngbButton type="radio" [value]="'24h'" fragment="24h"> 24h | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 3"> | ||||
|           <input ngbButton type="radio" [value]="'3d'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="3d"> 3D | ||||
|           <input ngbButton type="radio" [value]="'3d'" fragment="3d"> 3D | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 7"> | ||||
|           <input ngbButton type="radio" [value]="'1w'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="1w"> 1W | ||||
|           <input ngbButton type="radio" [value]="'1w'" fragment="1w"> 1W | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 30"> | ||||
|           <input ngbButton type="radio" [value]="'1m'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="1m"> 1M | ||||
|           <input ngbButton type="radio" [value]="'1m'" fragment="1m"> 1M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 90"> | ||||
|           <input ngbButton type="radio" [value]="'3m'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="3m"> 3M | ||||
|           <input ngbButton type="radio" [value]="'3m'" fragment="3m"> 3M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 180"> | ||||
|           <input ngbButton type="radio" [value]="'6m'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="6m"> 6M | ||||
|           <input ngbButton type="radio" [value]="'6m'" fragment="6m"> 6M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 365"> | ||||
|           <input ngbButton type="radio" [value]="'1y'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="1y"> 1Y | ||||
|           <input ngbButton type="radio" [value]="'1y'" fragment="1y"> 1Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 730"> | ||||
|           <input ngbButton type="radio" [value]="'2y'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="2y"> 2Y | ||||
|           <input ngbButton type="radio" [value]="'2y'" fragment="2y"> 2Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 1095"> | ||||
|           <input ngbButton type="radio" [value]="'3y'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="3y"> 3Y | ||||
|           <input ngbButton type="radio" [value]="'3y'" fragment="3y"> 3Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|           <input ngbButton type="radio" [value]="'all'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="all"> ALL | ||||
|           <input ngbButton type="radio" [value]="'all'" fragment="all"> ALL | ||||
|         </label> | ||||
|       </div> | ||||
|     </form> | ||||
|   </div> | ||||
| 
 | ||||
|   <table class="table table-borderless text-center pools-table" [alwaysCallback]="true" infiniteScroll [infiniteScrollDistance]="1.5" [infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50"> | ||||
|   <table *ngIf="widget === false" class="table table-borderless text-center pools-table"> | ||||
|     <thead> | ||||
|       <tr> | ||||
|         <th class="d-none d-md-block" i18n="mining.rank">Rank</th> | ||||
| @ -58,14 +57,14 @@ | ||||
|       <tr *ngFor="let pool of miningStats.pools"> | ||||
|         <td class="d-none d-md-block">{{ pool.rank }}</td> | ||||
|         <td class="text-right"><img width="25" height="25" src="{{ pool.logo }}" onError="this.src = './resources/mining-pools/default.svg'"></td> | ||||
|         <td class="">{{ pool.name }}</td> | ||||
|         <td class=""><a [routerLink]="[('/mining/pool/' + pool.poolId) | relativeUrl]">{{ pool.name }}</a></td> | ||||
|         <td class="" *ngIf="this.poolsWindowPreference === '24h'">{{ pool.lastEstimatedHashrate }} {{ miningStats.miningUnits.hashrateUnit }}</td> | ||||
|         <td class="">{{ pool['blockText'] }}</td> | ||||
|         <td class="d-none d-md-block">{{ pool.emptyBlocks }} ({{ pool.emptyBlockRatio }}%)</td> | ||||
|       </tr> | ||||
|       <tr style="border-top: 1px solid #555"> | ||||
|         <td class="d-none d-md-block">-</td> | ||||
|         <td class="text-right"><img width="25" height="25" src="./resources/mining-pools/default.svg"></td> | ||||
|         <td class="d-none d-md-block"></td> | ||||
|         <td class="text-right"></td> | ||||
|         <td class="" i18n="mining.all-miners"><b>All miners</b></td> | ||||
|         <td class="" *ngIf="this.poolsWindowPreference === '24h'"><b>{{ miningStats.lastEstimatedHashrate}} {{ miningStats.miningUnits.hashrateUnit }}</b></td> | ||||
|         <td class=""><b>{{ miningStats.blockCount }}</b></td> | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| import { Component, OnDestroy, OnInit } from '@angular/core'; | ||||
| import { Component, Input, OnInit } from '@angular/core'; | ||||
| import { FormBuilder, FormGroup } from '@angular/forms'; | ||||
| import { EChartsOption } from 'echarts'; | ||||
| import { Router } from '@angular/router'; | ||||
| import { EChartsOption, PieSeriesOption } from 'echarts'; | ||||
| import { combineLatest, Observable, of } from 'rxjs'; | ||||
| import { catchError, map, share, skip, startWith, switchMap, tap } from 'rxjs/operators'; | ||||
| import { SinglePoolStats } from 'src/app/interfaces/node-api.interface'; | ||||
| @ -8,6 +9,7 @@ import { SeoService } from 'src/app/services/seo.service'; | ||||
| import { StorageService } from '../..//services/storage.service'; | ||||
| import { MiningService, MiningStats } from '../../services/mining.service'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { chartColors } from 'src/app/app.constants'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-pool-ranking', | ||||
| @ -22,7 +24,9 @@ import { StateService } from '../../services/state.service'; | ||||
|     } | ||||
|   `],
 | ||||
| }) | ||||
| export class PoolRankingComponent implements OnInit, OnDestroy { | ||||
| export class PoolRankingComponent implements OnInit { | ||||
|   @Input() widget: boolean = false; | ||||
| 
 | ||||
|   poolsWindowPreference: string; | ||||
|   radioGroupForm: FormGroup; | ||||
| 
 | ||||
| @ -31,6 +35,7 @@ export class PoolRankingComponent implements OnInit, OnDestroy { | ||||
|   chartInitOptions = { | ||||
|     renderer: 'svg' | ||||
|   }; | ||||
|   chartInstance: any = undefined; | ||||
| 
 | ||||
|   miningStatsObservable$: Observable<MiningStats>; | ||||
| 
 | ||||
| @ -40,14 +45,20 @@ export class PoolRankingComponent implements OnInit, OnDestroy { | ||||
|     private formBuilder: FormBuilder, | ||||
|     private miningService: MiningService, | ||||
|     private seoService: SeoService, | ||||
|     private router: Router, | ||||
|   ) { | ||||
|     this.seoService.setTitle($localize`:@@mining.mining-pools:Mining Pools`); | ||||
|     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); | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     if (this.widget) { | ||||
|       this.poolsWindowPreference = '1w'; | ||||
|     } else { | ||||
|       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); | ||||
| 
 | ||||
|     // When...
 | ||||
|     this.miningStatsObservable$ = combineLatest([ | ||||
|       // ...a new block is mined
 | ||||
| @ -61,7 +72,9 @@ export class PoolRankingComponent implements OnInit, OnDestroy { | ||||
|         .pipe( | ||||
|           startWith(this.poolsWindowPreference), // (trigger when the page loads)
 | ||||
|           tap((value) => { | ||||
|             this.storageService.setValue('poolsWindowPreference', value); | ||||
|             if (!this.widget) { | ||||
|               this.storageService.setValue('poolsWindowPreference', value); | ||||
|             } | ||||
|             this.poolsWindowPreference = value; | ||||
|           }) | ||||
|         ) | ||||
| @ -87,9 +100,6 @@ export class PoolRankingComponent implements OnInit, OnDestroy { | ||||
|       ); | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy(): void { | ||||
|   } | ||||
| 
 | ||||
|   formatPoolUI(pool: SinglePoolStats) { | ||||
|     pool['blockText'] = pool.blockCount.toString() + ` (${pool.share}%)`; | ||||
|     return pool; | ||||
| @ -115,9 +125,9 @@ export class PoolRankingComponent implements OnInit, OnDestroy { | ||||
|           overflow: 'break', | ||||
|         }, | ||||
|         tooltip: { | ||||
|           backgroundColor: "#282d47", | ||||
|           backgroundColor: '#282d47', | ||||
|           textStyle: { | ||||
|             color: "#FFFFFF", | ||||
|             color: '#FFFFFF', | ||||
|           }, | ||||
|           formatter: () => { | ||||
|             if (this.poolsWindowPreference === '24h') { | ||||
| @ -129,8 +139,9 @@ export class PoolRankingComponent implements OnInit, OnDestroy { | ||||
|                 pool.blockCount.toString() + ` blocks`; | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       }); | ||||
|         }, | ||||
|         data: pool.poolId, | ||||
|       } as PieSeriesOption); | ||||
|     }); | ||||
|     return data; | ||||
|   } | ||||
| @ -144,8 +155,7 @@ export class PoolRankingComponent implements OnInit, OnDestroy { | ||||
| 
 | ||||
|     this.chartOptions = { | ||||
|       title: { | ||||
|         text: $localize`:@@mining.pool-chart-title:${network}:NETWORK: mining pools share`, | ||||
|         subtext: $localize`:@@mining.pool-chart-sub-title:Estimated from the # of blocks mined`, | ||||
|         text: this.widget ? '' : $localize`:@@mining.pool-chart-title:${network}:NETWORK: mining pools share`, | ||||
|         left: 'center', | ||||
|         textStyle: { | ||||
|           color: '#FFF', | ||||
| @ -160,10 +170,11 @@ export class PoolRankingComponent implements OnInit, OnDestroy { | ||||
|       }, | ||||
|       series: [ | ||||
|         { | ||||
|           top: this.isMobile() ? '5%' : '20%', | ||||
|           top: this.widget ? '0%' : (this.isMobile() ? '5%' : '10%'), | ||||
|           bottom: this.widget ? '0%' : (this.isMobile() ? '0%' : '5%'), | ||||
|           name: 'Mining pool', | ||||
|           type: 'pie', | ||||
|           radius: this.isMobile() ? ['10%', '50%'] : ['20%', '80%'], | ||||
|           radius: this.widget ? ['20%', '60%'] : (this.isMobile() ? ['10%', '50%'] : ['20%', '70%']), | ||||
|           data: this.generatePoolsChartSerieData(miningStats), | ||||
|           labelLine: { | ||||
|             lineStyle: { | ||||
| @ -180,11 +191,8 @@ export class PoolRankingComponent implements OnInit, OnDestroy { | ||||
|           }, | ||||
|           emphasis: { | ||||
|             itemStyle: { | ||||
|               borderWidth: 2, | ||||
|               borderColor: '#FFF', | ||||
|               borderRadius: 2, | ||||
|               shadowBlur: 80, | ||||
|               shadowColor: 'rgba(255, 255, 255, 0.75)', | ||||
|               shadowBlur: 40, | ||||
|               shadowColor: 'rgba(0, 0, 0, 0.75)', | ||||
|             }, | ||||
|             labelLine: { | ||||
|               lineStyle: { | ||||
| @ -193,10 +201,22 @@ export class PoolRankingComponent implements OnInit, OnDestroy { | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       ] | ||||
|       ], | ||||
|       color: chartColors | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   onChartInit(ec) { | ||||
|     if (this.chartInstance !== undefined) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.chartInstance = ec; | ||||
|     this.chartInstance.on('click', (e) => { | ||||
|       this.router.navigate(['/mining/pool/', e.data.data]); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Default mining stats if something goes wrong | ||||
|    */ | ||||
|  | ||||
							
								
								
									
										113
									
								
								frontend/src/app/components/pool/pool.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								frontend/src/app/components/pool/pool.component.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,113 @@ | ||||
| <div class="container"> | ||||
| 
 | ||||
|   <div *ngIf="poolStats$ | async as poolStats"> | ||||
|     <h1 class="m-0"> | ||||
|       <img width="50" src="{{ poolStats['logo'] }}" onError="this.src = './resources/mining-pools/default.svg'" class="mr-3"> | ||||
|       {{ poolStats.pool.name }} | ||||
|     </h1> | ||||
| 
 | ||||
|     <div class="box pl-0 bg-transparent"> | ||||
|       <div class="card-header mb-0 mb-lg-4 pr-0 pl-0"> | ||||
|         <form [formGroup]="radioGroupForm" class="formRadioGroup ml-0"> | ||||
|           <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> | ||||
|             <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|               <input ngbButton type="radio" [value]="'24h'"> 24h | ||||
|             </label> | ||||
|             <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|               <input ngbButton type="radio" [value]="'3d'"> 3D | ||||
|             </label> | ||||
|             <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|               <input ngbButton type="radio" [value]="'1w'"> 1W | ||||
|             </label> | ||||
|             <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|               <input ngbButton type="radio" [value]="'1m'"> 1M | ||||
|             </label> | ||||
|             <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|               <input ngbButton type="radio" [value]="'3m'"> 3M | ||||
|             </label> | ||||
|             <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|               <input ngbButton type="radio" [value]="'6m'"> 6M | ||||
|             </label> | ||||
|             <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|               <input ngbButton type="radio" [value]="'1y'"> 1Y | ||||
|             </label> | ||||
|             <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|               <input ngbButton type="radio" [value]="'2y'"> 2Y | ||||
|             </label> | ||||
|             <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|               <input ngbButton type="radio" [value]="'3y'"> 3Y | ||||
|             </label> | ||||
|             <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|               <input ngbButton type="radio" [value]="'all'"> ALL | ||||
|             </label> | ||||
|           </div> | ||||
|         </form> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="box"> | ||||
|       <div class="row"> | ||||
|         <div class="col-lg-9"> | ||||
|           <table class="table table-borderless table-striped" style="table-layout: fixed;"> | ||||
|             <tbody> | ||||
|               <tr> | ||||
|                 <td class="col-4 col-lg-3">Addresses</td> | ||||
|                 <td class="text-truncate" *ngIf="poolStats.pool.addresses.length else noaddress"> | ||||
|                   <div class="scrollable"> | ||||
|                     <a *ngFor="let address of poolStats.pool.addresses" [routerLink]="['/address' | relativeUrl, address]">{{ address }}<br></a> | ||||
|                   </div> | ||||
|                 </td> | ||||
|                 <ng-template #noaddress><td>~</td></ng-template> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td class="col-4 col-lg-3">Coinbase Tags</td> | ||||
|                 <td class="text-truncate">{{ poolStats.pool.regexes }}</td> | ||||
|               </tr> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|         <div class="col-lg-3"> | ||||
|           <table class="table table-borderless table-striped"> | ||||
|             <tbody> | ||||
|               <tr> | ||||
|                 <td class="col-4 col-lg-8">Mined Blocks</td> | ||||
|                 <td class="text-left">{{ poolStats.blockCount }}</td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td class="col-4 col-lg-8">Empty Blocks</td> | ||||
|                 <td class="text-left">{{ poolStats.emptyBlocks.length }}</td> | ||||
|               </tr> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| 
 | ||||
|   <table class="table table-borderless" [alwaysCallback]="true" infiniteScroll [infiniteScrollDistance]="1.5" [infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50" (scrolled)="loadMore()"> | ||||
|     <thead> | ||||
|       <th style="width: 15%;" i18n="latest-blocks.height">Height</th> | ||||
|       <th class="d-none d-md-block" style="width: 20%;" i18n="latest-blocks.timestamp">Timestamp</th> | ||||
|       <th style="width: 20%;" i18n="latest-blocks.mined">Mined</th> | ||||
|       <th style="width: 10%;" i18n="latest-blocks.reward">Reward</th> | ||||
|       <th class="d-none d-lg-block" style="width: 15%;" i18n="latest-blocks.transactions">Transactions</th> | ||||
|       <th style="width: 20%;" i18n="latest-blocks.size">Size</th> | ||||
|     </thead> | ||||
|     <tbody *ngIf="blocks$ | async as blocks"> | ||||
|       <tr *ngFor="let block of blocks"> | ||||
|         <td><a [routerLink]="['/block' | relativeUrl, block.id]">{{ block.height }}</a></td> | ||||
|         <td class="d-none d-md-block">‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}</td> | ||||
|         <td><app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since></td> | ||||
|         <td class=""><app-amount [satoshis]="block['reward']" digitsInfo="1.2-2" [noFiat]="true"></app-amount></td> | ||||
|         <td class="d-none d-lg-block">{{ block.tx_count | number }}</td> | ||||
|         <td> | ||||
|           <div class="progress"> | ||||
|             <div class="progress-bar progress-mempool" role="progressbar" [ngStyle]="{'width': (block.weight / stateService.env.BLOCK_WEIGHT_UNITS)*100 + '%' }"></div> | ||||
|             <div class="progress-text" [innerHTML]="block.size | bytes: 2"></div> | ||||
|           </div> | ||||
|         </td> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   </table> | ||||
| 
 | ||||
| </div> | ||||
							
								
								
									
										41
									
								
								frontend/src/app/components/pool/pool.component.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								frontend/src/app/components/pool/pool.component.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,41 @@ | ||||
| .progress { | ||||
|   background-color: #2d3348; | ||||
| } | ||||
| 
 | ||||
| @media (min-width: 768px) { | ||||
|   .d-md-block { | ||||
|       display: table-cell !important; | ||||
|   } | ||||
| } | ||||
| @media (min-width: 992px) { | ||||
|   .d-lg-block { | ||||
|       display: table-cell !important; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .formRadioGroup { | ||||
|   margin-top: 6px; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   @media (min-width: 830px) { | ||||
|     margin-left: 2%; | ||||
|     flex-direction: row; | ||||
|     float: left; | ||||
|     margin-top: 0px; | ||||
|   } | ||||
|   .btn-sm { | ||||
|     font-size: 9px; | ||||
|     @media (min-width: 830px) { | ||||
|       font-size: 14px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| div.scrollable { | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
|   overflow: auto; | ||||
|   max-height: 100px; | ||||
| } | ||||
							
								
								
									
										84
									
								
								frontend/src/app/components/pool/pool.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								frontend/src/app/components/pool/pool.component.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,84 @@ | ||||
| import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; | ||||
| import { FormBuilder, FormGroup } from '@angular/forms'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; | ||||
| import { distinctUntilChanged, map, startWith, switchMap, tap } from 'rxjs/operators'; | ||||
| import { BlockExtended, PoolStat } from 'src/app/interfaces/node-api.interface'; | ||||
| import { ApiService } from 'src/app/services/api.service'; | ||||
| import { StateService } from 'src/app/services/state.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-pool', | ||||
|   templateUrl: './pool.component.html', | ||||
|   styleUrls: ['./pool.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush | ||||
| }) | ||||
| export class PoolComponent implements OnInit { | ||||
|   poolStats$: Observable<PoolStat>; | ||||
|   blocks$: Observable<BlockExtended[]>; | ||||
| 
 | ||||
|   fromHeight: number = -1; | ||||
|   fromHeightSubject: BehaviorSubject<number> = new BehaviorSubject(this.fromHeight); | ||||
| 
 | ||||
|   blocks: BlockExtended[] = []; | ||||
|   poolId: number = undefined; | ||||
|   radioGroupForm: FormGroup; | ||||
| 
 | ||||
|   constructor( | ||||
|     private apiService: ApiService, | ||||
|     private route: ActivatedRoute, | ||||
|     public stateService: StateService, | ||||
|     private formBuilder: FormBuilder, | ||||
|   ) { | ||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: '1w' }); | ||||
|     this.radioGroupForm.controls.dateSpan.setValue('1w'); | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.poolStats$ = combineLatest([ | ||||
|       this.route.params.pipe(map((params) => params.poolId)), | ||||
|       this.radioGroupForm.get('dateSpan').valueChanges.pipe(startWith('1w')), | ||||
|     ]) | ||||
|       .pipe( | ||||
|         switchMap((params: any) => { | ||||
|           this.poolId = params[0]; | ||||
|           if (this.blocks.length === 0) { | ||||
|             this.fromHeightSubject.next(undefined); | ||||
|           } | ||||
|           return this.apiService.getPoolStats$(this.poolId, params[1] ?? '1w'); | ||||
|         }), | ||||
|         map((poolStats) => { | ||||
|           let regexes = '"'; | ||||
|           for (const regex of poolStats.pool.regexes) { | ||||
|             regexes += regex + '", "'; | ||||
|           } | ||||
|           poolStats.pool.regexes = regexes.slice(0, -3); | ||||
|           poolStats.pool.addresses = poolStats.pool.addresses; | ||||
| 
 | ||||
|           return Object.assign({ | ||||
|             logo: `./resources/mining-pools/` + poolStats.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg' | ||||
|           }, poolStats); | ||||
|         }) | ||||
|       ); | ||||
| 
 | ||||
|     this.blocks$ = this.fromHeightSubject | ||||
|       .pipe( | ||||
|         distinctUntilChanged(), | ||||
|         switchMap((fromHeight) => { | ||||
|           return this.apiService.getPoolBlocks$(this.poolId, fromHeight); | ||||
|         }), | ||||
|         tap((newBlocks) => { | ||||
|           this.blocks = this.blocks.concat(newBlocks); | ||||
|         }), | ||||
|         map(() => this.blocks) | ||||
|       ) | ||||
|   } | ||||
| 
 | ||||
|   loadMore() { | ||||
|     this.fromHeightSubject.next(this.blocks[this.blocks.length - 1]?.height); | ||||
|   } | ||||
| 
 | ||||
|   trackByBlock(index: number, block: BlockExtended) { | ||||
|     return block.height; | ||||
|   } | ||||
| } | ||||
| @ -54,8 +54,11 @@ export interface LiquidPegs { | ||||
| 
 | ||||
| export interface ITranslators { [language: string]: string; } | ||||
| 
 | ||||
| /** | ||||
|  * PoolRanking component | ||||
|  */ | ||||
| export interface SinglePoolStats { | ||||
|   pooldId: number; | ||||
|   poolId: number; | ||||
|   name: string; | ||||
|   link: string; | ||||
|   blockCount: number; | ||||
| @ -66,20 +69,35 @@ export interface SinglePoolStats { | ||||
|   emptyBlockRatio: string; | ||||
|   logo: string; | ||||
| } | ||||
| 
 | ||||
| export interface PoolsStats { | ||||
|   blockCount: number; | ||||
|   lastEstimatedHashrate: number; | ||||
|   oldestIndexedBlockTimestamp: number; | ||||
|   pools: SinglePoolStats[]; | ||||
| } | ||||
| 
 | ||||
| export interface MiningStats { | ||||
|   lastEstimatedHashrate: string, | ||||
|   blockCount: number, | ||||
|   totalEmptyBlock: number, | ||||
|   totalEmptyBlockRatio: string, | ||||
|   pools: SinglePoolStats[], | ||||
|   lastEstimatedHashrate: string; | ||||
|   blockCount: number; | ||||
|   totalEmptyBlock: number; | ||||
|   totalEmptyBlockRatio: string; | ||||
|   pools: SinglePoolStats[]; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Pool component | ||||
|  */ | ||||
| export interface PoolInfo { | ||||
|   id: number | null; // mysql row id
 | ||||
|   name: string; | ||||
|   link: string; | ||||
|   regexes: string; // JSON array
 | ||||
|   addresses: string; // JSON array
 | ||||
|   emptyBlocks: number; | ||||
| } | ||||
| export interface PoolStat { | ||||
|   pool: PoolInfo; | ||||
|   blockCount: number; | ||||
|   emptyBlocks: BlockExtended[]; | ||||
| } | ||||
| 
 | ||||
| export interface BlockExtension { | ||||
| @ -88,6 +106,10 @@ export interface BlockExtension { | ||||
|   reward?: number; | ||||
|   coinbaseTx?: Transaction; | ||||
|   matchRate?: number; | ||||
|   pool?: { | ||||
|     id: number; | ||||
|     name: string; | ||||
|   } | ||||
| 
 | ||||
|   stage?: number; // Frontend only
 | ||||
| } | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { HttpClient, HttpParams } from '@angular/common/http'; | ||||
| import { CpfpInfo, OptimizedMempoolStats, DifficultyAdjustment, AddressInformation, LiquidPegs, ITranslators, PoolsStats } from '../interfaces/node-api.interface'; | ||||
| import { CpfpInfo, OptimizedMempoolStats, DifficultyAdjustment, AddressInformation, LiquidPegs, ITranslators, PoolsStats, PoolStat, BlockExtended, BlockExtension } from '../interfaces/node-api.interface'; | ||||
| import { Observable } from 'rxjs'; | ||||
| import { StateService } from './state.service'; | ||||
| import { WebsocketResponse } from '../interfaces/websocket.interface'; | ||||
| @ -129,7 +129,31 @@ export class ApiService { | ||||
|     return this.httpClient.post<any>(this.apiBaseUrl + this.apiBasePath + '/api/tx', hexPayload, { responseType: 'text' as 'json'}); | ||||
|   } | ||||
| 
 | ||||
|   listPools$(interval: string | null) : Observable<PoolsStats> { | ||||
|     return this.httpClient.get<PoolsStats>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pools/${interval}`); | ||||
|   listPools$(interval: string | undefined) : Observable<PoolsStats> { | ||||
|     return this.httpClient.get<PoolsStats>( | ||||
|       this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pools` + | ||||
|       (interval !== undefined ? `/${interval}` : '') | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   getPoolStats$(poolId: number, interval: string | undefined): Observable<PoolStat> { | ||||
|     return this.httpClient.get<PoolStat>( | ||||
|       this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}` + | ||||
|       (interval !== undefined ? `/${interval}` : '') | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   getPoolBlocks$(poolId: number, fromHeight: number): Observable<BlockExtended[]> { | ||||
|     return this.httpClient.get<BlockExtended[]>( | ||||
|         this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}/blocks` + | ||||
|         (fromHeight !== undefined ? `/${fromHeight}` : '') | ||||
|       ); | ||||
|   } | ||||
| 
 | ||||
|   getHistoricalDifficulty$(interval: string | undefined): Observable<any> { | ||||
|     return this.httpClient.get<any[]>( | ||||
|         this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/difficulty` + | ||||
|         (interval !== undefined ? `/${interval}` : '') | ||||
|       ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -36,6 +36,7 @@ export interface Env { | ||||
|   MEMPOOL_WEBSITE_URL: string; | ||||
|   LIQUID_WEBSITE_URL: string; | ||||
|   BISQ_WEBSITE_URL: string; | ||||
|   MINING_DASHBOARD: boolean; | ||||
| } | ||||
| 
 | ||||
| const defaultEnv: Env = { | ||||
| @ -59,6 +60,7 @@ const defaultEnv: Env = { | ||||
|   'MEMPOOL_WEBSITE_URL': 'https://mempool.space', | ||||
|   'LIQUID_WEBSITE_URL': 'https://liquid.network', | ||||
|   'BISQ_WEBSITE_URL': 'https://bisq.markets', | ||||
|   'MINING_DASHBOARD': true | ||||
| }; | ||||
| 
 | ||||
| @Injectable({ | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user