diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..6eac74517 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +backend/src/api/database-migration.ts @wiz @softsimon diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index e8f6d1df1..bc66678d4 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -1,8 +1,11 @@ name: Cypress Tests on: + push: + branches: [master] pull_request: - types: [opened, review_requested, synchronize] + types: [opened, synchronize] + jobs: cypress: if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" diff --git a/.vscode/settings.json b/.vscode/settings.json index 06578f8f1..692791184 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "editor.tabSize": 2, + "typescript.preferences.importModuleSpecifier": "relative", "typescript.tsdk": "./backend/node_modules/typescript/lib" } \ No newline at end of file diff --git a/README.md b/README.md index cde9b5adb..d2f9f9382 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # The Mempool Open Source Project™ [![mempool](https://img.shields.io/endpoint?url=https://dashboard.cypress.io/badge/simple/ry4br7/master&style=flat-square)](https://dashboard.cypress.io/projects/ry4br7/runs) +https://user-images.githubusercontent.com/232186/222445818-234aa6c9-c233-4c52-b3f0-e32b8232893b.mp4 + Mempool is the fully-featured mempool visualizer, explorer, and API service running at [mempool.space](https://mempool.space/). It is an open-source project developed and operated for the benefit of the Bitcoin community, with a focus on the emerging transaction fee market that is evolving Bitcoin into a multi-layer ecosystem. diff --git a/backend/README.md b/backend/README.md index be85d25af..256dcaa43 100644 --- a/backend/README.md +++ b/backend/README.md @@ -171,52 +171,58 @@ Helpful link: https://gist.github.com/System-Glitch/cb4e87bf1ae3fec9925725bb3ebe Run bitcoind on regtest: ``` - bitcoind -regtest -rpcport=8332 + bitcoind -regtest ``` Create a new wallet, if needed: ``` - bitcoin-cli -regtest -rpcport=8332 createwallet test + bitcoin-cli -regtest createwallet test ``` Load wallet (this command may take a while if you have lot of UTXOs): ``` - bitcoin-cli -regtest -rpcport=8332 loadwallet test + bitcoin-cli -regtest loadwallet test ``` Get a new address: ``` - address=$(./src/bitcoin-cli -regtest -rpcport=8332 getnewaddress) + address=$(bitcoin-cli -regtest getnewaddress) ``` Mine blocks to the previously generated address. You need at least 101 blocks before you can spend. This will take some time to execute (~1 min): ``` - bitcoin-cli -regtest -rpcport=8332 generatetoaddress 101 $address + bitcoin-cli -regtest generatetoaddress 101 $address ``` Send 0.1 BTC at 5 sat/vB to another address: ``` - ./src/bitcoin-cli -named -regtest -rpcport=8332 sendtoaddress address=$(./src/bitcoin-cli -regtest -rpcport=8332 getnewaddress) amount=0.1 fee_rate=5 + bitcoin-cli -named -regtest sendtoaddress address=$(bitcoin-cli -regtest getnewaddress) amount=0.1 fee_rate=5 ``` See more example of `sendtoaddress`: ``` - ./src/bitcoin-cli sendtoaddress # will print the help + bitcoin-cli sendtoaddress # will print the help ``` -Mini script to generate transactions with random TX fee-rate (between 1 to 100 sat/vB). It's slow so don't expect to use this to test mempool spam, except if you let it run for a long time, or maybe with multiple regtest nodes connected to each other. +Mini script to generate random network activity (random TX count with random tx fee-rate). It's slow so don't expect to use this to test mempool spam, except if you let it run for a long time, or maybe with multiple regtest nodes connected to each other. ``` #!/bin/bash - address=$(./src/bitcoin-cli -regtest -rpcport=8332 getnewaddress) + address=$(bitcoin-cli -regtest getnewaddress) + bitcoin-cli -regtest generatetoaddress 101 $address for i in {1..1000000} do - ./src/bitcoin-cli -regtest -rpcport=8332 -named sendtoaddress address=$address amount=0.01 fee_rate=$(jot -r 1 1 100) + for y in $(seq 1 "$(jot -r 1 1 1000)") + do + bitcoin-cli -regtest -named sendtoaddress address=$address amount=0.01 fee_rate=$(jot -r 1 1 100) + done + bitcoin-cli -regtest generatetoaddress 1 $address + sleep 5 done ``` Generate block at regular interval (every 10 seconds in this example): ``` - watch -n 10 "./src/bitcoin-cli -regtest -rpcport=8332 generatetoaddress 1 $address" + watch -n 10 "bitcoin-cli -regtest generatetoaddress 1 $address" ``` ### Mining pools update diff --git a/backend/src/__tests__/api/difficulty-adjustment.test.ts b/backend/src/__tests__/api/difficulty-adjustment.test.ts index eb774d445..5ef1936e0 100644 --- a/backend/src/__tests__/api/difficulty-adjustment.test.ts +++ b/backend/src/__tests__/api/difficulty-adjustment.test.ts @@ -23,9 +23,11 @@ describe('Mempool Difficulty Adjustment', () => { remainingBlocks: 1834, remainingTime: 977591692, previousRetarget: 0.6280047707459726, + previousTime: 1660820820, nextRetargetHeight: 751968, timeAvg: 533038, timeOffset: 0, + expectedBlocks: 161.68833333333333, }, ], [ // Vector 2 (testnet) @@ -43,11 +45,13 @@ describe('Mempool Difficulty Adjustment', () => { estimatedRetargetDate: 1661895424692, remainingBlocks: 1834, remainingTime: 977591692, + previousTime: 1660820820, previousRetarget: 0.6280047707459726, nextRetargetHeight: 751968, timeAvg: 533038, timeOffset: -667000, // 11 min 7 seconds since last block (testnet only) // If we add time avg to abs(timeOffset) it makes exactly 1200000 ms, or 20 minutes + expectedBlocks: 161.68833333333333, }, ], ] as [[number, number, number, number, string, number], DifficultyAdjustment][]; diff --git a/backend/src/api/bitcoin/bitcoin-api.interface.ts b/backend/src/api/bitcoin/bitcoin-api.interface.ts index 54d666794..3afc22897 100644 --- a/backend/src/api/bitcoin/bitcoin-api.interface.ts +++ b/backend/src/api/bitcoin/bitcoin-api.interface.ts @@ -172,4 +172,35 @@ export namespace IBitcoinApi { } } + export interface BlockStats { + "avgfee": number; + "avgfeerate": number; + "avgtxsize": number; + "blockhash": string; + "feerate_percentiles": [number, number, number, number, number]; + "height": number; + "ins": number; + "maxfee": number; + "maxfeerate": number; + "maxtxsize": number; + "medianfee": number; + "mediantime": number; + "mediantxsize": number; + "minfee": number; + "minfeerate": number; + "mintxsize": number; + "outs": number; + "subsidy": number; + "swtotal_size": number; + "swtotal_weight": number; + "swtxs": number; + "time": number; + "total_out": number; + "total_size": number; + "total_weight": number; + "totalfee": number; + "txs": number; + "utxo_increase": number; + "utxo_size_inc": number; + } } diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index 117245ef8..e20fe9e34 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -28,7 +28,7 @@ class BitcoinApi implements AbstractBitcoinApi { size: block.size, weight: block.weight, previousblockhash: block.previousblockhash, - medianTime: block.mediantime, + mediantime: block.mediantime, }; } diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 78d027663..c6323d041 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -6,7 +6,7 @@ import websocketHandler from '../websocket-handler'; import mempool from '../mempool'; import feeApi from '../fee-api'; import mempoolBlocks from '../mempool-blocks'; -import bitcoinApi from './bitcoin-api-factory'; +import bitcoinApi, { bitcoinCoreApi } from './bitcoin-api-factory'; import { Common } from '../common'; import backendInfo from '../backend-info'; import transactionUtils from '../transaction-utils'; @@ -217,13 +217,20 @@ class BitcoinRoutes { res.json(cpfpInfo); return; } else { - const cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId); + let cpfpInfo; + if (config.DATABASE.ENABLED) { + cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId); + } if (cpfpInfo) { res.json(cpfpInfo); return; + } else { + res.json({ + ancestors: [] + }); + return; } } - res.status(404).send(`Transaction has no CPFP info available.`); } private getBackendInfo(req: Request, res: Response) { @@ -461,7 +468,7 @@ class BitcoinRoutes { returnBlocks.push(localBlock); nextHash = localBlock.previousblockhash; } else { - const block = await bitcoinApi.$getBlock(nextHash); + const block = await bitcoinCoreApi.$getBlock(nextHash); returnBlocks.push(block); nextHash = block.previousblockhash; } @@ -644,7 +651,7 @@ class BitcoinRoutes { if (result) { res.json(result); } else { - res.status(404).send('not found'); + res.status(204).send(); } } catch (e) { res.status(500).send(e instanceof Error ? e.message : e); diff --git a/backend/src/api/bitcoin/esplora-api.interface.ts b/backend/src/api/bitcoin/esplora-api.interface.ts index eaf6476f4..6d50bddfd 100644 --- a/backend/src/api/bitcoin/esplora-api.interface.ts +++ b/backend/src/api/bitcoin/esplora-api.interface.ts @@ -88,7 +88,7 @@ export namespace IEsploraApi { size: number; weight: number; previousblockhash: string; - medianTime?: number; + mediantime: number; } export interface Address { diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 204419496..ea2985b4f 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -1,8 +1,8 @@ import config from '../config'; -import bitcoinApi from './bitcoin/bitcoin-api-factory'; +import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory'; import logger from '../logger'; import memPool from './mempool'; -import { BlockExtended, BlockSummary, PoolTag, TransactionExtended, TransactionStripped, TransactionMinerInfo } from '../mempool.interfaces'; +import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionStripped, TransactionMinerInfo } from '../mempool.interfaces'; import { Common } from './common'; import diskCache from './disk-cache'; import transactionUtils from './transaction-utils'; @@ -13,7 +13,6 @@ import poolsRepository from '../repositories/PoolsRepository'; import blocksRepository from '../repositories/BlocksRepository'; import loadingIndicators from './loading-indicators'; import BitcoinApi from './bitcoin/bitcoin-api'; -import { prepareBlock } from '../utils/blocks-utils'; import BlocksRepository from '../repositories/BlocksRepository'; import HashratesRepository from '../repositories/HashratesRepository'; import indexer from '../indexer'; @@ -143,7 +142,7 @@ class Blocks { * @param block * @returns BlockSummary */ - private summarizeBlock(block: IBitcoinApi.VerboseBlock): BlockSummary { + public summarizeBlock(block: IBitcoinApi.VerboseBlock): BlockSummary { const stripped = block.tx.map((tx) => { return { txid: tx.txid, @@ -166,80 +165,81 @@ class Blocks { * @returns BlockExtended */ private async $getBlockExtended(block: IEsploraApi.Block, transactions: TransactionExtended[]): Promise { - const blk: BlockExtended = Object.assign({ extras: {} }, block); - blk.extras.reward = transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0); - blk.extras.coinbaseTx = transactionUtils.stripCoinbaseTransaction(transactions[0]); - blk.extras.coinbaseRaw = blk.extras.coinbaseTx.vin[0].scriptsig; - blk.extras.usd = priceUpdater.latestPrices.USD; - blk.extras.medianTimestamp = block.medianTime; - blk.extras.orphans = chainTips.getOrphanedBlocksAtHeight(blk.height); + const coinbaseTx = transactionUtils.stripCoinbaseTransaction(transactions[0]); + + const blk: Partial = Object.assign({}, block); + const extras: Partial = {}; + + extras.reward = transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0); + extras.coinbaseRaw = coinbaseTx.vin[0].scriptsig; + extras.orphans = chainTips.getOrphanedBlocksAtHeight(blk.height); if (block.height === 0) { - blk.extras.medianFee = 0; // 50th percentiles - blk.extras.feeRange = [0, 0, 0, 0, 0, 0, 0]; - blk.extras.totalFees = 0; - blk.extras.avgFee = 0; - blk.extras.avgFeeRate = 0; - blk.extras.utxoSetChange = 0; - blk.extras.avgTxSize = 0; - blk.extras.totalInputs = 0; - blk.extras.totalOutputs = 1; - blk.extras.totalOutputAmt = 0; - blk.extras.segwitTotalTxs = 0; - blk.extras.segwitTotalSize = 0; - blk.extras.segwitTotalWeight = 0; + extras.medianFee = 0; // 50th percentiles + extras.feeRange = [0, 0, 0, 0, 0, 0, 0]; + extras.totalFees = 0; + extras.avgFee = 0; + extras.avgFeeRate = 0; + extras.utxoSetChange = 0; + extras.avgTxSize = 0; + extras.totalInputs = 0; + extras.totalOutputs = 1; + extras.totalOutputAmt = 0; + extras.segwitTotalTxs = 0; + extras.segwitTotalSize = 0; + extras.segwitTotalWeight = 0; } else { - const stats = await bitcoinClient.getBlockStats(block.id); - blk.extras.medianFee = stats.feerate_percentiles[2]; // 50th percentiles - blk.extras.feeRange = [stats.minfeerate, stats.feerate_percentiles, stats.maxfeerate].flat(); - blk.extras.totalFees = stats.totalfee; - blk.extras.avgFee = stats.avgfee; - blk.extras.avgFeeRate = stats.avgfeerate; - blk.extras.utxoSetChange = stats.utxo_increase; - blk.extras.avgTxSize = Math.round(stats.total_size / stats.txs * 100) * 0.01; - blk.extras.totalInputs = stats.ins; - blk.extras.totalOutputs = stats.outs; - blk.extras.totalOutputAmt = stats.total_out; - blk.extras.segwitTotalTxs = stats.swtxs; - blk.extras.segwitTotalSize = stats.swtotal_size; - blk.extras.segwitTotalWeight = stats.swtotal_weight; + const stats: IBitcoinApi.BlockStats = await bitcoinClient.getBlockStats(block.id); + extras.medianFee = stats.feerate_percentiles[2]; // 50th percentiles + extras.feeRange = [stats.minfeerate, stats.feerate_percentiles, stats.maxfeerate].flat(); + extras.totalFees = stats.totalfee; + extras.avgFee = stats.avgfee; + extras.avgFeeRate = stats.avgfeerate; + extras.utxoSetChange = stats.utxo_increase; + extras.avgTxSize = Math.round(stats.total_size / stats.txs * 100) * 0.01; + extras.totalInputs = stats.ins; + extras.totalOutputs = stats.outs; + extras.totalOutputAmt = stats.total_out; + extras.segwitTotalTxs = stats.swtxs; + extras.segwitTotalSize = stats.swtotal_size; + extras.segwitTotalWeight = stats.swtotal_weight; } if (Common.blocksSummariesIndexingEnabled()) { - blk.extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(block.id); - if (blk.extras.feePercentiles !== null) { - blk.extras.medianFeeAmt = blk.extras.feePercentiles[3]; + extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(block.id); + if (extras.feePercentiles !== null) { + extras.medianFeeAmt = extras.feePercentiles[3]; } } - blk.extras.virtualSize = block.weight / 4.0; - if (blk.extras.coinbaseTx.vout.length > 0) { - blk.extras.coinbaseAddress = blk.extras.coinbaseTx.vout[0].scriptpubkey_address ?? null; - blk.extras.coinbaseSignature = blk.extras.coinbaseTx.vout[0].scriptpubkey_asm ?? null; - blk.extras.coinbaseSignatureAscii = transactionUtils.hex2ascii(blk.extras.coinbaseTx.vin[0].scriptsig) ?? null; + extras.virtualSize = block.weight / 4.0; + if (coinbaseTx?.vout.length > 0) { + extras.coinbaseAddress = coinbaseTx.vout[0].scriptpubkey_address ?? null; + extras.coinbaseSignature = coinbaseTx.vout[0].scriptpubkey_asm ?? null; + extras.coinbaseSignatureAscii = transactionUtils.hex2ascii(coinbaseTx.vin[0].scriptsig) ?? null; } else { - blk.extras.coinbaseAddress = null; - blk.extras.coinbaseSignature = null; - blk.extras.coinbaseSignatureAscii = null; + extras.coinbaseAddress = null; + extras.coinbaseSignature = null; + extras.coinbaseSignatureAscii = null; } const header = await bitcoinClient.getBlockHeader(block.id, false); - blk.extras.header = header; + extras.header = header; const coinStatsIndex = indexer.isCoreIndexReady('coinstatsindex'); if (coinStatsIndex !== null && coinStatsIndex.best_block_height >= block.height) { const txoutset = await bitcoinClient.getTxoutSetinfo('none', block.height); - blk.extras.utxoSetSize = txoutset.txouts, - blk.extras.totalInputAmt = Math.round(txoutset.block_info.prevout_spent * 100000000); + extras.utxoSetSize = txoutset.txouts, + extras.totalInputAmt = Math.round(txoutset.block_info.prevout_spent * 100000000); } else { - blk.extras.utxoSetSize = null; - blk.extras.totalInputAmt = null; + extras.utxoSetSize = null; + extras.totalInputAmt = null; } if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { let pool: PoolTag; - if (blk.extras?.coinbaseTx !== undefined) { - pool = await this.$findBlockMiner(blk.extras?.coinbaseTx); + if (coinbaseTx !== undefined) { + pool = await this.$findBlockMiner(coinbaseTx); } else { if (config.DATABASE.ENABLED === true) { pool = await poolsRepository.$getUnknownPool(); @@ -252,22 +252,24 @@ class Blocks { logger.warn(`Cannot assign pool to block ${blk.height} and 'unknown' pool does not exist. ` + `Check your "pools" table entries`); } else { - blk.extras.pool = { - id: pool.id, + extras.pool = { + id: pool.uniqueId, name: pool.name, slug: pool.slug, }; } + extras.matchRate = null; if (config.MEMPOOL.AUDIT) { const auditScore = await BlocksAuditsRepository.$getBlockAuditScore(block.id); if (auditScore != null) { - blk.extras.matchRate = auditScore.matchRate; + extras.matchRate = auditScore.matchRate; } } } - return blk; + blk.extras = extras; + return blk; } /** @@ -293,15 +295,18 @@ class Blocks { } else { pools = poolsParser.miningPools; } + for (let i = 0; i < pools.length; ++i) { if (address !== undefined) { - const addresses: string[] = JSON.parse(pools[i].addresses); + const addresses: string[] = typeof pools[i].addresses === 'string' ? + JSON.parse(pools[i].addresses) : pools[i].addresses; if (addresses.indexOf(address) !== -1) { return pools[i]; } } - const regexes: string[] = JSON.parse(pools[i].regexes); + const regexes: string[] = typeof pools[i].regexes === 'string' ? + JSON.parse(pools[i].regexes) : pools[i].regexes; for (let y = 0; y < regexes.length; ++y) { const regex = new RegExp(regexes[y], 'i'); const match = asciiScriptSig.match(regex); @@ -479,7 +484,7 @@ class Blocks { loadingIndicators.setProgress('block-indexing', progress, false); } const blockHash = await bitcoinApi.$getBlockHash(blockHeight); - const block = BitcoinApi.convertBlock(await bitcoinClient.getBlock(blockHash)); + const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash); const transactions = await this.$getTransactionsExtended(blockHash, block.height, true, true); const blockExtended = await this.$getBlockExtended(block, transactions); @@ -527,13 +532,13 @@ class Blocks { if (blockchainInfo.blocks === blockchainInfo.headers) { const heightDiff = blockHeightTip % 2016; const blockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff); - const block = BitcoinApi.convertBlock(await bitcoinClient.getBlock(blockHash)); + const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash); this.lastDifficultyAdjustmentTime = block.timestamp; this.currentDifficulty = block.difficulty; if (blockHeightTip >= 2016) { const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016); - const previousPeriodBlock = await bitcoinClient.getBlock(previousPeriodBlockHash) + const previousPeriodBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(previousPeriodBlockHash); this.previousDifficultyRetarget = (block.difficulty - previousPeriodBlock.difficulty) / previousPeriodBlock.difficulty * 100; logger.debug(`Initial difficulty adjustment data set.`); } @@ -565,18 +570,18 @@ class Blocks { if (Common.indexingEnabled()) { if (!fastForwarded) { const lastBlock = await blocksRepository.$getBlockByHeight(blockExtended.height - 1); - if (lastBlock !== null && blockExtended.previousblockhash !== lastBlock['hash']) { - logger.warn(`Chain divergence detected at block ${lastBlock['height']}, re-indexing most recent data`); + if (lastBlock !== null && blockExtended.previousblockhash !== lastBlock.id) { + logger.warn(`Chain divergence detected at block ${lastBlock.height}, re-indexing most recent data`); // We assume there won't be a reorg with more than 10 block depth - await BlocksRepository.$deleteBlocksFrom(lastBlock['height'] - 10); + await BlocksRepository.$deleteBlocksFrom(lastBlock.height - 10); await HashratesRepository.$deleteLastEntries(); - await BlocksSummariesRepository.$deleteBlocksFrom(lastBlock['height'] - 10); - await cpfpRepository.$deleteClustersFrom(lastBlock['height'] - 10); + await BlocksSummariesRepository.$deleteBlocksFrom(lastBlock.height - 10); + await cpfpRepository.$deleteClustersFrom(lastBlock.height - 10); for (let i = 10; i >= 0; --i) { - const newBlock = await this.$indexBlock(lastBlock['height'] - i); + const newBlock = await this.$indexBlock(lastBlock.height - i); await this.$getStrippedBlockTransactions(newBlock.id, true, true); if (config.MEMPOOL.CPFP_INDEXING) { - await this.$indexCPFP(newBlock.id, lastBlock['height'] - i); + await this.$indexCPFP(newBlock.id, lastBlock.height - i); } } await mining.$indexDifficultyAdjustments(); @@ -652,12 +657,12 @@ class Blocks { if (Common.indexingEnabled()) { const dbBlock = await blocksRepository.$getBlockByHeight(height); if (dbBlock !== null) { - return prepareBlock(dbBlock); + return dbBlock; } } const blockHash = await bitcoinApi.$getBlockHash(height); - const block = BitcoinApi.convertBlock(await bitcoinClient.getBlock(blockHash)); + const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash); const transactions = await this.$getTransactionsExtended(blockHash, block.height, true); const blockExtended = await this.$getBlockExtended(block, transactions); @@ -665,11 +670,11 @@ class Blocks { await blocksRepository.$saveBlockInDatabase(blockExtended); } - return prepareBlock(blockExtended); + return blockExtended; } /** - * Index a block by hash if it's missing from the database. Returns the block after indexing + * Get one block by its hash */ public async $getBlock(hash: string): Promise { // Check the memory cache @@ -678,31 +683,14 @@ class Blocks { return blockByHash; } - // Block has already been indexed - if (Common.indexingEnabled()) { - const dbBlock = await blocksRepository.$getBlockByHash(hash); - if (dbBlock != null) { - return prepareBlock(dbBlock); - } - } - - // Not Bitcoin network, return the block as it + // Not Bitcoin network, return the block as it from the bitcoin backend if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) { - return await bitcoinApi.$getBlock(hash); + return await bitcoinCoreApi.$getBlock(hash); } - let block = await bitcoinClient.getBlock(hash); - block = prepareBlock(block); - // Bitcoin network, add our custom data on top - const transactions = await this.$getTransactionsExtended(hash, block.height, true); - const blockExtended = await this.$getBlockExtended(block, transactions); - if (Common.indexingEnabled()) { - delete(blockExtended['coinbaseTx']); - await blocksRepository.$saveBlockInDatabase(blockExtended); - } - - return blockExtended; + const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(hash); + return await this.$indexBlock(block.height); } public async $getStrippedBlockTransactions(hash: string, skipMemoryCache = false, @@ -736,6 +724,18 @@ class Blocks { return summary.transactions; } + /** + * Get 15 blocks + * + * Internally this function uses two methods to get the blocks, and + * the method is automatically selected: + * - Using previous block hash links + * - Using block height + * + * @param fromHeight + * @param limit + * @returns + */ public async $getBlocks(fromHeight?: number, limit: number = 15): Promise { let currentHeight = fromHeight !== undefined ? fromHeight : this.currentBlockHeight; if (currentHeight > this.currentBlockHeight) { @@ -748,27 +748,15 @@ class Blocks { 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 if (!Common.indexingEnabled()) { - startFromHash = await bitcoinApi.$getBlockHash(currentHeight); - } - - let nextHash = startFromHash; for (let i = 0; i < limit && currentHeight >= 0; i++) { let block = this.getBlocks().find((b) => b.height === currentHeight); if (block) { + // Using the memory cache (find by height) returnBlocks.push(block); - } else if (Common.indexingEnabled()) { + } else { + // Using indexing (find by height, index on the fly, save in database) block = await this.$indexBlock(currentHeight); returnBlocks.push(block); - } else if (nextHash != null) { - block = await this.$indexBlock(currentHeight); - nextHash = block.previousblockhash; - returnBlocks.push(block); } currentHeight--; } @@ -790,7 +778,7 @@ class Blocks { const blocks: any[] = []; while (fromHeight <= toHeight) { - let block: any = await blocksRepository.$getBlockByHeight(fromHeight); + let block: BlockExtended | null = await blocksRepository.$getBlockByHeight(fromHeight); if (!block) { await this.$indexBlock(fromHeight); block = await blocksRepository.$getBlockByHeight(fromHeight); @@ -803,11 +791,11 @@ class Blocks { const cleanBlock: any = { height: block.height ?? null, hash: block.id ?? null, - timestamp: block.blockTimestamp ?? null, - median_timestamp: block.medianTime ?? null, + timestamp: block.timestamp ?? null, + median_timestamp: block.mediantime ?? null, previous_block_hash: block.previousblockhash ?? null, difficulty: block.difficulty ?? null, - header: block.header ?? null, + header: block.extras.header ?? null, version: block.version ?? null, bits: block.bits ?? null, nonce: block.nonce ?? null, @@ -815,29 +803,30 @@ class Blocks { weight: block.weight ?? null, tx_count: block.tx_count ?? null, merkle_root: block.merkle_root ?? null, - reward: block.reward ?? null, - total_fee_amt: block.fees ?? null, - avg_fee_amt: block.avg_fee ?? null, - median_fee_amt: block.median_fee_amt ?? null, - fee_amt_percentiles: block.fee_percentiles ?? null, - avg_fee_rate: block.avg_fee_rate ?? null, - median_fee_rate: block.median_fee ?? null, - fee_rate_percentiles: block.fee_span ?? null, - total_inputs: block.total_inputs ?? null, - total_input_amt: block.total_input_amt ?? null, - total_outputs: block.total_outputs ?? null, - total_output_amt: block.total_output_amt ?? null, - segwit_total_txs: block.segwit_total_txs ?? null, - segwit_total_size: block.segwit_total_size ?? null, - segwit_total_weight: block.segwit_total_weight ?? null, - avg_tx_size: block.avg_tx_size ?? null, - utxoset_change: block.utxoset_change ?? null, - utxoset_size: block.utxoset_size ?? null, - coinbase_raw: block.coinbase_raw ?? null, - coinbase_address: block.coinbase_address ?? null, - coinbase_signature: block.coinbase_signature ?? null, - coinbase_signature_ascii: block.coinbase_signature_ascii ?? null, - pool_slug: block.pool_slug ?? null, + reward: block.extras.reward ?? null, + total_fee_amt: block.extras.totalFees ?? null, + avg_fee_amt: block.extras.avgFee ?? null, + median_fee_amt: block.extras.medianFeeAmt ?? null, + fee_amt_percentiles: block.extras.feePercentiles ?? null, + avg_fee_rate: block.extras.avgFeeRate ?? null, + median_fee_rate: block.extras.medianFee ?? null, + fee_rate_percentiles: block.extras.feeRange ?? null, + total_inputs: block.extras.totalInputs ?? null, + total_input_amt: block.extras.totalInputAmt ?? null, + total_outputs: block.extras.totalOutputs ?? null, + total_output_amt: block.extras.totalOutputAmt ?? null, + segwit_total_txs: block.extras.segwitTotalTxs ?? null, + segwit_total_size: block.extras.segwitTotalSize ?? null, + segwit_total_weight: block.extras.segwitTotalWeight ?? null, + avg_tx_size: block.extras.avgTxSize ?? null, + utxoset_change: block.extras.utxoSetChange ?? null, + utxoset_size: block.extras.utxoSetSize ?? null, + coinbase_raw: block.extras.coinbaseRaw ?? null, + coinbase_address: block.extras.coinbaseAddress ?? null, + coinbase_signature: block.extras.coinbaseSignature ?? null, + coinbase_signature_ascii: block.extras.coinbaseSignatureAscii ?? null, + pool_slug: block.extras.pool.slug ?? null, + pool_id: block.extras.pool.id ?? null, }; if (Common.blocksSummariesIndexingEnabled() && cleanBlock.fee_amt_percentiles === null) { diff --git a/backend/src/api/chain-tips.ts b/backend/src/api/chain-tips.ts index 3384ebb19..b68b0b281 100644 --- a/backend/src/api/chain-tips.ts +++ b/backend/src/api/chain-tips.ts @@ -1,5 +1,5 @@ -import logger from "../logger"; -import bitcoinClient from "./bitcoin/bitcoin-client"; +import logger from '../logger'; +import bitcoinClient from './bitcoin/bitcoin-client'; export interface ChainTip { height: number; @@ -43,7 +43,11 @@ class ChainTips { } } - public getOrphanedBlocksAtHeight(height: number): OrphanedBlock[] { + public getOrphanedBlocksAtHeight(height: number | undefined): OrphanedBlock[] { + if (height === undefined) { + return []; + } + const orphans: OrphanedBlock[] = []; for (const block of this.orphanedBlocks) { if (block.height === height) { diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 954c1a17d..f762cfc2c 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -175,6 +175,7 @@ export class Common { case '1y': return '1 YEAR'; case '2y': return '2 YEAR'; case '3y': return '3 YEAR'; + case '4y': return '4 YEAR'; default: return null; } } @@ -237,14 +238,21 @@ export class Common { ].join('x'); } - static utcDateToMysql(date?: number): string { + static utcDateToMysql(date?: number | null): string | null { + if (date === null) { + return null; + } const d = new Date((date || 0) * 1000); return d.toISOString().split('T')[0] + ' ' + d.toTimeString().split(' ')[0]; } static findSocketNetwork(addr: string): {network: string | null, url: string} { let network: string | null = null; - let url = addr.split('://')[1]; + let url: string = addr; + + if (config.LIGHTNING.BACKEND === 'cln') { + url = addr.split('://')[1]; + } if (!url) { return { @@ -261,7 +269,7 @@ export class Common { } } else if (addr.indexOf('i2p') !== -1) { network = 'i2p'; - } else if (addr.indexOf('ipv4') !== -1) { + } else if (addr.indexOf('ipv4') !== -1 || (config.LIGHTNING.BACKEND === 'lnd' && isIP(url.split(':')[0]) === 4)) { const ipv = isIP(url.split(':')[0]); if (ipv === 4) { network = 'ipv4'; @@ -271,7 +279,7 @@ export class Common { url: addr, }; } - } else if (addr.indexOf('ipv6') !== -1) { + } else if (addr.indexOf('ipv6') !== -1 || (config.LIGHTNING.BACKEND === 'lnd' && url.indexOf(']:'))) { url = url.split('[')[1].split(']')[0]; const ipv = isIP(url); if (ipv === 6) { diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 13cffd755..1ef31c90b 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository'; import { RowDataPacket } from 'mysql2'; class DatabaseMigration { - private static currentVersion = 56; + private static currentVersion = 58; private queryTimeout = 3600_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -500,6 +500,16 @@ class DatabaseMigration { this.uniqueLog(logger.notice, '`pools` table has been truncated`'); await this.updateToSchemaVersion(56); } + + if (databaseSchemaVersion < 57 && isBitcoin === true) { + await this.$executeQuery(`ALTER TABLE nodes MODIFY updated_at datetime NULL`); + await this.updateToSchemaVersion(57); + } + + if (databaseSchemaVersion < 58) { + // We only run some migration queries for this version + await this.updateToSchemaVersion(58); + } } /** @@ -627,6 +637,11 @@ class DatabaseMigration { queries.push(`INSERT INTO state(name, number, string) VALUES ('last_weekly_hashrates_indexing', 0, NULL)`); } + if (version < 58) { + queries.push(`DELETE FROM state WHERE name = 'last_hashrates_indexing'`); + queries.push(`DELETE FROM state WHERE name = 'last_weekly_hashrates_indexing'`); + } + return queries; } @@ -1018,10 +1033,11 @@ class DatabaseMigration { await this.$executeQuery(`TRUNCATE blocks`); await this.$executeQuery(`TRUNCATE hashrates`); + await this.$executeQuery(`TRUNCATE difficulty_adjustments`); await this.$executeQuery('DELETE FROM `pools`'); await this.$executeQuery('ALTER TABLE pools AUTO_INCREMENT = 1'); await this.$executeQuery(`UPDATE state SET string = NULL WHERE name = 'pools_json_sha'`); -} + } private async $convertCompactCpfpTables(): Promise { try { diff --git a/backend/src/api/difficulty-adjustment.ts b/backend/src/api/difficulty-adjustment.ts index a1b6ab70e..c4e2abf31 100644 --- a/backend/src/api/difficulty-adjustment.ts +++ b/backend/src/api/difficulty-adjustment.ts @@ -9,9 +9,11 @@ export interface DifficultyAdjustment { remainingBlocks: number; // Block count remainingTime: number; // Duration of time in ms previousRetarget: number; // Percent: -75 to 300 + previousTime: number; // Unix time in ms nextRetargetHeight: number; // Block Height timeAvg: number; // Duration of time in ms timeOffset: number; // (Testnet) Time since last block (cap @ 20min) in ms + expectedBlocks: number; // Block count } export function calcDifficultyAdjustment( @@ -32,12 +34,12 @@ export function calcDifficultyAdjustment( const progressPercent = (blockHeight >= 0) ? blocksInEpoch / EPOCH_BLOCK_LENGTH * 100 : 100; const remainingBlocks = EPOCH_BLOCK_LENGTH - blocksInEpoch; const nextRetargetHeight = (blockHeight >= 0) ? blockHeight + remainingBlocks : 0; + const expectedBlocks = diffSeconds / BLOCK_SECONDS_TARGET; let difficultyChange = 0; - let timeAvgSecs = BLOCK_SECONDS_TARGET; + let timeAvgSecs = diffSeconds / blocksInEpoch; // Only calculate the estimate once we have 7.2% of blocks in current epoch if (blocksInEpoch >= ESTIMATE_LAG_BLOCKS) { - timeAvgSecs = diffSeconds / blocksInEpoch; difficultyChange = (BLOCK_SECONDS_TARGET / timeAvgSecs - 1) * 100; // Max increase is x4 (+300%) if (difficultyChange > 300) { @@ -74,9 +76,11 @@ export function calcDifficultyAdjustment( remainingBlocks, remainingTime, previousRetarget, + previousTime: DATime, nextRetargetHeight, timeAvg, timeOffset, + expectedBlocks, }; } diff --git a/backend/src/api/disk-cache.ts b/backend/src/api/disk-cache.ts index a75fd43cc..af04d5acb 100644 --- a/backend/src/api/disk-cache.ts +++ b/backend/src/api/disk-cache.ts @@ -9,21 +9,35 @@ import { TransactionExtended } from '../mempool.interfaces'; import { Common } from './common'; class DiskCache { - private cacheSchemaVersion = 2; + private cacheSchemaVersion = 3; + private static TMP_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/tmp-cache.json'; + private static TMP_FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/tmp-cache{number}.json'; private static FILE_NAME = config.MEMPOOL.CACHE_DIR + '/cache.json'; private static FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/cache{number}.json'; private static CHUNK_FILES = 25; private isWritingCache = false; - constructor() { } + constructor() { + if (!cluster.isMaster) { + return; + } + process.on('SIGINT', (e) => { + this.saveCacheToDiskSync(); + process.exit(2); + }); + process.on('SIGTERM', (e) => { + this.saveCacheToDiskSync(); + process.exit(2); + }); + } async $saveCacheToDisk(): Promise { if (!cluster.isPrimary) { return; } if (this.isWritingCache) { - logger.debug('Saving cache already in progress. Skipping.') + logger.debug('Saving cache already in progress. Skipping.'); return; } try { @@ -61,14 +75,78 @@ class DiskCache { } } - wipeCache() { - fs.unlinkSync(DiskCache.FILE_NAME); - for (let i = 1; i < DiskCache.CHUNK_FILES; i++) { - fs.unlinkSync(DiskCache.FILE_NAMES.replace('{number}', i.toString())); + saveCacheToDiskSync(): void { + if (!cluster.isPrimary) { + return; + } + if (this.isWritingCache) { + logger.debug('Saving cache already in progress. Skipping.'); + return; + } + try { + logger.debug('Writing mempool and blocks data to disk cache (sync)...'); + this.isWritingCache = true; + + const mempool = memPool.getMempool(); + const mempoolArray: TransactionExtended[] = []; + for (const tx in mempool) { + mempoolArray.push(mempool[tx]); + } + + Common.shuffleArray(mempoolArray); + + const chunkSize = Math.floor(mempoolArray.length / DiskCache.CHUNK_FILES); + + fs.writeFileSync(DiskCache.TMP_FILE_NAME, JSON.stringify({ + cacheSchemaVersion: this.cacheSchemaVersion, + blocks: blocks.getBlocks(), + blockSummaries: blocks.getBlockSummaries(), + mempool: {}, + mempoolArray: mempoolArray.splice(0, chunkSize), + }), { flag: 'w' }); + for (let i = 1; i < DiskCache.CHUNK_FILES; i++) { + fs.writeFileSync(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), JSON.stringify({ + mempool: {}, + mempoolArray: mempoolArray.splice(0, chunkSize), + }), { flag: 'w' }); + } + + fs.renameSync(DiskCache.TMP_FILE_NAME, DiskCache.FILE_NAME); + for (let i = 1; i < DiskCache.CHUNK_FILES; i++) { + fs.renameSync(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), DiskCache.FILE_NAMES.replace('{number}', i.toString())); + } + + logger.debug('Mempool and blocks data saved to disk cache'); + this.isWritingCache = false; + } catch (e) { + logger.warn('Error writing to cache file: ' + (e instanceof Error ? e.message : e)); + this.isWritingCache = false; } } - loadMempoolCache() { + wipeCache(): void { + logger.notice(`Wiping nodejs backend cache/cache*.json files`); + try { + fs.unlinkSync(DiskCache.FILE_NAME); + } catch (e: any) { + if (e?.code !== 'ENOENT') { + logger.err(`Cannot wipe cache file ${DiskCache.FILE_NAME}. Exception ${JSON.stringify(e)}`); + } + } + + for (let i = 1; i < DiskCache.CHUNK_FILES; i++) { + const filename = DiskCache.FILE_NAMES.replace('{number}', i.toString()); + try { + fs.unlinkSync(filename); + } catch (e: any) { + if (e?.code !== 'ENOENT') { + logger.err(`Cannot wipe cache file ${filename}. Exception ${JSON.stringify(e)}`); + } + } + } + } + + loadMempoolCache(): void { if (!fs.existsSync(DiskCache.FILE_NAME)) { return; } diff --git a/backend/src/api/explorer/channels.api.ts b/backend/src/api/explorer/channels.api.ts index 8314b3345..00d146770 100644 --- a/backend/src/api/explorer/channels.api.ts +++ b/backend/src/api/explorer/channels.api.ts @@ -559,6 +559,17 @@ class ChannelsApi { const policy1: Partial = channel.node1_policy || {}; const policy2: Partial = channel.node2_policy || {}; + // https://github.com/mempool/mempool/issues/3006 + if ((channel.last_update ?? 0) < 1514736061) { // January 1st 2018 + channel.last_update = null; + } + if ((policy1.last_update ?? 0) < 1514736061) { // January 1st 2018 + policy1.last_update = null; + } + if ((policy2.last_update ?? 0) < 1514736061) { // January 1st 2018 + policy2.last_update = null; + } + const query = `INSERT INTO channels ( id, diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index b3f83faa6..d86ecf665 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -228,7 +228,7 @@ class NodesApi { nodes.capacity FROM nodes ORDER BY capacity DESC - LIMIT 100 + LIMIT 6 `; [rows] = await DB.query(query); @@ -269,14 +269,26 @@ class NodesApi { let query: string; if (full === false) { query = ` - SELECT nodes.public_key as publicKey, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias, - nodes.channels + SELECT + nodes.public_key as publicKey, + IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias, + nodes.channels, + geo_names_city.names as city, geo_names_country.names as country, + geo_names_iso.names as iso_code, geo_names_subdivision.names as subdivision FROM nodes + LEFT JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country' + LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city' + LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code' + LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division' ORDER BY channels DESC - LIMIT 100; + LIMIT 6; `; [rows] = await DB.query(query); + for (let i = 0; i < rows.length; ++i) { + rows[i].country = JSON.parse(rows[i].country); + rows[i].city = JSON.parse(rows[i].city); + } } else { query = ` SELECT nodes.public_key AS publicKey, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias, @@ -405,24 +417,24 @@ class NodesApi { if (!ispList[isp1]) { ispList[isp1] = { - id: channel.isp1ID.toString(), + ids: [channel.isp1ID], capacity: 0, channels: 0, nodes: {}, }; - } else if (ispList[isp1].id.indexOf(channel.isp1ID) === -1) { - ispList[isp1].id += ',' + channel.isp1ID.toString(); + } else if (ispList[isp1].ids.includes(channel.isp1ID) === false) { + ispList[isp1].ids.push(channel.isp1ID); } if (!ispList[isp2]) { ispList[isp2] = { - id: channel.isp2ID.toString(), + ids: [channel.isp2ID], capacity: 0, channels: 0, nodes: {}, }; - } else if (ispList[isp2].id.indexOf(channel.isp2ID) === -1) { - ispList[isp2].id += ',' + channel.isp2ID.toString(); + } else if (ispList[isp2].ids.includes(channel.isp2ID) === false) { + ispList[isp2].ids.push(channel.isp2ID); } ispList[isp1].capacity += channel.capacity; @@ -432,11 +444,11 @@ class NodesApi { ispList[isp2].channels += 1; ispList[isp2].nodes[channel.node2PublicKey] = true; } - + const ispRanking: any[] = []; for (const isp of Object.keys(ispList)) { ispRanking.push([ - ispList[isp].id, + ispList[isp].ids.sort((a, b) => a - b).join(','), isp, ispList[isp].capacity, ispList[isp].channels, @@ -630,6 +642,11 @@ class NodesApi { */ public async $saveNode(node: ILightningApi.Node): Promise { try { + // https://github.com/mempool/mempool/issues/3006 + if ((node.last_update ?? 0) < 1514736061) { // January 1st 2018 + node.last_update = null; + } + const sockets = (node.addresses?.map(a => a.addr).join(',')) ?? ''; const query = `INSERT INTO nodes( public_key, diff --git a/backend/src/api/lightning/lightning-api.interface.ts b/backend/src/api/lightning/lightning-api.interface.ts index 453e2fffc..cd5cb973d 100644 --- a/backend/src/api/lightning/lightning-api.interface.ts +++ b/backend/src/api/lightning/lightning-api.interface.ts @@ -21,7 +21,7 @@ export namespace ILightningApi { export interface Channel { channel_id: string; chan_point: string; - last_update: number; + last_update: number | null; node1_pub: string; node2_pub: string; capacity: string; @@ -36,11 +36,11 @@ export namespace ILightningApi { fee_rate_milli_msat: string; disabled: boolean; max_htlc_msat: string; - last_update: number; + last_update: number | null; } export interface Node { - last_update: number; + last_update: number | null; pub_key: string; alias: string; addresses: { diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index 0df125d55..3c2feb0e2 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -97,14 +97,14 @@ class MempoolBlocks { blockSize += tx.size; transactions.push(tx); } else { - mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length)); + mempoolBlocks.push(this.dataToMempoolBlocks(transactions, mempoolBlocks.length)); blockWeight = tx.weight; blockSize = tx.size; transactions = [tx]; } }); if (transactions.length) { - mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length)); + mempoolBlocks.push(this.dataToMempoolBlocks(transactions, mempoolBlocks.length)); } return mempoolBlocks; @@ -281,7 +281,7 @@ class MempoolBlocks { const mempoolBlocks = blocks.map((transactions, blockIndex) => { return this.dataToMempoolBlocks(transactions.map(tx => { return mempool[tx.txid] || null; - }).filter(tx => !!tx), undefined, undefined, blockIndex); + }).filter(tx => !!tx), blockIndex); }); if (saveResults) { @@ -293,18 +293,17 @@ class MempoolBlocks { return mempoolBlocks; } - private dataToMempoolBlocks(transactions: TransactionExtended[], - blockSize: number | undefined, blockWeight: number | undefined, blocksIndex: number): MempoolBlockWithTransactions { - let totalSize = blockSize || 0; - let totalWeight = blockWeight || 0; - if (blockSize === undefined && blockWeight === undefined) { - totalSize = 0; - totalWeight = 0; - transactions.forEach(tx => { - totalSize += tx.size; - totalWeight += tx.weight; - }); - } + private dataToMempoolBlocks(transactions: TransactionExtended[], blocksIndex: number): MempoolBlockWithTransactions { + let totalSize = 0; + let totalWeight = 0; + const fitTransactions: TransactionExtended[] = []; + transactions.forEach(tx => { + totalSize += tx.size; + totalWeight += tx.weight; + if ((totalWeight + tx.weight) <= config.MEMPOOL.BLOCK_WEIGHT_UNITS * 1.2) { + fitTransactions.push(tx); + } + }); let rangeLength = 4; if (blocksIndex === 0) { rangeLength = 8; @@ -322,7 +321,7 @@ class MempoolBlocks { medianFee: Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE), feeRange: Common.getFeesInRange(transactions, rangeLength), transactionIds: transactions.map((tx) => tx.txid), - transactions: transactions.map((tx) => Common.stripTransaction(tx)), + transactions: fitTransactions.map((tx) => Common.stripTransaction(tx)), }; } } diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 4c475502c..db5de82b2 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -31,6 +31,11 @@ class Mempool { private mempoolProtection = 0; private latestTransactions: any[] = []; + private ESPLORA_MISSING_TX_WARNING_THRESHOLD = 100; + private SAMPLE_TIME = 10000; // In ms + private timer = new Date().getTime(); + private missingTxCount = 0; + constructor() { setInterval(this.updateTxPerSecond.bind(this), 1000); setInterval(this.deleteExpiredTransactions.bind(this), 20000); @@ -128,6 +133,16 @@ class Mempool { loadingIndicators.setProgress('mempool', Object.keys(this.mempoolCache).length / transactions.length * 100); } + // https://github.com/mempool/mempool/issues/3283 + const logEsplora404 = (missingTxCount, threshold, time) => { + const log = `In the past ${time / 1000} seconds, esplora tx API replied ${missingTxCount} times with a 404 error code while updating nodejs backend mempool`; + if (missingTxCount >= threshold) { + logger.warn(log); + } else if (missingTxCount > 0) { + logger.debug(log); + } + }; + for (const txid of transactions) { if (!this.mempoolCache[txid]) { try { @@ -142,7 +157,10 @@ class Mempool { } hasChange = true; newTransactions.push(transaction); - } catch (e) { + } catch (e: any) { + if (config.MEMPOOL.BACKEND === 'esplora' && e.response?.status === 404) { + this.missingTxCount++; + } logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e)); } } @@ -152,6 +170,14 @@ class Mempool { } } + // Reset esplora 404 counter and log a warning if needed + const elapsedTime = new Date().getTime() - this.timer; + if (elapsedTime > this.SAMPLE_TIME) { + logEsplora404(this.missingTxCount, this.ESPLORA_MISSING_TX_WARNING_THRESHOLD, elapsedTime); + this.timer = new Date().getTime(); + this.missingTxCount = 0; + } + // Prevent mempool from clear on bitcoind restart by delaying the deletion if (this.mempoolProtection === 0 && currentMempoolSize > 20000 diff --git a/backend/src/api/mining/mining-routes.ts b/backend/src/api/mining/mining-routes.ts index f7f392068..0198f9ab4 100644 --- a/backend/src/api/mining/mining-routes.ts +++ b/backend/src/api/mining/mining-routes.ts @@ -263,7 +263,7 @@ class MiningRoutes { const audit = await BlocksAuditsRepository.$getBlockAudit(req.params.hash); if (!audit) { - res.status(404).send(`This block has not been audited.`); + res.status(204).send(`This block has not been audited.`); return; } diff --git a/backend/src/api/mining/mining.ts b/backend/src/api/mining/mining.ts index f33a68dcb..8b4abb0d6 100644 --- a/backend/src/api/mining/mining.ts +++ b/backend/src/api/mining/mining.ts @@ -11,12 +11,13 @@ import DifficultyAdjustmentsRepository from '../../repositories/DifficultyAdjust import config from '../../config'; import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository'; import PricesRepository from '../../repositories/PricesRepository'; +import { bitcoinCoreApi } from '../bitcoin/bitcoin-api-factory'; +import { IEsploraApi } from '../bitcoin/esplora-api.interface'; class Mining { - blocksPriceIndexingRunning = false; - - constructor() { - } + private blocksPriceIndexingRunning = false; + public lastHashrateIndexingDate: number | null = null; + public lastWeeklyHashrateIndexingDate: number | null = null; /** * Get historical block predictions match rate @@ -116,7 +117,7 @@ class Mining { poolsStatistics['lastEstimatedHashrate'] = await bitcoinClient.getNetworkHashPs(totalBlock24h); } catch (e) { poolsStatistics['lastEstimatedHashrate'] = 0; - logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate'); + logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate', logger.tags.mining); } return poolsStatistics; @@ -144,7 +145,7 @@ class Mining { try { currentEstimatedHashrate = await bitcoinClient.getNetworkHashPs(totalBlock24h); } catch (e) { - logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate'); + logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate', logger.tags.mining); } return { @@ -176,21 +177,22 @@ class Mining { */ public async $generatePoolHashrateHistory(): Promise { const now = new Date(); - const lastestRunDate = await HashratesRepository.$getLatestRun('last_weekly_hashrates_indexing'); // Run only if: - // * lastestRunDate is set to 0 (node backend restart, reorg) + // * this.lastWeeklyHashrateIndexingDate is set to null (node backend restart, reorg) // * we started a new week (around Monday midnight) - const runIndexing = lastestRunDate === 0 || now.getUTCDay() === 1 && lastestRunDate !== now.getUTCDate(); + const runIndexing = this.lastWeeklyHashrateIndexingDate === null || + now.getUTCDay() === 1 && this.lastWeeklyHashrateIndexingDate !== now.getUTCDate(); if (!runIndexing) { + logger.debug(`Pool hashrate history indexing is up to date, nothing to do`, logger.tags.mining); return; } try { const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp; - const genesisBlock = await bitcoinClient.getBlock(await bitcoinClient.getBlockHash(0)); - const genesisTimestamp = genesisBlock.time * 1000; + const genesisBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(await bitcoinClient.getBlockHash(0)); + const genesisTimestamp = genesisBlock.timestamp * 1000; const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps(); const hashrates: any[] = []; @@ -206,7 +208,7 @@ class Mining { const startedAt = new Date().getTime() / 1000; let timer = new Date().getTime() / 1000; - logger.debug(`Indexing weekly mining pool hashrate`); + logger.debug(`Indexing weekly mining pool hashrate`, logger.tags.mining); loadingIndicators.setProgress('weekly-hashrate-indexing', 0); while (toTimestamp > genesisTimestamp && toTimestamp > oldestConsecutiveBlockTimestamp) { @@ -243,7 +245,7 @@ class Mining { }); } - newlyIndexed += hashrates.length; + newlyIndexed += hashrates.length / Math.max(1, pools.length); await HashratesRepository.$saveHashrates(hashrates); hashrates.length = 0; } @@ -254,7 +256,7 @@ class Mining { const weeksPerSeconds = Math.max(1, Math.round(indexedThisRun / elapsedSeconds)); const progress = Math.round(totalIndexed / totalWeekIndexed * 10000) / 100; const formattedDate = new Date(fromTimestamp).toUTCString(); - logger.debug(`Getting weekly pool hashrate for ${formattedDate} | ~${weeksPerSeconds.toFixed(2)} weeks/sec | total: ~${totalIndexed}/${Math.round(totalWeekIndexed)} (${progress}%) | elapsed: ${runningFor} seconds`); + logger.debug(`Getting weekly pool hashrate for ${formattedDate} | ~${weeksPerSeconds.toFixed(2)} weeks/sec | total: ~${totalIndexed}/${Math.round(totalWeekIndexed)} (${progress}%) | elapsed: ${runningFor} seconds`, logger.tags.mining); timer = new Date().getTime() / 1000; indexedThisRun = 0; loadingIndicators.setProgress('weekly-hashrate-indexing', progress, false); @@ -264,16 +266,16 @@ class Mining { ++indexedThisRun; ++totalIndexed; } - await HashratesRepository.$setLatestRun('last_weekly_hashrates_indexing', new Date().getUTCDate()); + this.lastWeeklyHashrateIndexingDate = new Date().getUTCDate(); if (newlyIndexed > 0) { - logger.notice(`Weekly mining pools hashrates indexing completed: indexed ${newlyIndexed}`, logger.tags.mining); + logger.info(`Weekly mining pools hashrates indexing completed: indexed ${newlyIndexed} weeks`, logger.tags.mining); } else { - logger.debug(`Weekly mining pools hashrates indexing completed: indexed ${newlyIndexed}`, logger.tags.mining); + logger.debug(`Weekly mining pools hashrates indexing completed: indexed ${newlyIndexed} weeks`, logger.tags.mining); } loadingIndicators.setProgress('weekly-hashrate-indexing', 100); } catch (e) { loadingIndicators.setProgress('weekly-hashrate-indexing', 100); - logger.err(`Weekly mining pools hashrates indexing failed. Trying again in 10 seconds. Reason: ${(e instanceof Error ? e.message : e)}`); + logger.err(`Weekly mining pools hashrates indexing failed. Trying again in 10 seconds. Reason: ${(e instanceof Error ? e.message : e)}`, logger.tags.mining); throw e; } } @@ -283,17 +285,17 @@ class Mining { */ public async $generateNetworkHashrateHistory(): Promise { // We only run this once a day around midnight - const latestRunDate = await HashratesRepository.$getLatestRun('last_hashrates_indexing'); - const now = new Date().getUTCDate(); - if (now === latestRunDate) { + const today = new Date().getUTCDate(); + if (today === this.lastHashrateIndexingDate) { + logger.debug(`Network hashrate history indexing is up to date, nothing to do`, logger.tags.mining); return; } const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp; try { - const genesisBlock = await bitcoinClient.getBlock(await bitcoinClient.getBlockHash(0)); - const genesisTimestamp = genesisBlock.time * 1000; + const genesisBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(await bitcoinClient.getBlockHash(0)); + const genesisTimestamp = genesisBlock.timestamp * 1000; const indexedTimestamp = (await HashratesRepository.$getRawNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp); const lastMidnight = this.getDateMidnight(new Date()); let toTimestamp = Math.round(lastMidnight.getTime()); @@ -306,7 +308,7 @@ class Mining { const startedAt = new Date().getTime() / 1000; let timer = new Date().getTime() / 1000; - logger.debug(`Indexing daily network hashrate`); + logger.debug(`Indexing daily network hashrate`, logger.tags.mining); loadingIndicators.setProgress('daily-hashrate-indexing', 0); while (toTimestamp > genesisTimestamp && toTimestamp > oldestConsecutiveBlockTimestamp) { @@ -344,7 +346,7 @@ class Mining { const daysPerSeconds = Math.max(1, Math.round(indexedThisRun / elapsedSeconds)); const progress = Math.round(totalIndexed / totalDayIndexed * 10000) / 100; const formattedDate = new Date(fromTimestamp).toUTCString(); - logger.debug(`Getting network daily hashrate for ${formattedDate} | ~${daysPerSeconds.toFixed(2)} days/sec | total: ~${totalIndexed}/${Math.round(totalDayIndexed)} (${progress}%) | elapsed: ${runningFor} seconds`); + logger.debug(`Getting network daily hashrate for ${formattedDate} | ~${daysPerSeconds.toFixed(2)} days/sec | total: ~${totalIndexed}/${Math.round(totalDayIndexed)} (${progress}%) | elapsed: ${runningFor} seconds`, logger.tags.mining); timer = new Date().getTime() / 1000; indexedThisRun = 0; loadingIndicators.setProgress('daily-hashrate-indexing', progress); @@ -369,16 +371,16 @@ class Mining { newlyIndexed += hashrates.length; await HashratesRepository.$saveHashrates(hashrates); - await HashratesRepository.$setLatestRun('last_hashrates_indexing', new Date().getUTCDate()); + this.lastHashrateIndexingDate = new Date().getUTCDate(); if (newlyIndexed > 0) { - logger.notice(`Daily network hashrate indexing completed: indexed ${newlyIndexed} days`, logger.tags.mining); + logger.info(`Daily network hashrate indexing completed: indexed ${newlyIndexed} days`, logger.tags.mining); } else { logger.debug(`Daily network hashrate indexing completed: indexed ${newlyIndexed} days`, logger.tags.mining); } loadingIndicators.setProgress('daily-hashrate-indexing', 100); } catch (e) { loadingIndicators.setProgress('daily-hashrate-indexing', 100); - logger.err(`Daily network hashrate indexing failed. Trying again in 10 seconds. Reason: ${(e instanceof Error ? e.message : e)}`, logger.tags.mining); + logger.err(`Daily network hashrate indexing failed. Trying again later. Reason: ${(e instanceof Error ? e.message : e)}`, logger.tags.mining); throw e; } } @@ -394,13 +396,13 @@ class Mining { } const blocks: any = await BlocksRepository.$getBlocksDifficulty(); - const genesisBlock = await bitcoinClient.getBlock(await bitcoinClient.getBlockHash(0)); + const genesisBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(await bitcoinClient.getBlockHash(0)); let currentDifficulty = genesisBlock.difficulty; let totalIndexed = 0; if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === -1 && indexedHeights[0] !== true) { await DifficultyAdjustmentsRepository.$saveAdjustments({ - time: genesisBlock.time, + time: genesisBlock.timestamp, height: 0, difficulty: currentDifficulty, adjustment: 0.0, @@ -444,13 +446,13 @@ class Mining { const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer)); if (elapsedSeconds > 5) { const progress = Math.round(totalBlockChecked / blocks.length * 100); - logger.info(`Indexing difficulty adjustment at block #${block.height} | Progress: ${progress}%`); + logger.info(`Indexing difficulty adjustment at block #${block.height} | Progress: ${progress}%`, logger.tags.mining); timer = new Date().getTime() / 1000; } } if (totalIndexed > 0) { - logger.notice(`Indexed ${totalIndexed} difficulty adjustments`, logger.tags.mining); + logger.info(`Indexed ${totalIndexed} difficulty adjustments`, logger.tags.mining); } else { logger.debug(`Indexed ${totalIndexed} difficulty adjustments`, logger.tags.mining); } @@ -497,7 +499,7 @@ class Mining { if (blocksWithoutPrices.length > 200000) { logStr += ` | Progress ${Math.round(totalInserted / blocksWithoutPrices.length * 100)}%`; } - logger.debug(logStr); + logger.debug(logStr, logger.tags.mining); await BlocksRepository.$saveBlockPrices(blocksPrices); blocksPrices.length = 0; } @@ -509,7 +511,7 @@ class Mining { if (blocksWithoutPrices.length > 200000) { logStr += ` | Progress ${Math.round(totalInserted / blocksWithoutPrices.length * 100)}%`; } - logger.debug(logStr); + logger.debug(logStr, logger.tags.mining); await BlocksRepository.$saveBlockPrices(blocksPrices); } } catch (e) { @@ -566,6 +568,7 @@ class Mining { private getTimeRange(interval: string | null, scale = 1): number { switch (interval) { + case '4y': return 43200 * scale; // 12h case '3y': return 43200 * scale; // 12h case '2y': return 28800 * scale; // 8h case '1y': return 28800 * scale; // 8h diff --git a/backend/src/api/pools-parser.ts b/backend/src/api/pools-parser.ts index b34dcb7b8..f94c147a2 100644 --- a/backend/src/api/pools-parser.ts +++ b/backend/src/api/pools-parser.ts @@ -3,10 +3,12 @@ import logger from '../logger'; import config from '../config'; import PoolsRepository from '../repositories/PoolsRepository'; import { PoolTag } from '../mempool.interfaces'; +import diskCache from './disk-cache'; class PoolsParser { miningPools: any[] = []; unknownPool: any = { + 'id': 0, 'name': 'Unknown', 'link': 'https://learnmeabitcoin.com/technical/coinbase-transaction', 'regexes': '[]', @@ -26,6 +28,7 @@ class PoolsParser { public setMiningPools(pools): void { for (const pool of pools) { pool.regexes = pool.tags; + pool.slug = pool.name.replace(/[^a-z0-9]/gi, '').toLowerCase(); delete(pool.tags); } this.miningPools = pools; @@ -36,6 +39,10 @@ class PoolsParser { * @param pools */ public async migratePoolsJson(): Promise { + // We also need to wipe the backend cache to make sure we don't serve blocks with + // the wrong mining pool (usually happen with unknown blocks) + diskCache.wipeCache(); + await this.$insertUnknownPool(); for (const pool of this.miningPools) { diff --git a/backend/src/api/statistics/statistics-api.ts b/backend/src/api/statistics/statistics-api.ts index 56a868f8f..1e8b0b7bb 100644 --- a/backend/src/api/statistics/statistics-api.ts +++ b/backend/src/api/statistics/statistics-api.ts @@ -375,6 +375,17 @@ class StatisticsApi { } } + public async $list4Y(): Promise { + try { + const query = this.getQueryForDays(43200, '4 YEAR'); // 12h interval + const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout }); + return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); + } catch (e) { + logger.err('$list4Y() error' + (e instanceof Error ? e.message : e)); + return []; + } + } + private mapStatisticToOptimizedStatistic(statistic: Statistic[]): OptimizedStatistic[] { return statistic.map((s) => { return { diff --git a/backend/src/api/statistics/statistics.routes.ts b/backend/src/api/statistics/statistics.routes.ts index 4b1b91ce9..2a5871dd6 100644 --- a/backend/src/api/statistics/statistics.routes.ts +++ b/backend/src/api/statistics/statistics.routes.ts @@ -14,10 +14,11 @@ class StatisticsRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1y', this.$getStatisticsByTime.bind(this, '1y')) .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2y', this.$getStatisticsByTime.bind(this, '2y')) .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3y', this.$getStatisticsByTime.bind(this, '3y')) + .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/4y', this.$getStatisticsByTime.bind(this, '4y')) ; } - private async $getStatisticsByTime(time: '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y', req: Request, res: Response) { + private async $getStatisticsByTime(time: '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y' | '4y', req: Request, res: Response) { res.header('Pragma', 'public'); res.header('Cache-control', 'public'); res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); @@ -54,6 +55,9 @@ class StatisticsRoutes { case '3y': result = await statisticsApi.$list3Y(); break; + case '4y': + result = await statisticsApi.$list4Y(); + break; default: result = await statisticsApi.$list2H(); } diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index c1c3b3995..a96264825 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -1,8 +1,8 @@ import logger from '../logger'; import * as WebSocket from 'ws'; import { - BlockExtended, TransactionExtended, WebsocketResponse, MempoolBlock, MempoolBlockDelta, - OptimizedStatistic, ILoadingIndicators, IConversionRates + BlockExtended, TransactionExtended, WebsocketResponse, + OptimizedStatistic, ILoadingIndicators } from '../mempool.interfaces'; import blocks from './blocks'; import memPool from './mempool'; @@ -20,6 +20,7 @@ import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository import Audit from './audit'; import { deepClone } from '../utils/clone'; import priceUpdater from '../tasks/price-updater'; +import { ApiPrice } from '../repositories/PricesRepository'; class WebsocketHandler { private wss: WebSocket.Server | undefined; @@ -193,7 +194,7 @@ class WebsocketHandler { }); } - handleNewConversionRates(conversionRates: IConversionRates) { + handleNewConversionRates(conversionRates: ApiPrice) { if (!this.wss) { throw new Error('WebSocket.Server is not set'); } @@ -214,7 +215,7 @@ class WebsocketHandler { 'mempoolInfo': memPool.getMempoolInfo(), 'vBytesPerSecond': memPool.getVBytesPerSecond(), 'blocks': _blocks, - 'conversions': priceUpdater.latestPrices, + 'conversions': priceUpdater.getLatestPrices(), 'mempool-blocks': mempoolBlocks.getMempoolBlocks(), 'transactions': memPool.getLatestTransactions(), 'backendInfo': backendInfo.getBackendInfo(), diff --git a/backend/src/index.ts b/backend/src/index.ts index 6f259a2bd..fbe9c08c2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -38,6 +38,8 @@ import forensicsService from './tasks/lightning/forensics.service'; import priceUpdater from './tasks/price-updater'; import chainTips from './api/chain-tips'; import { AxiosError } from 'axios'; +import v8 from 'v8'; +import { formatBytes, getBytesUnit } from './utils/format'; class Server { private wss: WebSocket.Server | undefined; @@ -45,6 +47,11 @@ class Server { private app: Application; private currentBackendRetryInterval = 5; + private maxHeapSize: number = 0; + private heapLogInterval: number = 60; + private warnedHeapCritical: boolean = false; + private lastHeapLogTime: number | null = null; + constructor() { this.app = express(); @@ -87,9 +94,6 @@ class Server { await databaseMigration.$blocksReindexingTruncate(); } await databaseMigration.$initializeOrMigrateDatabase(); - if (Common.indexingEnabled()) { - await indexer.$resetHashratesIndexingState(); - } } catch (e) { throw new Error(e instanceof Error ? e.message : 'Error'); } @@ -113,6 +117,7 @@ class Server { this.setUpWebsocketHandling(); + await poolsUpdater.updatePoolsJson(); // Needs to be done before loading the disk cache because we sometimes wipe it await syncAssets.syncAssets$(); if (config.MEMPOOL.ENABLED) { diskCache.loadMempoolCache(); @@ -139,6 +144,8 @@ class Server { this.runMainUpdateLoop(); } + setInterval(() => { this.healthCheck(); }, 2500); + if (config.BISQ.ENABLED) { bisq.startBisqService(); bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitProperties('bsq-price', price)); @@ -171,7 +178,6 @@ class Server { logger.debug(msg); } } - await poolsUpdater.updatePoolsJson(); await blocks.$updateBlocks(); await memPool.$updateMempool(); indexer.$run(); @@ -179,7 +185,14 @@ class Server { setTimeout(this.runMainUpdateLoop.bind(this), config.MEMPOOL.POLL_RATE_MS); this.currentBackendRetryInterval = 5; } catch (e: any) { - const loggerMsg = `runMainLoop error: ${(e instanceof Error ? e.message : e)}. Retrying in ${this.currentBackendRetryInterval} sec.`; + let loggerMsg = `Exception in runMainUpdateLoop(). Retrying in ${this.currentBackendRetryInterval} sec.`; + loggerMsg += ` Reason: ${(e instanceof Error ? e.message : e)}.`; + if (e?.stack) { + loggerMsg += ` Stack trace: ${e.stack}`; + } + // When we get a first Exception, only `logger.debug` it and retry after 5 seconds + // From the second Exception, `logger.warn` the Exception and increase the retry delay + // Maximum retry delay is 60 seconds if (this.currentBackendRetryInterval > 5) { logger.warn(loggerMsg); mempool.setOutOfSync(); @@ -199,8 +212,8 @@ class Server { try { await fundingTxFetcher.$init(); await networkSyncService.$startService(); - await forensicsService.$startService(); await lightningStatsUpdater.$startService(); + await forensicsService.$startService(); } catch(e) { logger.err(`Nodejs lightning backend crashed. Restarting in 1 minute. Reason: ${(e instanceof Error ? e.message : e)}`); await Common.sleep$(1000 * 60); @@ -251,6 +264,26 @@ class Server { channelsRoutes.initRoutes(this.app); } } + + healthCheck(): void { + const now = Date.now(); + const stats = v8.getHeapStatistics(); + this.maxHeapSize = Math.max(stats.used_heap_size, this.maxHeapSize); + const warnThreshold = 0.8 * stats.heap_size_limit; + + const byteUnits = getBytesUnit(Math.max(this.maxHeapSize, stats.heap_size_limit)); + + if (!this.warnedHeapCritical && this.maxHeapSize > warnThreshold) { + this.warnedHeapCritical = true; + logger.warn(`Used ${(this.maxHeapSize / stats.heap_size_limit).toFixed(2)}% of heap limit (${formatBytes(this.maxHeapSize, byteUnits, true)} / ${formatBytes(stats.heap_size_limit, byteUnits)})!`); + } + if (this.lastHeapLogTime === null || (now - this.lastHeapLogTime) > (this.heapLogInterval * 1000)) { + logger.debug(`Memory usage: ${formatBytes(this.maxHeapSize, byteUnits)} / ${formatBytes(stats.heap_size_limit, byteUnits)}`); + this.warnedHeapCritical = false; + this.maxHeapSize = 0; + this.lastHeapLogTime = now; + } + } } ((): Server => new Server())(); diff --git a/backend/src/indexer.ts b/backend/src/indexer.ts index 41c8024e0..3b16ad155 100644 --- a/backend/src/indexer.ts +++ b/backend/src/indexer.ts @@ -3,7 +3,6 @@ import blocks from './api/blocks'; import mempool from './api/mempool'; import mining from './api/mining/mining'; import logger from './logger'; -import HashratesRepository from './repositories/HashratesRepository'; import bitcoinClient from './api/bitcoin/bitcoin-client'; import priceUpdater from './tasks/price-updater'; import PricesRepository from './repositories/PricesRepository'; @@ -77,13 +76,13 @@ class Indexer { this.tasksRunning.push(task); const lastestPriceId = await PricesRepository.$getLatestPriceId(); if (priceUpdater.historyInserted === false || lastestPriceId === null) { - logger.debug(`Blocks prices indexer is waiting for the price updater to complete`); + logger.debug(`Blocks prices indexer is waiting for the price updater to complete`, logger.tags.mining); setTimeout(() => { this.tasksRunning = this.tasksRunning.filter(runningTask => runningTask !== task); this.runSingleTask('blocksPrices'); }, 10000); } else { - logger.debug(`Blocks prices indexer will run now`); + logger.debug(`Blocks prices indexer will run now`, logger.tags.mining); await mining.$indexBlockPrices(); this.tasksRunning = this.tasksRunning.filter(runningTask => runningTask !== task); } @@ -113,7 +112,7 @@ class Indexer { this.runIndexer = false; this.indexerRunning = true; - logger.info(`Running mining indexer`); + logger.debug(`Running mining indexer`); await this.checkAvailableCoreIndexes(); @@ -123,7 +122,7 @@ class Indexer { const chainValid = await blocks.$generateBlockDatabase(); if (chainValid === false) { // Chain of block hash was invalid, so we need to reindex. Stop here and continue at the next iteration - logger.warn(`The chain of block hash is invalid, re-indexing invalid data in 10 seconds.`); + logger.warn(`The chain of block hash is invalid, re-indexing invalid data in 10 seconds.`, logger.tags.mining); setTimeout(() => this.reindex(), 10000); this.indexerRunning = false; return; @@ -131,7 +130,6 @@ class Indexer { this.runSingleTask('blocksPrices'); await mining.$indexDifficultyAdjustments(); - await this.$resetHashratesIndexingState(); // TODO - Remove this as it's not efficient await mining.$generateNetworkHashrateHistory(); await mining.$generatePoolHashrateHistory(); await blocks.$generateBlocksSummariesDatabase(); @@ -150,16 +148,6 @@ class Indexer { logger.debug(`Indexing completed. Next run planned at ${new Date(new Date().getTime() + runEvery).toUTCString()}`); setTimeout(() => this.reindex(), runEvery); } - - async $resetHashratesIndexingState(): Promise { - try { - await HashratesRepository.$setLatestRun('last_hashrates_indexing', 0); - await HashratesRepository.$setLatestRun('last_weekly_hashrates_indexing', 0); - } catch (e) { - logger.err(`Cannot reset hashrate indexing timestamps. Reason: ` + (e instanceof Error ? e.message : e)); - throw e; - } - } } export default new Indexer(); diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index cb95be98a..8662770bc 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -1,9 +1,10 @@ import { IEsploraApi } from './api/bitcoin/esplora-api.interface'; import { OrphanedBlock } from './api/chain-tips'; -import { HeapNode } from "./utils/pairing-heap"; +import { HeapNode } from './utils/pairing-heap'; export interface PoolTag { - id: number; // mysql row id + id: number; + uniqueId: number; name: string; link: string; regexes: string; // JSON array @@ -147,44 +148,44 @@ export interface TransactionStripped { } export interface BlockExtension { - totalFees?: number; - medianFee?: number; - feeRange?: number[]; - reward?: number; - coinbaseTx?: TransactionMinerInfo; - matchRate?: number; - pool?: { - id: number; + totalFees: number; + medianFee: number; // median fee rate + feeRange: number[]; // fee rate percentiles + reward: number; + matchRate: number | null; + pool: { + id: number; // Note - This is the `unique_id`, not to mix with the auto increment `id` name: string; slug: string; }; - avgFee?: number; - avgFeeRate?: number; - coinbaseRaw?: string; - usd?: number | null; - medianTimestamp?: number; - blockTime?: number; - orphans?: OrphanedBlock[] | null; - coinbaseAddress?: string | null; - coinbaseSignature?: string | null; - coinbaseSignatureAscii?: string | null; - virtualSize?: number; - avgTxSize?: number; - totalInputs?: number; - totalOutputs?: number; - totalOutputAmt?: number; - medianFeeAmt?: number | null; - feePercentiles?: number[] | null, - segwitTotalTxs?: number; - segwitTotalSize?: number; - segwitTotalWeight?: number; - header?: string; - utxoSetChange?: number; + avgFee: number; + avgFeeRate: number; + coinbaseRaw: string; + orphans: OrphanedBlock[] | null; + coinbaseAddress: string | null; + coinbaseSignature: string | null; + coinbaseSignatureAscii: string | null; + virtualSize: number; + avgTxSize: number; + totalInputs: number; + totalOutputs: number; + totalOutputAmt: number; + medianFeeAmt: number | null; // median fee in sats + feePercentiles: number[] | null, // fee percentiles in sats + segwitTotalTxs: number; + segwitTotalSize: number; + segwitTotalWeight: number; + header: string; + utxoSetChange: number; // Requires coinstatsindex, will be set to NULL otherwise - utxoSetSize?: number | null; - totalInputAmt?: number | null; + utxoSetSize: number | null; + totalInputAmt: number | null; } +/** + * Note: Everything that is added in here will be automatically returned through + * /api/v1/block and /api/v1/blocks APIs + */ export interface BlockExtended extends IEsploraApi.Block { extras: BlockExtension; } @@ -292,7 +293,6 @@ interface RequiredParams { } export interface ILoadingIndicators { [name: string]: number; } -export interface IConversionRates { [currency: string]: number; } export interface IBackendInfo { hostname: string; diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index c7edb97cb..f2d0a283e 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -1,8 +1,7 @@ -import { BlockExtended, BlockPrice } from '../mempool.interfaces'; +import { BlockExtended, BlockExtension, BlockPrice } from '../mempool.interfaces'; import DB from '../database'; import logger from '../logger'; import { Common } from '../api/common'; -import { prepareBlock } from '../utils/blocks-utils'; import PoolsRepository from './PoolsRepository'; import HashratesRepository from './HashratesRepository'; import { escape } from 'mysql2'; @@ -10,6 +9,51 @@ import BlocksSummariesRepository from './BlocksSummariesRepository'; import DifficultyAdjustmentsRepository from './DifficultyAdjustmentsRepository'; import bitcoinClient from '../api/bitcoin/bitcoin-client'; import config from '../config'; +import chainTips from '../api/chain-tips'; +import blocks from '../api/blocks'; +import BlocksAuditsRepository from './BlocksAuditsRepository'; + +const BLOCK_DB_FIELDS = ` + blocks.hash AS id, + blocks.height, + blocks.version, + UNIX_TIMESTAMP(blocks.blockTimestamp) AS timestamp, + blocks.bits, + blocks.nonce, + blocks.difficulty, + blocks.merkle_root, + blocks.tx_count, + blocks.size, + blocks.weight, + blocks.previous_block_hash AS previousblockhash, + UNIX_TIMESTAMP(blocks.median_timestamp) AS mediantime, + blocks.fees AS totalFees, + blocks.median_fee AS medianFee, + blocks.fee_span AS feeRange, + blocks.reward, + pools.unique_id AS poolId, + pools.name AS poolName, + pools.slug AS poolSlug, + blocks.avg_fee AS avgFee, + blocks.avg_fee_rate AS avgFeeRate, + blocks.coinbase_raw AS coinbaseRaw, + blocks.coinbase_address AS coinbaseAddress, + blocks.coinbase_signature AS coinbaseSignature, + blocks.coinbase_signature_ascii AS coinbaseSignatureAscii, + blocks.avg_tx_size AS avgTxSize, + blocks.total_inputs AS totalInputs, + blocks.total_outputs AS totalOutputs, + blocks.total_output_amt AS totalOutputAmt, + blocks.median_fee_amt AS medianFeeAmt, + blocks.fee_percentiles AS feePercentiles, + blocks.segwit_total_txs AS segwitTotalTxs, + blocks.segwit_total_size AS segwitTotalSize, + blocks.segwit_total_weight AS segwitTotalWeight, + blocks.header, + blocks.utxoset_change AS utxoSetChange, + blocks.utxoset_size AS utxoSetSize, + blocks.total_input_amt AS totalInputAmts +`; class BlocksRepository { /** @@ -44,6 +88,11 @@ class BlocksRepository { ?, ? )`; + const poolDbId = await PoolsRepository.$getPoolByUniqueId(block.extras.pool.id); + if (!poolDbId) { + throw Error(`Could not find a mining pool with the unique_id = ${block.extras.pool.id}. This error should never be printed.`); + } + const params: any[] = [ block.height, block.id, @@ -53,7 +102,7 @@ class BlocksRepository { block.tx_count, block.extras.coinbaseRaw, block.difficulty, - block.extras.pool?.id, // Should always be set to something + poolDbId.id, block.extras.totalFees, JSON.stringify(block.extras.feeRange), block.extras.medianFee, @@ -65,7 +114,7 @@ class BlocksRepository { block.previousblockhash, block.extras.avgFee, block.extras.avgFeeRate, - block.extras.medianTimestamp, + block.mediantime, block.extras.header, block.extras.coinbaseAddress, truncatedCoinbaseSignature, @@ -87,9 +136,9 @@ class BlocksRepository { await DB.query(query, params); } catch (e: any) { if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart - logger.debug(`$saveBlockInDatabase() - Block ${block.height} has already been indexed, ignoring`); + logger.debug(`$saveBlockInDatabase() - Block ${block.height} has already been indexed, ignoring`, logger.tags.mining); } else { - logger.err('Cannot save indexed block into db. Reason: ' + (e instanceof Error ? e.message : e)); + logger.err('Cannot save indexed block into db. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); throw e; } } @@ -307,34 +356,17 @@ class BlocksRepository { /** * Get blocks mined by a specific mining pool */ - public async $getBlocksByPool(slug: string, startHeight?: number): Promise { + public async $getBlocksByPool(slug: string, startHeight?: number): Promise { const pool = await PoolsRepository.$getPool(slug); if (!pool) { throw new Error('This mining pool does not exist ' + escape(slug)); } const params: any[] = []; - let query = ` SELECT - blocks.height, - hash as id, - UNIX_TIMESTAMP(blocks.blockTimestamp) as blockTimestamp, - size, - weight, - tx_count, - coinbase_raw, - difficulty, - fees, - fee_span, - median_fee, - reward, - version, - bits, - nonce, - merkle_root, - previous_block_hash as previousblockhash, - avg_fee, - avg_fee_rate + let query = ` + SELECT ${BLOCK_DB_FIELDS} FROM blocks + JOIN pools ON blocks.pool_id = pools.id WHERE pool_id = ?`; params.push(pool.id); @@ -347,11 +379,11 @@ class BlocksRepository { LIMIT 10`; try { - const [rows] = await DB.query(query, params); + const [rows]: any[] = await DB.query(query, params); const blocks: BlockExtended[] = []; - for (const block of rows) { - blocks.push(prepareBlock(block)); + for (const block of rows) { + blocks.push(await this.formatDbBlockIntoExtendedBlock(block)); } return blocks; @@ -364,32 +396,21 @@ class BlocksRepository { /** * Get one block by height */ - public async $getBlockByHeight(height: number): Promise { + public async $getBlockByHeight(height: number): Promise { try { - const [rows]: any[] = await DB.query(`SELECT - blocks.*, - hash as id, - UNIX_TIMESTAMP(blocks.blockTimestamp) as blockTimestamp, - UNIX_TIMESTAMP(blocks.median_timestamp) as medianTime, - pools.id as pool_id, - pools.name as pool_name, - pools.link as pool_link, - pools.slug as pool_slug, - pools.addresses as pool_addresses, - pools.regexes as pool_regexes, - previous_block_hash as previousblockhash + const [rows]: any[] = await DB.query(` + SELECT ${BLOCK_DB_FIELDS} FROM blocks JOIN pools ON blocks.pool_id = pools.id - WHERE blocks.height = ${height} - `); + WHERE blocks.height = ?`, + [height] + ); if (rows.length <= 0) { return null; } - rows[0].fee_span = JSON.parse(rows[0].fee_span); - rows[0].fee_percentiles = JSON.parse(rows[0].fee_percentiles); - return rows[0]; + return await this.formatDbBlockIntoExtendedBlock(rows[0]); } catch (e) { logger.err(`Cannot get indexed block ${height}. Reason: ` + (e instanceof Error ? e.message : e)); throw e; @@ -402,10 +423,7 @@ class BlocksRepository { public async $getBlockByHash(hash: string): Promise { try { const query = ` - SELECT *, blocks.height, UNIX_TIMESTAMP(blocks.blockTimestamp) as blockTimestamp, hash as id, - pools.id as pool_id, pools.name as pool_name, pools.link as pool_link, pools.slug as pool_slug, - pools.addresses as pool_addresses, pools.regexes as pool_regexes, - previous_block_hash as previousblockhash + SELECT ${BLOCK_DB_FIELDS} FROM blocks JOIN pools ON blocks.pool_id = pools.id WHERE hash = ?; @@ -415,9 +433,8 @@ class BlocksRepository { if (rows.length <= 0) { return null; } - - rows[0].fee_span = JSON.parse(rows[0].fee_span); - return rows[0]; + + return await this.formatDbBlockIntoExtendedBlock(rows[0]); } catch (e) { logger.err(`Cannot get indexed block ${hash}. Reason: ` + (e instanceof Error ? e.message : e)); throw e; @@ -508,8 +525,15 @@ class BlocksRepository { public async $validateChain(): Promise { try { const start = new Date().getTime(); - const [blocks]: any[] = await DB.query(`SELECT height, hash, previous_block_hash, - UNIX_TIMESTAMP(blockTimestamp) as timestamp FROM blocks ORDER BY height`); + const [blocks]: any[] = await DB.query(` + SELECT + height, + hash, + previous_block_hash, + UNIX_TIMESTAMP(blockTimestamp) AS timestamp + FROM blocks + ORDER BY height + `); let partialMsg = false; let idx = 1; @@ -724,6 +748,7 @@ class BlocksRepository { SELECT height FROM compact_cpfp_clusters WHERE height <= ? AND height >= ? + GROUP BY height ORDER BY height DESC; `, [currentBlockHeight, minHeight]); @@ -833,6 +858,95 @@ class BlocksRepository { throw e; } } + + /** + * Convert a mysql row block into a BlockExtended. Note that you + * must provide the correct field into dbBlk object param + * + * @param dbBlk + */ + private async formatDbBlockIntoExtendedBlock(dbBlk: any): Promise { + const blk: Partial = {}; + const extras: Partial = {}; + + // IEsploraApi.Block + blk.id = dbBlk.id; + blk.height = dbBlk.height; + blk.version = dbBlk.version; + blk.timestamp = dbBlk.timestamp; + blk.bits = dbBlk.bits; + blk.nonce = dbBlk.nonce; + blk.difficulty = dbBlk.difficulty; + blk.merkle_root = dbBlk.merkle_root; + blk.tx_count = dbBlk.tx_count; + blk.size = dbBlk.size; + blk.weight = dbBlk.weight; + blk.previousblockhash = dbBlk.previousblockhash; + blk.mediantime = dbBlk.mediantime; + + // BlockExtension + extras.totalFees = dbBlk.totalFees; + extras.medianFee = dbBlk.medianFee; + extras.feeRange = JSON.parse(dbBlk.feeRange); + extras.reward = dbBlk.reward; + extras.pool = { + id: dbBlk.poolId, + name: dbBlk.poolName, + slug: dbBlk.poolSlug, + }; + extras.avgFee = dbBlk.avgFee; + extras.avgFeeRate = dbBlk.avgFeeRate; + extras.coinbaseRaw = dbBlk.coinbaseRaw; + extras.coinbaseAddress = dbBlk.coinbaseAddress; + extras.coinbaseSignature = dbBlk.coinbaseSignature; + extras.coinbaseSignatureAscii = dbBlk.coinbaseSignatureAscii; + extras.avgTxSize = dbBlk.avgTxSize; + extras.totalInputs = dbBlk.totalInputs; + extras.totalOutputs = dbBlk.totalOutputs; + extras.totalOutputAmt = dbBlk.totalOutputAmt; + extras.medianFeeAmt = dbBlk.medianFeeAmt; + extras.feePercentiles = JSON.parse(dbBlk.feePercentiles); + extras.segwitTotalTxs = dbBlk.segwitTotalTxs; + extras.segwitTotalSize = dbBlk.segwitTotalSize; + extras.segwitTotalWeight = dbBlk.segwitTotalWeight; + extras.header = dbBlk.header, + extras.utxoSetChange = dbBlk.utxoSetChange; + extras.utxoSetSize = dbBlk.utxoSetSize; + extras.totalInputAmt = dbBlk.totalInputAmt; + extras.virtualSize = dbBlk.weight / 4.0; + + // Re-org can happen after indexing so we need to always get the + // latest state from core + extras.orphans = chainTips.getOrphanedBlocksAtHeight(dbBlk.height); + + // Match rate is not part of the blocks table, but it is part of APIs so we must include it + extras.matchRate = null; + if (config.MEMPOOL.AUDIT) { + const auditScore = await BlocksAuditsRepository.$getBlockAuditScore(dbBlk.id); + if (auditScore != null) { + extras.matchRate = auditScore.matchRate; + } + } + + // If we're missing block summary related field, check if we can populate them on the fly now + if (Common.blocksSummariesIndexingEnabled() && + (extras.medianFeeAmt === null || extras.feePercentiles === null)) + { + extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(dbBlk.id); + if (extras.feePercentiles === null) { + const block = await bitcoinClient.getBlock(dbBlk.id, 2); + const summary = blocks.summarizeBlock(block); + await BlocksSummariesRepository.$saveSummary({ height: block.height, mined: summary }); + extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(dbBlk.id); + } + if (extras.feePercentiles !== null) { + extras.medianFeeAmt = extras.feePercentiles[3]; + } + } + + blk.extras = extras; + return blk; + } } export default new BlocksRepository(); diff --git a/backend/src/repositories/DifficultyAdjustmentsRepository.ts b/backend/src/repositories/DifficultyAdjustmentsRepository.ts index 910c65c10..0b19cc640 100644 --- a/backend/src/repositories/DifficultyAdjustmentsRepository.ts +++ b/backend/src/repositories/DifficultyAdjustmentsRepository.ts @@ -1,5 +1,4 @@ import { Common } from '../api/common'; -import config from '../config'; import DB from '../database'; import logger from '../logger'; import { IndexedDifficultyAdjustment } from '../mempool.interfaces'; @@ -21,9 +20,9 @@ class DifficultyAdjustmentsRepository { await DB.query(query, params); } catch (e: any) { if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart - logger.debug(`Cannot save difficulty adjustment at block ${adjustment.height}, already indexed, ignoring`); + logger.debug(`Cannot save difficulty adjustment at block ${adjustment.height}, already indexed, ignoring`, logger.tags.mining); } else { - logger.err(`Cannot save difficulty adjustment at block ${adjustment.height}. Reason: ${e instanceof Error ? e.message : e}`); + logger.err(`Cannot save difficulty adjustment at block ${adjustment.height}. Reason: ${e instanceof Error ? e.message : e}`, logger.tags.mining); throw e; } } @@ -55,7 +54,7 @@ class DifficultyAdjustmentsRepository { const [rows] = await DB.query(query); return rows as IndexedDifficultyAdjustment[]; } catch (e) { - logger.err(`Cannot get difficulty adjustments from the database. Reason: ` + (e instanceof Error ? e.message : e)); + logger.err(`Cannot get difficulty adjustments from the database. Reason: ` + (e instanceof Error ? e.message : e), logger.tags.mining); throw e; } } @@ -84,7 +83,7 @@ class DifficultyAdjustmentsRepository { const [rows] = await DB.query(query); return rows as IndexedDifficultyAdjustment[]; } catch (e) { - logger.err(`Cannot get difficulty adjustments from the database. Reason: ` + (e instanceof Error ? e.message : e)); + logger.err(`Cannot get difficulty adjustments from the database. Reason: ` + (e instanceof Error ? e.message : e), logger.tags.mining); throw e; } } @@ -94,27 +93,27 @@ class DifficultyAdjustmentsRepository { const [rows]: any[] = await DB.query(`SELECT height FROM difficulty_adjustments`); return rows.map(block => block.height); } catch (e: any) { - logger.err(`Cannot get difficulty adjustment block heights. Reason: ${e instanceof Error ? e.message : e}`); + logger.err(`Cannot get difficulty adjustment block heights. Reason: ${e instanceof Error ? e.message : e}`, logger.tags.mining); throw e; } } public async $deleteAdjustementsFromHeight(height: number): Promise { try { - logger.info(`Delete newer difficulty adjustments from height ${height} from the database`); + logger.info(`Delete newer difficulty adjustments from height ${height} from the database`, logger.tags.mining); await DB.query(`DELETE FROM difficulty_adjustments WHERE height >= ?`, [height]); } catch (e: any) { - logger.err(`Cannot delete difficulty adjustments from the database. Reason: ${e instanceof Error ? e.message : e}`); + logger.err(`Cannot delete difficulty adjustments from the database. Reason: ${e instanceof Error ? e.message : e}`, logger.tags.mining); throw e; } } public async $deleteLastAdjustment(): Promise { try { - logger.info(`Delete last difficulty adjustment from the database`); + logger.info(`Delete last difficulty adjustment from the database`, logger.tags.mining); await DB.query(`DELETE FROM difficulty_adjustments ORDER BY time LIMIT 1`); } catch (e: any) { - logger.err(`Cannot delete last difficulty adjustment from the database. Reason: ${e instanceof Error ? e.message : e}`); + logger.err(`Cannot delete last difficulty adjustment from the database. Reason: ${e instanceof Error ? e.message : e}`, logger.tags.mining); throw e; } } diff --git a/backend/src/repositories/HashratesRepository.ts b/backend/src/repositories/HashratesRepository.ts index e5a193477..875f77b34 100644 --- a/backend/src/repositories/HashratesRepository.ts +++ b/backend/src/repositories/HashratesRepository.ts @@ -1,5 +1,6 @@ import { escape } from 'mysql2'; import { Common } from '../api/common'; +import mining from '../api/mining/mining'; import DB from '../database'; import logger from '../logger'; import PoolsRepository from './PoolsRepository'; @@ -24,7 +25,7 @@ class HashratesRepository { try { await DB.query(query); } catch (e: any) { - logger.err('Cannot save indexed hashrate into db. Reason: ' + (e instanceof Error ? e.message : e)); + logger.err('Cannot save indexed hashrate into db. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); throw e; } } @@ -50,7 +51,7 @@ class HashratesRepository { const [rows]: any[] = await DB.query(query); return rows; } catch (e) { - logger.err('Cannot fetch network hashrate history. Reason: ' + (e instanceof Error ? e.message : e)); + logger.err('Cannot fetch network hashrate history. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); throw e; } } @@ -77,7 +78,7 @@ class HashratesRepository { const [rows]: any[] = await DB.query(query); return rows; } catch (e) { - logger.err('Cannot fetch network hashrate history. Reason: ' + (e instanceof Error ? e.message : e)); + logger.err('Cannot fetch network hashrate history. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); throw e; } } @@ -92,7 +93,7 @@ class HashratesRepository { const [rows]: any[] = await DB.query(query); return rows.map(row => row.timestamp); } catch (e) { - logger.err('Cannot retreive indexed weekly hashrate timestamps. Reason: ' + (e instanceof Error ? e.message : e)); + logger.err('Cannot retreive indexed weekly hashrate timestamps. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); throw e; } } @@ -127,7 +128,7 @@ class HashratesRepository { const [rows]: any[] = await DB.query(query); return rows; } catch (e) { - logger.err('Cannot fetch weekly pools hashrate history. Reason: ' + (e instanceof Error ? e.message : e)); + logger.err('Cannot fetch weekly pools hashrate history. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); throw e; } } @@ -157,7 +158,7 @@ class HashratesRepository { const [rows]: any[] = await DB.query(query, [pool.id]); boundaries = rows[0]; } catch (e) { - logger.err('Cannot fetch hashrate start/end timestamps for this pool. Reason: ' + (e instanceof Error ? e.message : e)); + logger.err('Cannot fetch hashrate start/end timestamps for this pool. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); } // Get hashrates entries between boundaries @@ -172,21 +173,7 @@ class HashratesRepository { const [rows]: any[] = await DB.query(query, [boundaries.firstTimestamp, boundaries.lastTimestamp, pool.id]); return rows; } catch (e) { - logger.err('Cannot fetch pool hashrate history for this pool. Reason: ' + (e instanceof Error ? e.message : e)); - throw e; - } - } - - /** - * Set latest run timestamp - */ - public async $setLatestRun(key: string, val: number) { - const query = `UPDATE state SET number = ? WHERE name = ?`; - - try { - await DB.query(query, [val, key]); - } catch (e) { - logger.err(`Cannot set last indexing run for ${key}. Reason: ` + (e instanceof Error ? e.message : e)); + logger.err('Cannot fetch pool hashrate history for this pool. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); throw e; } } @@ -205,7 +192,7 @@ class HashratesRepository { } return rows[0]['number']; } catch (e) { - logger.err(`Cannot retrieve last indexing run for ${key}. Reason: ` + (e instanceof Error ? e.message : e)); + logger.err(`Cannot retrieve last indexing run for ${key}. Reason: ` + (e instanceof Error ? e.message : e), logger.tags.mining); throw e; } } @@ -214,7 +201,7 @@ class HashratesRepository { * Delete most recent data points for re-indexing */ public async $deleteLastEntries() { - logger.info(`Delete latest hashrates data points from the database`); + logger.info(`Delete latest hashrates data points from the database`, logger.tags.mining); try { const [rows]: any[] = await DB.query(`SELECT MAX(hashrate_timestamp) as timestamp FROM hashrates GROUP BY type`); @@ -222,10 +209,10 @@ class HashratesRepository { await DB.query(`DELETE FROM hashrates WHERE hashrate_timestamp = ?`, [row.timestamp]); } // Re-run the hashrate indexing to fill up missing data - await this.$setLatestRun('last_hashrates_indexing', 0); - await this.$setLatestRun('last_weekly_hashrates_indexing', 0); + mining.lastHashrateIndexingDate = null; + mining.lastWeeklyHashrateIndexingDate = null; } catch (e) { - logger.err('Cannot delete latest hashrates data points. Reason: ' + (e instanceof Error ? e.message : e)); + logger.err('Cannot delete latest hashrates data points. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); } } @@ -238,10 +225,10 @@ class HashratesRepository { try { await DB.query(`DELETE FROM hashrates WHERE hashrate_timestamp >= FROM_UNIXTIME(?)`, [timestamp]); // Re-run the hashrate indexing to fill up missing data - await this.$setLatestRun('last_hashrates_indexing', 0); - await this.$setLatestRun('last_weekly_hashrates_indexing', 0); + mining.lastHashrateIndexingDate = null; + mining.lastWeeklyHashrateIndexingDate = null; } catch (e) { - logger.err('Cannot delete latest hashrates data points. Reason: ' + (e instanceof Error ? e.message : e)); + logger.err('Cannot delete latest hashrates data points. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); } } } diff --git a/backend/src/repositories/PoolsRepository.ts b/backend/src/repositories/PoolsRepository.ts index 236955d65..293fd5e39 100644 --- a/backend/src/repositories/PoolsRepository.ts +++ b/backend/src/repositories/PoolsRepository.ts @@ -10,7 +10,7 @@ class PoolsRepository { * Get all pools tagging info */ public async $getPools(): Promise { - const [rows] = await DB.query('SELECT id, name, addresses, regexes, slug FROM pools;'); + const [rows] = await DB.query('SELECT id, unique_id as uniqueId, name, addresses, regexes, slug FROM pools'); return rows; } @@ -18,10 +18,10 @@ class PoolsRepository { * Get unknown pool tagging info */ public async $getUnknownPool(): Promise { - let [rows]: any[] = await DB.query('SELECT id, name, slug FROM pools where name = "Unknown"'); + let [rows]: any[] = await DB.query('SELECT id, unique_id as uniqueId, name, slug FROM pools where name = "Unknown"'); if (rows && rows.length === 0 && config.DATABASE.ENABLED) { await poolsParser.$insertUnknownPool(); - [rows] = await DB.query('SELECT id, name, slug FROM pools where name = "Unknown"'); + [rows] = await DB.query('SELECT id, unique_id as uniqueId, name, slug FROM pools where name = "Unknown"'); } return rows[0]; } diff --git a/backend/src/repositories/PricesRepository.ts b/backend/src/repositories/PricesRepository.ts index 83336eaff..4cbc06afd 100644 --- a/backend/src/repositories/PricesRepository.ts +++ b/backend/src/repositories/PricesRepository.ts @@ -1,6 +1,5 @@ import DB from '../database'; import logger from '../logger'; -import { IConversionRates } from '../mempool.interfaces'; import priceUpdater from '../tasks/price-updater'; export interface ApiPrice { @@ -13,6 +12,16 @@ export interface ApiPrice { AUD: number, JPY: number, } +const ApiPriceFields = ` + UNIX_TIMESTAMP(time) as time, + USD, + EUR, + GBP, + CAD, + CHF, + AUD, + JPY +`; export interface ExchangeRates { USDEUR: number, @@ -39,8 +48,8 @@ export const MAX_PRICES = { }; class PricesRepository { - public async $savePrices(time: number, prices: IConversionRates): Promise { - if (prices.USD === 0) { + public async $savePrices(time: number, prices: ApiPrice): Promise { + if (prices.USD === -1) { // Some historical price entries have no USD prices, so we just ignore them to avoid future UX issues // As of today there are only 4 (on 2013-09-05, 2013-0909, 2013-09-12 and 2013-09-26) so that's fine return; @@ -60,77 +69,115 @@ class PricesRepository { VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ? )`, [time, prices.USD, prices.EUR, prices.GBP, prices.CAD, prices.CHF, prices.AUD, prices.JPY] ); - } catch (e: any) { + } catch (e) { logger.err(`Cannot save exchange rate into db. Reason: ` + (e instanceof Error ? e.message : e)); throw e; } } public async $getOldestPriceTime(): Promise { - const [oldestRow] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices WHERE USD != 0 ORDER BY time LIMIT 1`); + const [oldestRow] = await DB.query(` + SELECT UNIX_TIMESTAMP(time) AS time + FROM prices + ORDER BY time + LIMIT 1 + `); return oldestRow[0] ? oldestRow[0].time : 0; } public async $getLatestPriceId(): Promise { - const [oldestRow] = await DB.query(`SELECT id from prices WHERE USD != 0 ORDER BY time DESC LIMIT 1`); - return oldestRow[0] ? oldestRow[0].id : null; - } - - public async $getLatestPriceTime(): Promise { - const [oldestRow] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices WHERE USD != 0 ORDER BY time DESC LIMIT 1`); - return oldestRow[0] ? oldestRow[0].time : 0; - } - - public async $getPricesTimes(): Promise { - const [times]: any[] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices WHERE USD != 0 ORDER BY time`); - return times.map(time => time.time); - } - - public async $getPricesTimesAndId(): Promise { - const [times]: any[] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time, id, USD from prices ORDER BY time`); - return times; - } - - public async $getLatestConversionRates(): Promise { - const [rates]: any[] = await DB.query(` - SELECT USD, EUR, GBP, CAD, CHF, AUD, JPY + const [oldestRow] = await DB.query(` + SELECT id FROM prices ORDER BY time DESC LIMIT 1` ); - if (!rates || rates.length === 0) { + return oldestRow[0] ? oldestRow[0].id : null; + } + + public async $getLatestPriceTime(): Promise { + const [oldestRow] = await DB.query(` + SELECT UNIX_TIMESTAMP(time) AS time + FROM prices + ORDER BY time DESC + LIMIT 1` + ); + return oldestRow[0] ? oldestRow[0].time : 0; + } + + public async $getPricesTimes(): Promise { + const [times] = await DB.query(` + SELECT UNIX_TIMESTAMP(time) AS time + FROM prices + WHERE USD != -1 + ORDER BY time + `); + if (!Array.isArray(times)) { + return []; + } + return times.map(time => time.time); + } + + public async $getPricesTimesAndId(): Promise<{time: number, id: number, USD: number}[]> { + const [times] = await DB.query(` + SELECT + UNIX_TIMESTAMP(time) AS time, + id, + USD + FROM prices + ORDER BY time + `); + return times as {time: number, id: number, USD: number}[]; + } + + public async $getLatestConversionRates(): Promise { + const [rates] = await DB.query(` + SELECT ${ApiPriceFields} + FROM prices + ORDER BY time DESC + LIMIT 1` + ); + + if (!Array.isArray(rates) || rates.length === 0) { return priceUpdater.getEmptyPricesObj(); } - return rates[0]; + return rates[0] as ApiPrice; } public async $getNearestHistoricalPrice(timestamp: number | undefined): Promise { try { - const [rates]: any[] = await DB.query(` - SELECT *, UNIX_TIMESTAMP(time) AS time + const [rates] = await DB.query(` + SELECT ${ApiPriceFields} FROM prices WHERE UNIX_TIMESTAMP(time) < ? ORDER BY time DESC LIMIT 1`, [timestamp] ); - if (!rates) { + if (!Array.isArray(rates)) { throw Error(`Cannot get single historical price from the database`); } // Compute fiat exchange rates - const latestPrice = await this.$getLatestConversionRates(); + let latestPrice = rates[0] as ApiPrice; + if (latestPrice.USD === -1) { + latestPrice = priceUpdater.getEmptyPricesObj(); + } + + const computeFx = (usd: number, other: number): number => + Math.round(Math.max(other, 0) / Math.max(usd, 1) * 100) / 100; + const exchangeRates: ExchangeRates = { - USDEUR: Math.round(latestPrice.EUR / latestPrice.USD * 100) / 100, - USDGBP: Math.round(latestPrice.GBP / latestPrice.USD * 100) / 100, - USDCAD: Math.round(latestPrice.CAD / latestPrice.USD * 100) / 100, - USDCHF: Math.round(latestPrice.CHF / latestPrice.USD * 100) / 100, - USDAUD: Math.round(latestPrice.AUD / latestPrice.USD * 100) / 100, - USDJPY: Math.round(latestPrice.JPY / latestPrice.USD * 100) / 100, + USDEUR: computeFx(latestPrice.USD, latestPrice.EUR), + USDGBP: computeFx(latestPrice.USD, latestPrice.GBP), + USDCAD: computeFx(latestPrice.USD, latestPrice.CAD), + USDCHF: computeFx(latestPrice.USD, latestPrice.CHF), + USDAUD: computeFx(latestPrice.USD, latestPrice.AUD), + USDJPY: computeFx(latestPrice.USD, latestPrice.JPY), }; return { - prices: rates, + prices: rates as ApiPrice[], exchangeRates: exchangeRates }; } catch (e) { @@ -141,28 +188,35 @@ class PricesRepository { public async $getHistoricalPrices(): Promise { try { - const [rates]: any[] = await DB.query(` - SELECT *, UNIX_TIMESTAMP(time) AS time + const [rates] = await DB.query(` + SELECT ${ApiPriceFields} FROM prices ORDER BY time DESC `); - if (!rates) { + if (!Array.isArray(rates)) { throw Error(`Cannot get average historical price from the database`); } // Compute fiat exchange rates - const latestPrice: ApiPrice = rates[0]; + let latestPrice = rates[0] as ApiPrice; + if (latestPrice.USD === -1) { + latestPrice = priceUpdater.getEmptyPricesObj(); + } + + const computeFx = (usd: number, other: number): number => + Math.round(Math.max(other, 0) / Math.max(usd, 1) * 100) / 100; + const exchangeRates: ExchangeRates = { - USDEUR: Math.round(latestPrice.EUR / latestPrice.USD * 100) / 100, - USDGBP: Math.round(latestPrice.GBP / latestPrice.USD * 100) / 100, - USDCAD: Math.round(latestPrice.CAD / latestPrice.USD * 100) / 100, - USDCHF: Math.round(latestPrice.CHF / latestPrice.USD * 100) / 100, - USDAUD: Math.round(latestPrice.AUD / latestPrice.USD * 100) / 100, - USDJPY: Math.round(latestPrice.JPY / latestPrice.USD * 100) / 100, + USDEUR: computeFx(latestPrice.USD, latestPrice.EUR), + USDGBP: computeFx(latestPrice.USD, latestPrice.GBP), + USDCAD: computeFx(latestPrice.USD, latestPrice.CAD), + USDCHF: computeFx(latestPrice.USD, latestPrice.CHF), + USDAUD: computeFx(latestPrice.USD, latestPrice.AUD), + USDJPY: computeFx(latestPrice.USD, latestPrice.JPY), }; return { - prices: rates, + prices: rates as ApiPrice[], exchangeRates: exchangeRates }; } catch (e) { diff --git a/backend/src/tasks/lightning/network-sync.service.ts b/backend/src/tasks/lightning/network-sync.service.ts index fdef7ecae..3e5ae1366 100644 --- a/backend/src/tasks/lightning/network-sync.service.ts +++ b/backend/src/tasks/lightning/network-sync.service.ts @@ -72,7 +72,7 @@ class NetworkSyncService { const graphNodesPubkeys: string[] = []; for (const node of nodes) { const latestUpdated = await channelsApi.$getLatestChannelUpdateForNode(node.pub_key); - node.last_update = Math.max(node.last_update, latestUpdated); + node.last_update = Math.max(node.last_update ?? 0, latestUpdated); await nodesApi.$saveNode(node); graphNodesPubkeys.push(node.pub_key); diff --git a/backend/src/tasks/lightning/sync-tasks/stats-importer.ts b/backend/src/tasks/lightning/sync-tasks/stats-importer.ts index 14f592a14..d009ce052 100644 --- a/backend/src/tasks/lightning/sync-tasks/stats-importer.ts +++ b/backend/src/tasks/lightning/sync-tasks/stats-importer.ts @@ -411,7 +411,7 @@ class LightningStatsImporter { } if (totalProcessed > 0) { - logger.notice(`Lightning network stats historical import completed`, logger.tags.ln); + logger.info(`Lightning network stats historical import completed`, logger.tags.ln); } } catch (e) { logger.err(`Lightning network stats historical failed. Reason: ${e instanceof Error ? e.message : e}`, logger.tags.ln); diff --git a/backend/src/tasks/pools-updater.ts b/backend/src/tasks/pools-updater.ts index 32de85f3a..dc76382d6 100644 --- a/backend/src/tasks/pools-updater.ts +++ b/backend/src/tasks/pools-updater.ts @@ -12,7 +12,7 @@ import * as https from 'https'; */ class PoolsUpdater { lastRun: number = 0; - currentSha: string | undefined = undefined; + currentSha: string | null = null; poolsUrl: string = config.MEMPOOL.POOLS_JSON_URL; treeUrl: string = config.MEMPOOL.POOLS_JSON_TREE_URL; @@ -33,7 +33,7 @@ class PoolsUpdater { try { const githubSha = await this.fetchPoolsSha(); // Fetch pools-v2.json sha from github - if (githubSha === undefined) { + if (githubSha === null) { return; } @@ -42,12 +42,12 @@ class PoolsUpdater { } logger.debug(`pools-v2.json sha | Current: ${this.currentSha} | Github: ${githubSha}`); - if (this.currentSha !== undefined && this.currentSha === githubSha) { + if (this.currentSha !== null && this.currentSha === githubSha) { return; } // See backend README for more details about the mining pools update process - if (this.currentSha !== undefined && // If we don't have any mining pool, download it at least once + if (this.currentSha !== null && // If we don't have any mining pool, download it at least once config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING !== true && // Automatic pools update is disabled !process.env.npm_config_update_pools // We're not manually updating mining pool ) { @@ -57,7 +57,7 @@ class PoolsUpdater { } const network = config.SOCKS5PROXY.ENABLED ? 'tor' : 'clearnet'; - if (this.currentSha === undefined) { + if (this.currentSha === null) { logger.info(`Downloading pools-v2.json for the first time from ${this.poolsUrl} over ${network}`, logger.tags.mining); } else { logger.warn(`pools-v2.json is outdated, fetch latest from ${this.poolsUrl} over ${network}`, logger.tags.mining); @@ -82,7 +82,7 @@ class PoolsUpdater { logger.err(`Could not migrate mining pools, rolling back. Exception: ${JSON.stringify(e)}`, logger.tags.mining); await DB.query('ROLLBACK;'); } - logger.notice('PoolsUpdater completed'); + logger.info('PoolsUpdater completed'); } catch (e) { this.lastRun = now - (oneWeek - oneDay); // Try again in 24h instead of waiting next week @@ -108,20 +108,20 @@ class PoolsUpdater { /** * Fetch our latest pools-v2.json sha from the db */ - private async getShaFromDb(): Promise { + private async getShaFromDb(): Promise { try { const [rows]: any[] = await DB.query('SELECT string FROM state WHERE name="pools_json_sha"'); - return (rows.length > 0 ? rows[0].string : undefined); + return (rows.length > 0 ? rows[0].string : null); } catch (e) { logger.err('Cannot fetch pools-v2.json sha from db. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); - return undefined; + return null; } } /** * Fetch our latest pools-v2.json sha from github */ - private async fetchPoolsSha(): Promise { + private async fetchPoolsSha(): Promise { const response = await this.query(this.treeUrl); if (response !== undefined) { @@ -133,7 +133,7 @@ class PoolsUpdater { } logger.err(`Cannot find "pools-v2.json" in git tree (${this.treeUrl})`, logger.tags.mining); - return undefined; + return null; } /** diff --git a/backend/src/tasks/price-feeds/bitfinex-api.ts b/backend/src/tasks/price-feeds/bitfinex-api.ts index 0e06c3af7..30b70e9eb 100644 --- a/backend/src/tasks/price-feeds/bitfinex-api.ts +++ b/backend/src/tasks/price-feeds/bitfinex-api.ts @@ -8,9 +8,6 @@ class BitfinexApi implements PriceFeed { public url: string = 'https://api.bitfinex.com/v1/pubticker/BTC'; public urlHist: string = 'https://api-pub.bitfinex.com/v2/candles/trade:{GRANULARITY}:tBTC{CURRENCY}/hist'; - constructor() { - } - public async $fetchPrice(currency): Promise { const response = await query(this.url + currency); if (response && response['last_price']) { diff --git a/backend/src/tasks/price-feeds/kraken-api.ts b/backend/src/tasks/price-feeds/kraken-api.ts index c6b3c0c11..ebc784c6f 100644 --- a/backend/src/tasks/price-feeds/kraken-api.ts +++ b/backend/src/tasks/price-feeds/kraken-api.ts @@ -98,7 +98,7 @@ class KrakenApi implements PriceFeed { } if (Object.keys(priceHistory).length > 0) { - logger.notice(`Inserted ${Object.keys(priceHistory).length} Kraken EUR, USD, GBP, JPY, CAD, CHF and AUD weekly price history into db`, logger.tags.mining); + logger.info(`Inserted ${Object.keys(priceHistory).length} Kraken EUR, USD, GBP, JPY, CAD, CHF and AUD weekly price history into db`, logger.tags.mining); } } } diff --git a/backend/src/tasks/price-updater.ts b/backend/src/tasks/price-updater.ts index b39e152ae..ccb8d3e68 100644 --- a/backend/src/tasks/price-updater.ts +++ b/backend/src/tasks/price-updater.ts @@ -2,8 +2,7 @@ import * as fs from 'fs'; import path from 'path'; import config from '../config'; import logger from '../logger'; -import { IConversionRates } from '../mempool.interfaces'; -import PricesRepository, { MAX_PRICES } from '../repositories/PricesRepository'; +import PricesRepository, { ApiPrice, MAX_PRICES } from '../repositories/PricesRepository'; import BitfinexApi from './price-feeds/bitfinex-api'; import BitflyerApi from './price-feeds/bitflyer-api'; import CoinbaseApi from './price-feeds/coinbase-api'; @@ -21,18 +20,18 @@ export interface PriceFeed { } export interface PriceHistory { - [timestamp: number]: IConversionRates; + [timestamp: number]: ApiPrice; } class PriceUpdater { public historyInserted = false; - lastRun = 0; - lastHistoricalRun = 0; - running = false; - feeds: PriceFeed[] = []; - currencies: string[] = ['USD', 'EUR', 'GBP', 'CAD', 'CHF', 'AUD', 'JPY']; - latestPrices: IConversionRates; - private ratesChangedCallback: ((rates: IConversionRates) => void) | undefined; + private lastRun = 0; + private lastHistoricalRun = 0; + private running = false; + private feeds: PriceFeed[] = []; + private currencies: string[] = ['USD', 'EUR', 'GBP', 'CAD', 'CHF', 'AUD', 'JPY']; + private latestPrices: ApiPrice; + private ratesChangedCallback: ((rates: ApiPrice) => void) | undefined; constructor() { this.latestPrices = this.getEmptyPricesObj(); @@ -44,8 +43,13 @@ class PriceUpdater { this.feeds.push(new GeminiApi()); } - public getEmptyPricesObj(): IConversionRates { + public getLatestPrices(): ApiPrice { + return this.latestPrices; + } + + public getEmptyPricesObj(): ApiPrice { return { + time: 0, USD: -1, EUR: -1, GBP: -1, @@ -56,7 +60,7 @@ class PriceUpdater { }; } - public setRatesChangedCallback(fn: (rates: IConversionRates) => void) { + public setRatesChangedCallback(fn: (rates: ApiPrice) => void): void { this.ratesChangedCallback = fn; } @@ -156,6 +160,10 @@ class PriceUpdater { } this.lastRun = new Date().getTime() / 1000; + + if (this.latestPrices.USD === -1) { + this.latestPrices = await PricesRepository.$getLatestConversionRates(); + } } /** @@ -224,7 +232,7 @@ class PriceUpdater { // Group them by timestamp and currency, for example // grouped[123456789]['USD'] = [1, 2, 3, 4]; - const grouped: any = {}; + const grouped = {}; for (const historicalEntry of historicalPrices) { for (const time in historicalEntry) { if (existingPriceTimes.includes(parseInt(time, 10))) { @@ -249,7 +257,7 @@ class PriceUpdater { // Average prices and insert everything into the db let totalInserted = 0; for (const time in grouped) { - const prices: IConversionRates = this.getEmptyPricesObj(); + const prices: ApiPrice = this.getEmptyPricesObj(); for (const currency in grouped[time]) { if (grouped[time][currency].length === 0) { continue; diff --git a/backend/src/utils/blocks-utils.ts b/backend/src/utils/blocks-utils.ts deleted file mode 100644 index 43a2fc964..000000000 --- a/backend/src/utils/blocks-utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { BlockExtended } from '../mempool.interfaces'; - -export function prepareBlock(block: any): BlockExtended { - return { - id: block.id ?? block.hash, // hash for indexed block - timestamp: block.timestamp ?? block.time ?? block.blockTimestamp, // blockTimestamp for indexed block - height: block.height, - version: block.version, - bits: (typeof block.bits === 'string' ? parseInt(block.bits, 16): block.bits), - nonce: block.nonce, - difficulty: block.difficulty, - merkle_root: block.merkle_root ?? block.merkleroot, - tx_count: block.tx_count ?? block.nTx, - size: block.size, - weight: block.weight, - previousblockhash: block.previousblockhash, - extras: { - coinbaseRaw: block.coinbase_raw ?? block.extras?.coinbaseRaw, - medianFee: block.medianFee ?? block.median_fee ?? block.extras?.medianFee, - feeRange: block.feeRange ?? block?.extras?.feeRange ?? block.fee_span, - reward: block.reward ?? block?.extras?.reward, - totalFees: block.totalFees ?? block?.fees ?? block?.extras?.totalFees, - avgFee: block?.extras?.avgFee ?? block.avg_fee, - avgFeeRate: block?.avgFeeRate ?? block.avg_fee_rate, - pool: block?.extras?.pool ?? (block?.pool_id ? { - id: block.pool_id, - name: block.pool_name, - slug: block.pool_slug, - } : undefined), - usd: block?.extras?.usd ?? block.usd ?? null, - } - }; -} diff --git a/backend/src/utils/format.ts b/backend/src/utils/format.ts new file mode 100644 index 000000000..a18ce1892 --- /dev/null +++ b/backend/src/utils/format.ts @@ -0,0 +1,29 @@ +const byteUnits = ['B', 'kB', 'MB', 'GB', 'TB']; + +export function getBytesUnit(bytes: number): string { + if (isNaN(bytes) || !isFinite(bytes)) { + return 'B'; + } + + let unitIndex = 0; + while (unitIndex < byteUnits.length && bytes > 1024) { + unitIndex++; + bytes /= 1024; + } + + return byteUnits[unitIndex]; +} + +export function formatBytes(bytes: number, toUnit: string, skipUnit = false): string { + if (isNaN(bytes) || !isFinite(bytes)) { + return `${bytes}`; + } + + let unitIndex = 0; + while (unitIndex < byteUnits.length && (toUnit && byteUnits[unitIndex] !== toUnit || (!toUnit && bytes > 1024))) { + unitIndex++; + bytes /= 1024; + } + + return `${bytes.toFixed(2)}${skipUnit ? '' : ' ' + byteUnits[unitIndex]}`; +} \ No newline at end of file diff --git a/docker/backend/mempool-config.json b/docker/backend/mempool-config.json index d2aa75c69..78a2c116b 100644 --- a/docker/backend/mempool-config.json +++ b/docker/backend/mempool-config.json @@ -26,7 +26,7 @@ "ADVANCED_GBT_AUDIT": __MEMPOOL_ADVANCED_GBT_AUDIT__, "ADVANCED_GBT_MEMPOOL": __MEMPOOL_ADVANCED_GBT_MEMPOOL__, "CPFP_INDEXING": __MEMPOOL_CPFP_INDEXING__, - "MAX_BLOCKS_BULK_QUERY": __MEMPOOL__MAX_BLOCKS_BULK_QUERY__ + "MAX_BLOCKS_BULK_QUERY": __MEMPOOL_MAX_BLOCKS_BULK_QUERY__ }, "CORE_RPC": { "HOST": "__CORE_RPC_HOST__", @@ -108,4 +108,4 @@ "BISQ_URL": "__EXTERNAL_DATA_SERVER_BISQ_URL__", "BISQ_ONION": "__EXTERNAL_DATA_SERVER_BISQ_ONION__" } -} +} \ No newline at end of file diff --git a/docker/frontend/entrypoint.sh b/docker/frontend/entrypoint.sh index 18cb782e9..45d852c45 100644 --- a/docker/frontend/entrypoint.sh +++ b/docker/frontend/entrypoint.sh @@ -35,6 +35,7 @@ __AUDIT__=${AUDIT:=false} __MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0} __TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0} __SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0} +__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true} # Export as environment variables to be used by envsubst export __TESTNET_ENABLED__ @@ -60,6 +61,7 @@ export __AUDIT__ export __MAINNET_BLOCK_AUDIT_START_HEIGHT__ export __TESTNET_BLOCK_AUDIT_START_HEIGHT__ export __SIGNET_BLOCK_AUDIT_START_HEIGHT__ +export __HISTORICAL_PRICE__ folder=$(find /var/www/mempool -name "config.js" | xargs dirname) echo ${folder} diff --git a/frontend/.gitignore b/frontend/.gitignore index 789881ddd..9c4b5d5e8 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -54,6 +54,7 @@ src/resources/assets-testnet.json src/resources/assets-testnet.minimal.json src/resources/pools.json src/resources/mining-pools/* +src/resources/*.mp4 # environment config mempool-frontend-config.json diff --git a/frontend/cypress.config.ts b/frontend/cypress.config.ts index 14d36fc74..4bdbd257d 100644 --- a/frontend/cypress.config.ts +++ b/frontend/cypress.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'cypress' +import { defineConfig } from 'cypress'; export default defineConfig({ projectId: 'ry4br7', @@ -12,12 +12,18 @@ export default defineConfig({ }, chromeWebSecurity: false, e2e: { - // We've imported your old cypress plugins here. - // You may want to clean this up later by importing these. - setupNodeEvents(on, config) { - return require('./cypress/plugins/index.js')(on, config) + setupNodeEvents(on: any, config: any) { + const fs = require('fs'); + const CONFIG_FILE = 'mempool-frontend-config.json'; + if (fs.existsSync(CONFIG_FILE)) { + let contents = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); + config.env.BASE_MODULE = contents.BASE_MODULE ? contents.BASE_MODULE : 'mempool'; + } else { + config.env.BASE_MODULE = 'mempool'; + } + return config; }, baseUrl: 'http://localhost:4200', specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}', }, -}) +}); diff --git a/frontend/cypress/e2e/bisq/bisq.spec.ts b/frontend/cypress/e2e/bisq/bisq.spec.ts index e81b17185..ac3b747b2 100644 --- a/frontend/cypress/e2e/bisq/bisq.spec.ts +++ b/frontend/cypress/e2e/bisq/bisq.spec.ts @@ -1,5 +1,5 @@ describe('Bisq', () => { - const baseModule = Cypress.env("BASE_MODULE"); + const baseModule = Cypress.env('BASE_MODULE'); const basePath = ''; beforeEach(() => { @@ -20,7 +20,7 @@ describe('Bisq', () => { cy.waitForSkeletonGone(); }); - describe("transactions", () => { + describe('transactions', () => { it('loads the transactions screen', () => { cy.visit(`${basePath}`); cy.waitForSkeletonGone(); @@ -30,9 +30,9 @@ describe('Bisq', () => { }); const filters = [ - "Asset listing fee", "Blind vote", "Compensation request", - "Genesis", "Irregular", "Lockup", "Pay trade fee", "Proof of burn", - "Proposal", "Reimbursement request", "Transfer BSQ", "Unlock", "Vote reveal" + 'Asset listing fee', 'Blind vote', 'Compensation request', + 'Genesis', 'Irregular', 'Lockup', 'Pay trade fee', 'Proof of burn', + 'Proposal', 'Reimbursement request', 'Transfer BSQ', 'Unlock', 'Vote reveal' ]; filters.forEach((filter) => { it.only(`filters the transaction screen by ${filter}`, () => { @@ -49,7 +49,7 @@ describe('Bisq', () => { }); }); - it("filters using multiple criteria", () => { + it('filters using multiple criteria', () => { const filters = ['Proposal', 'Lockup', 'Unlock']; cy.visit(`${basePath}/transactions`); cy.waitForSkeletonGone(); diff --git a/frontend/cypress/e2e/liquid/liquid.spec.ts b/frontend/cypress/e2e/liquid/liquid.spec.ts index e24b19fad..e22a9b94e 100644 --- a/frontend/cypress/e2e/liquid/liquid.spec.ts +++ b/frontend/cypress/e2e/liquid/liquid.spec.ts @@ -1,5 +1,5 @@ describe('Liquid', () => { - const baseModule = Cypress.env("BASE_MODULE"); + const baseModule = Cypress.env('BASE_MODULE'); const basePath = ''; beforeEach(() => { diff --git a/frontend/cypress/e2e/liquidtestnet/liquidtestnet.spec.ts b/frontend/cypress/e2e/liquidtestnet/liquidtestnet.spec.ts index 5cf6cf331..e1172e51a 100644 --- a/frontend/cypress/e2e/liquidtestnet/liquidtestnet.spec.ts +++ b/frontend/cypress/e2e/liquidtestnet/liquidtestnet.spec.ts @@ -1,5 +1,5 @@ describe('Liquid Testnet', () => { - const baseModule = Cypress.env("BASE_MODULE"); + const baseModule = Cypress.env('BASE_MODULE'); const basePath = '/testnet'; beforeEach(() => { diff --git a/frontend/cypress/e2e/mainnet/mainnet.spec.ts b/frontend/cypress/e2e/mainnet/mainnet.spec.ts index 5ab3f9ce9..71a35ba86 100644 --- a/frontend/cypress/e2e/mainnet/mainnet.spec.ts +++ b/frontend/cypress/e2e/mainnet/mainnet.spec.ts @@ -1,6 +1,6 @@ -import { emitMempoolInfo, dropWebSocket } from "../../support/websocket"; +import { emitMempoolInfo, dropWebSocket } from '../../support/websocket'; -const baseModule = Cypress.env("BASE_MODULE"); +const baseModule = Cypress.env('BASE_MODULE'); //Credit: https://github.com/bahmutov/cypress-examples/blob/6cedb17f83a3bb03ded13cf1d6a3f0656ca2cdf5/docs/recipes/overlapping-elements.md @@ -339,14 +339,14 @@ describe('Mainnet', () => { cy.visit('/'); cy.waitForSkeletonGone(); - cy.changeNetwork("testnet"); - cy.changeNetwork("signet"); - cy.changeNetwork("mainnet"); + cy.changeNetwork('testnet'); + cy.changeNetwork('signet'); + cy.changeNetwork('mainnet'); }); it.skip('loads the dashboard with the skeleton blocks', () => { cy.mockMempoolSocket(); - cy.visit("/"); + 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'); diff --git a/frontend/cypress/e2e/mainnet/mining.spec.ts b/frontend/cypress/e2e/mainnet/mining.spec.ts index 5c60f3a8c..cfaa40015 100644 --- a/frontend/cypress/e2e/mainnet/mining.spec.ts +++ b/frontend/cypress/e2e/mainnet/mining.spec.ts @@ -1,4 +1,4 @@ -const baseModule = Cypress.env("BASE_MODULE"); +const baseModule = Cypress.env('BASE_MODULE'); describe('Mainnet - Mining Features', () => { beforeEach(() => { diff --git a/frontend/cypress/e2e/signet/signet.spec.ts b/frontend/cypress/e2e/signet/signet.spec.ts index 2f09bc4b8..03cfb3480 100644 --- a/frontend/cypress/e2e/signet/signet.spec.ts +++ b/frontend/cypress/e2e/signet/signet.spec.ts @@ -1,6 +1,6 @@ -import { emitMempoolInfo } from "../../support/websocket"; +import { emitMempoolInfo } from '../../support/websocket'; -const baseModule = Cypress.env("BASE_MODULE"); +const baseModule = Cypress.env('BASE_MODULE'); describe('Signet', () => { beforeEach(() => { @@ -25,7 +25,7 @@ describe('Signet', () => { it.skip('loads the dashboard with the skeleton blocks', () => { cy.mockMempoolSocket(); - cy.visit("/signet"); + cy.visit('/signet'); 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'); @@ -35,7 +35,7 @@ describe('Signet', () => { emitMempoolInfo({ 'params': { - "network": "signet" + 'network': 'signet' } }); diff --git a/frontend/cypress/e2e/testnet/testnet.spec.ts b/frontend/cypress/e2e/testnet/testnet.spec.ts index b05229a28..4236ca207 100644 --- a/frontend/cypress/e2e/testnet/testnet.spec.ts +++ b/frontend/cypress/e2e/testnet/testnet.spec.ts @@ -1,6 +1,6 @@ -import { confirmAddress, emitMempoolInfo, sendWsMock, showNewTx, startTrackingAddress } from "../../support/websocket"; +import { emitMempoolInfo } from '../../support/websocket'; -const baseModule = Cypress.env("BASE_MODULE"); +const baseModule = Cypress.env('BASE_MODULE'); describe('Testnet', () => { beforeEach(() => { @@ -25,7 +25,7 @@ describe('Testnet', () => { it.skip('loads the dashboard with the skeleton blocks', () => { cy.mockMempoolSocket(); - cy.visit("/testnet"); + cy.visit('/testnet'); 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'); diff --git a/frontend/cypress/plugins/index.js b/frontend/cypress/plugins/index.js deleted file mode 100644 index 11f43df95..000000000 --- a/frontend/cypress/plugins/index.js +++ /dev/null @@ -1,13 +0,0 @@ -const fs = require('fs'); - -const CONFIG_FILE = 'mempool-frontend-config.json'; - -module.exports = (on, config) => { - if (fs.existsSync(CONFIG_FILE)) { - let contents = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); - config.env.BASE_MODULE = contents.BASE_MODULE ? contents.BASE_MODULE : 'mempool'; - } else { - config.env.BASE_MODULE = 'mempool'; - } - return config; -} diff --git a/frontend/mempool-frontend-config.sample.json b/frontend/mempool-frontend-config.sample.json index 9035315a4..084cbd0ef 100644 --- a/frontend/mempool-frontend-config.sample.json +++ b/frontend/mempool-frontend-config.sample.json @@ -21,5 +21,6 @@ "MAINNET_BLOCK_AUDIT_START_HEIGHT": 0, "TESTNET_BLOCK_AUDIT_START_HEIGHT": 0, "SIGNET_BLOCK_AUDIT_START_HEIGHT": 0, - "LIGHTNING": false + "LIGHTNING": false, + "HISTORICAL_PRICE": true } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f4ae62701..6372f9af6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -58,7 +58,7 @@ }, "optionalDependencies": { "@cypress/schematic": "^2.4.0", - "cypress": "^12.3.0", + "cypress": "^12.7.0", "cypress-fail-on-console-error": "~4.0.2", "cypress-wait-until": "^1.7.2", "mock-socket": "~9.1.5", @@ -7010,9 +7010,9 @@ "peer": true }, "node_modules/cypress": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.3.0.tgz", - "integrity": "sha512-ZQNebibi6NBt51TRxRMYKeFvIiQZ01t50HSy7z/JMgRVqBUey3cdjog5MYEbzG6Ktti5ckDt1tfcC47lmFwXkw==", + "version": "12.7.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.7.0.tgz", + "integrity": "sha512-7rq+nmhzz0u6yabCFyPtADU2OOrYt6pvUau9qV7xyifJ/hnsaw/vkr0tnLlcuuQKUAOC1v1M1e4Z0zG7S0IAvA==", "hasInstallScript": true, "optional": true, "dependencies": { @@ -7033,7 +7033,7 @@ "commander": "^5.1.0", "common-tags": "^1.8.0", "dayjs": "^1.10.4", - "debug": "^4.3.2", + "debug": "^4.3.4", "enquirer": "^2.3.6", "eventemitter2": "6.4.7", "execa": "4.1.0", @@ -7159,6 +7159,23 @@ "node": ">= 6" } }, + "node_modules/cypress/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/cypress/node_modules/execa": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", @@ -22276,9 +22293,9 @@ "peer": true }, "cypress": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.3.0.tgz", - "integrity": "sha512-ZQNebibi6NBt51TRxRMYKeFvIiQZ01t50HSy7z/JMgRVqBUey3cdjog5MYEbzG6Ktti5ckDt1tfcC47lmFwXkw==", + "version": "12.7.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.7.0.tgz", + "integrity": "sha512-7rq+nmhzz0u6yabCFyPtADU2OOrYt6pvUau9qV7xyifJ/hnsaw/vkr0tnLlcuuQKUAOC1v1M1e4Z0zG7S0IAvA==", "optional": true, "requires": { "@cypress/request": "^2.88.10", @@ -22298,7 +22315,7 @@ "commander": "^5.1.0", "common-tags": "^1.8.0", "dayjs": "^1.10.4", - "debug": "^4.3.2", + "debug": "^4.3.4", "enquirer": "^2.3.6", "eventemitter2": "6.4.7", "execa": "4.1.0", @@ -22382,6 +22399,15 @@ "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", "optional": true }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, + "requires": { + "ms": "2.1.2" + } + }, "execa": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index e42c1bd8f..f23866d06 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -110,7 +110,7 @@ }, "optionalDependencies": { "@cypress/schematic": "^2.4.0", - "cypress": "^12.3.0", + "cypress": "^12.7.0", "cypress-fail-on-console-error": "~4.0.2", "cypress-wait-until": "^1.7.2", "mock-socket": "~9.1.5", @@ -119,4 +119,4 @@ "scarfSettings": { "enabled": false } -} +} \ No newline at end of file diff --git a/frontend/src/app/app.constants.ts b/frontend/src/app/app.constants.ts index 779eab62e..f15733bb0 100644 --- a/frontend/src/app/app.constants.ts +++ b/frontend/src/app/app.constants.ts @@ -87,9 +87,9 @@ export const languages: Language[] = [ { code: 'ar', name: 'العربية' }, // Arabic // { code: 'bg', name: 'Български' }, // Bulgarian // { code: 'bs', name: 'Bosanski' }, // Bosnian - { code: 'ca', name: 'Català' }, // Catalan +// { code: 'ca', name: 'Català' }, // Catalan { code: 'cs', name: 'Čeština' }, // Czech -// { code: 'da', name: 'Dansk' }, // Danish + { code: 'da', name: 'Dansk' }, // Danish { code: 'de', name: 'Deutsch' }, // German // { code: 'et', name: 'Eesti' }, // Estonian // { code: 'el', name: 'Ελληνικά' }, // Greek @@ -136,12 +136,28 @@ export const languages: Language[] = [ ]; export const specialBlocks = { + '0': { + labelEvent: 'Genesis', + labelEventCompleted: 'The Genesis of Bitcoin', + }, + '210000': { + labelEvent: 'Bitcoin\'s 1st Halving', + labelEventCompleted: 'Block Subsidy has halved to 25 BTC per block', + }, + '420000': { + labelEvent: 'Bitcoin\'s 2nd Halving', + labelEventCompleted: 'Block Subsidy has halved to 12.5 BTC per block', + }, + '630000': { + labelEvent: 'Bitcoin\'s 3rd Halving', + labelEventCompleted: 'Block Subsidy has halved to 6.25 BTC per block', + }, '709632': { labelEvent: 'Taproot 🌱 activation', labelEventCompleted: 'Taproot 🌱 has been activated!', }, '840000': { - labelEvent: 'Halving 🥳', + labelEvent: 'Bitcoin\'s 4th Halving', labelEventCompleted: 'Block Subsidy has halved to 3.125 BTC per block', } }; diff --git a/frontend/src/app/bisq/bisq-block/bisq-block.component.html b/frontend/src/app/bisq/bisq-block/bisq-block.component.html index 9cc2ad699..4f79d8838 100644 --- a/frontend/src/app/bisq/bisq-block/bisq-block.component.html +++ b/frontend/src/app/bisq/bisq-block/bisq-block.component.html @@ -24,7 +24,7 @@ ‎{{ block.time | date:'yyyy-MM-dd HH:mm' }}
- () + ()
diff --git a/frontend/src/app/bisq/bisq-blocks/bisq-blocks.component.html b/frontend/src/app/bisq/bisq-blocks/bisq-blocks.component.html index 750e2e3b1..15f15b258 100644 --- a/frontend/src/app/bisq/bisq-blocks/bisq-blocks.component.html +++ b/frontend/src/app/bisq/bisq-blocks/bisq-blocks.component.html @@ -17,7 +17,7 @@ {{ block.height }} - + {{ calculateTotalOutput(block) / 100 | number: '1.2-2' }} BSQ {{ block.txs.length }} diff --git a/frontend/src/app/bisq/bisq-transaction/bisq-transaction.component.html b/frontend/src/app/bisq/bisq-transaction/bisq-transaction.component.html index 11f981774..3a23688e6 100644 --- a/frontend/src/app/bisq/bisq-transaction/bisq-transaction.component.html +++ b/frontend/src/app/bisq/bisq-transaction/bisq-transaction.component.html @@ -35,7 +35,7 @@ ‎{{ bisqTx.time | date:'yyyy-MM-dd HH:mm' }}
- () + ()
diff --git a/frontend/src/app/bisq/bisq-transactions/bisq-transactions.component.html b/frontend/src/app/bisq/bisq-transactions/bisq-transactions.component.html index 8d34448d8..bc22414ca 100644 --- a/frontend/src/app/bisq/bisq-transactions/bisq-transactions.component.html +++ b/frontend/src/app/bisq/bisq-transactions/bisq-transactions.component.html @@ -37,7 +37,7 @@ {{ calculateTotalOutput(tx.outputs) / 100 | number: '1.2-2' }} BSQ - + {{ tx.blockHeight }} diff --git a/frontend/src/app/components/about/about.component.html b/frontend/src/app/components/about/about.component.html index 03323b6ed..ed4b9db87 100644 --- a/frontend/src/app/components/about/about.component.html +++ b/frontend/src/app/components/about/about.component.html @@ -13,19 +13,9 @@

Our mempool and blockchain explorer for the Bitcoin community, focusing on the transaction fee market and multi-layer ecosystem, completely self-hosted without any trusted third-parties.

- + -
+

Enterprise Sponsors 🚀

-
+

Community Sponsors ❤️

@@ -187,7 +177,7 @@
-
+ -
+

Community Alliances

-
+

Project Translators

@@ -311,7 +301,7 @@ -
+

Project Contributors

@@ -323,7 +313,7 @@
-
+

Project Members

@@ -336,7 +326,7 @@
-
+

Project Maintainers

@@ -383,6 +373,23 @@
-
+
- + { - if (block[0].height < this.lastBlockHeight) { - return []; // Return an empty stream so the last pipe is not executed + if (block[0].height <= this.lastBlockHeight) { + return [null]; // Return an empty stream so the last pipe is not executed } this.lastBlockHeight = block[0].height; return [block]; @@ -101,14 +101,16 @@ export class BlocksList implements OnInit, OnDestroy { this.lastPage = this.page; return blocks[0]; } - this.blocksCount = Math.max(this.blocksCount, blocks[1][0].height) + 1; - if (this.stateService.env.MINING_DASHBOARD) { - // @ts-ignore: Need to add an extra field for the template - blocks[1][0].extras.pool.logo = `/resources/mining-pools/` + - blocks[1][0].extras.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg'; + if (blocks[1]) { + this.blocksCount = Math.max(this.blocksCount, blocks[1][0].height) + 1; + if (this.stateService.env.MINING_DASHBOARD) { + // @ts-ignore: Need to add an extra field for the template + blocks[1][0].extras.pool.logo = `/resources/mining-pools/` + + blocks[1][0].extras.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg'; + } + acc.unshift(blocks[1][0]); + acc = acc.slice(0, this.widget ? 6 : 15); } - acc.unshift(blocks[1][0]); - acc = acc.slice(0, this.widget ? 6 : 15); return acc; }, []) ); diff --git a/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html b/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html index 1d6038070..0950a11ed 100644 --- a/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html +++ b/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html @@ -13,7 +13,7 @@ {{ diffChange.height }} - + {{ diffChange.difficultyShorten }} diff --git a/frontend/src/app/components/difficulty-mining/difficulty-mining.component.html b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.html new file mode 100644 index 000000000..ce0bf7eff --- /dev/null +++ b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.html @@ -0,0 +1,87 @@ +
Difficulty Adjustment
+
+
+
+
+
+
Remaining
+
+ + {{ i }} blocks + {{ i }} block +
+
+
+
+
Estimate
+
+ + + + + + + {{ epochData.change | absolute | number: '1.2-2' }} + % +
+ +
+
+
+ Previous: + + + + + + + + {{ epochData.previousRetarget | absolute | number: '1.2-2' }} % +
+
+
+
Current Period
+
{{ epochData.progress | number: '1.2-2' }} %
+
+
 
+
+
+
+
Next Halving
+
+ + {{ i }} blocks + {{ i }} block +
+
+
+
+
+
+
+ + +
+
+
Remaining
+
+
+
+
+
+
+
Estimate
+
+
+
+
+
+
+
Current Period
+
+
+
+
+
+
+
diff --git a/frontend/src/app/components/difficulty-mining/difficulty-mining.component.scss b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.scss new file mode 100644 index 000000000..c5cd2dc5e --- /dev/null +++ b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.scss @@ -0,0 +1,154 @@ +.difficulty-adjustment-container { + display: flex; + flex-direction: row; + justify-content: space-around; + height: 76px; + .shared-block { + color: #ffffff66; + font-size: 12px; + } + .item { + padding: 0 5px; + width: 100%; + &:nth-child(1) { + display: none; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } + } + .card-text { + font-size: 22px; + margin-top: -9px; + position: relative; + } +} + + +.difficulty-skeleton { + display: flex; + justify-content: space-between; + @media (min-width: 376px) { + flex-direction: row; + } + .item { + max-width: 150px; + margin: 0; + width: -webkit-fill-available; + @media (min-width: 376px) { + margin: 0 auto 0px; + } + &:first-child{ + display: none; + @media (min-width: 485px) { + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + &:last-child { + margin-bottom: 0; + } + } + .card-text { + .skeleton-loader { + width: 100%; + display: block; + &:first-child { + margin: 14px auto 0; + max-width: 80px; + } + &:last-child { + margin: 10px auto 0; + max-width: 120px; + } + } + } +} + +.card { + background-color: #1d1f31; + height: 100%; +} + +.card-title { + color: #4a68b9; + font-size: 1rem; +} + +.progress { + display: inline-flex; + width: 100%; + background-color: #2d3348; + height: 1.1rem; + max-width: 180px; +} + +.skeleton-loader { + max-width: 100%; +} + +.more-padding { + padding: 18px; +} + +.small-bar { + height: 8px; + top: -4px; + max-width: 120px; +} + +.loading-container { + min-height: 76px; +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.card-wrapper { + .card { + height: auto !important; + } + .card-body { + display: flex; + flex: inherit; + text-align: center; + flex-direction: column; + justify-content: space-around; + padding: 24px 20px; + } +} + +.retarget-sign { + margin-right: -3px; + font-size: 14px; + top: -2px; + position: relative; +} + +.previous-retarget-sign { + margin-right: -2px; + font-size: 10px; +} + +.symbol { + font-size: 13px; +} \ No newline at end of file diff --git a/frontend/src/app/components/difficulty-mining/difficulty-mining.component.ts b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.ts new file mode 100644 index 000000000..2abb02e22 --- /dev/null +++ b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.ts @@ -0,0 +1,86 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { combineLatest, Observable, timer } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { StateService } from '../../services/state.service'; + +interface EpochProgress { + base: string; + change: number; + progress: number; + remainingBlocks: number; + newDifficultyHeight: number; + colorAdjustments: string; + colorPreviousAdjustments: string; + estimatedRetargetDate: number; + previousRetarget: number; + blocksUntilHalving: number; + timeUntilHalving: number; +} + +@Component({ + selector: 'app-difficulty-mining', + templateUrl: './difficulty-mining.component.html', + styleUrls: ['./difficulty-mining.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DifficultyMiningComponent implements OnInit { + isLoadingWebSocket$: Observable; + difficultyEpoch$: Observable; + + @Input() showProgress = true; + @Input() showHalving = false; + @Input() showTitle = true; + + constructor( + public stateService: StateService, + ) { } + + ngOnInit(): void { + this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$; + this.difficultyEpoch$ = combineLatest([ + this.stateService.blocks$.pipe(map(([block]) => block)), + this.stateService.difficultyAdjustment$, + ]) + .pipe( + map(([block, da]) => { + let colorAdjustments = '#ffffff66'; + if (da.difficultyChange > 0) { + colorAdjustments = '#3bcc49'; + } + if (da.difficultyChange < 0) { + colorAdjustments = '#dc3545'; + } + + let colorPreviousAdjustments = '#dc3545'; + if (da.previousRetarget) { + if (da.previousRetarget >= 0) { + colorPreviousAdjustments = '#3bcc49'; + } + if (da.previousRetarget === 0) { + colorPreviousAdjustments = '#ffffff66'; + } + } else { + colorPreviousAdjustments = '#ffffff66'; + } + + const blocksUntilHalving = 210000 - (block.height % 210000); + const timeUntilHalving = new Date().getTime() + (blocksUntilHalving * 600000); + + const data = { + base: `${da.progressPercent.toFixed(2)}%`, + change: da.difficultyChange, + progress: da.progressPercent, + remainingBlocks: da.remainingBlocks - 1, + colorAdjustments, + colorPreviousAdjustments, + newDifficultyHeight: da.nextRetargetHeight, + estimatedRetargetDate: da.estimatedRetargetDate, + previousRetarget: da.previousRetarget, + blocksUntilHalving, + timeUntilHalving, + }; + return data; + }) + ); + } +} diff --git a/frontend/src/app/components/difficulty/difficulty-tooltip.component.html b/frontend/src/app/components/difficulty/difficulty-tooltip.component.html new file mode 100644 index 000000000..d06bb5e91 --- /dev/null +++ b/frontend/src/app/components/difficulty/difficulty-tooltip.component.html @@ -0,0 +1,41 @@ +
+ + + + + {{ i }} blocks expected + {{ i }} block expected + + + + {{ i }} blocks mined + {{ i }} block mined + + + + + {{ i }} blocks remaining + {{ i }} block remaining + + + + {{ i }} blocks ahead + {{ i }} block ahead + + + + {{ i }} blocks behind + {{ i }} block behind + + + Next Block + + +
\ No newline at end of file diff --git a/frontend/src/app/components/difficulty/difficulty-tooltip.component.scss b/frontend/src/app/components/difficulty/difficulty-tooltip.component.scss new file mode 100644 index 000000000..5b4a8a02f --- /dev/null +++ b/frontend/src/app/components/difficulty/difficulty-tooltip.component.scss @@ -0,0 +1,22 @@ +.difficulty-tooltip { + position: fixed; + background: rgba(#11131f, 0.95); + border-radius: 4px; + box-shadow: 1px 1px 10px rgba(0,0,0,0.5); + color: #b1b1b1; + padding: 10px 15px; + text-align: left; + pointer-events: none; + max-width: 300px; + min-width: 200px; + text-align: center; + + p { + margin: 0; + white-space: nowrap; + } +} + +.next-block { + text-transform: lowercase; +} diff --git a/frontend/src/app/components/difficulty/difficulty-tooltip.component.ts b/frontend/src/app/components/difficulty/difficulty-tooltip.component.ts new file mode 100644 index 000000000..c7d26f61a --- /dev/null +++ b/frontend/src/app/components/difficulty/difficulty-tooltip.component.ts @@ -0,0 +1,66 @@ +import { Component, ElementRef, ViewChild, Input, OnChanges } from '@angular/core'; + +interface EpochProgress { + base: string; + change: number; + progress: number; + minedBlocks: number; + remainingBlocks: number; + expectedBlocks: number; + newDifficultyHeight: number; + colorAdjustments: string; + colorPreviousAdjustments: string; + estimatedRetargetDate: number; + previousRetarget: number; + blocksUntilHalving: number; + timeUntilHalving: number; +} + +const EPOCH_BLOCK_LENGTH = 2016; // Bitcoin mainnet + +@Component({ + selector: 'app-difficulty-tooltip', + templateUrl: './difficulty-tooltip.component.html', + styleUrls: ['./difficulty-tooltip.component.scss'], +}) +export class DifficultyTooltipComponent implements OnChanges { + @Input() status: string | void; + @Input() progress: EpochProgress | void = null; + @Input() cursorPosition: { x: number, y: number }; + + mined: number; + ahead: number; + behind: number; + expected: number; + remaining: number; + isAhead: boolean; + isBehind: boolean; + + tooltipPosition = { x: 0, y: 0 }; + + @ViewChild('tooltip') tooltipElement: ElementRef; + + constructor() {} + + ngOnChanges(changes): void { + if (changes.cursorPosition && changes.cursorPosition.currentValue) { + let x = changes.cursorPosition.currentValue.x; + let y = changes.cursorPosition.currentValue.y - 50; + if (this.tooltipElement) { + const elementBounds = this.tooltipElement.nativeElement.getBoundingClientRect(); + x -= elementBounds.width / 2; + x = Math.min(Math.max(x, 20), (window.innerWidth - 20 - elementBounds.width)); + } + this.tooltipPosition = { x, y }; + } + if ((changes.progress || changes.status) && this.progress && this.status) { + this.remaining = this.progress.remainingBlocks; + this.expected = this.progress.expectedBlocks; + this.mined = this.progress.minedBlocks; + this.ahead = Math.max(0, this.mined - this.expected); + this.behind = Math.max(0, this.expected - this.mined); + this.isAhead = this.ahead > 0; + this.isBehind = this.behind > 0; + } + } +} diff --git a/frontend/src/app/components/difficulty/difficulty.component.html b/frontend/src/app/components/difficulty/difficulty.component.html index e030f74fa..b65092331 100644 --- a/frontend/src/app/components/difficulty/difficulty.component.html +++ b/frontend/src/app/components/difficulty/difficulty.component.html @@ -3,81 +3,100 @@
-
-
Remaining
-
- - {{ i }} blocks - {{ i }} block -
-
+
+ + + + + + + + + + + + + + +
-
-
Estimate
-
- - - - - - - {{ epochData.change | absolute | number: '1.2-2' }} - % +
+
+
+ ~ +
+
Average block time
- -
-
-
- Previous: - - - +
+
+ + - - + + - {{ epochData.previousRetarget | absolute | number: '1.2-2' }} % + {{ epochData.change | absolute | number: '1.2-2' }} + % +
+ +
+
+
+ Previous: + + + + + + + + {{ epochData.previousRetarget | absolute | number: '1.2-2' }} % +
-
-
-
Current Period
-
{{ epochData.progress | number: '1.2-2' }} %
-
-
 
+
+
+
+ {{ epochData.retargetDateString }} +
-
-
Next Halving
-
- - {{ i }} blocks - {{ i }} block -
-
-
+
+
+
-
Remaining
-
Estimate
-
Current Period
@@ -85,3 +104,10 @@
+ + \ No newline at end of file diff --git a/frontend/src/app/components/difficulty/difficulty.component.scss b/frontend/src/app/components/difficulty/difficulty.component.scss index c5cd2dc5e..9828ba8f5 100644 --- a/frontend/src/app/components/difficulty/difficulty.component.scss +++ b/frontend/src/app/components/difficulty/difficulty.component.scss @@ -1,8 +1,14 @@ .difficulty-adjustment-container { + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.difficulty-stats { display: flex; flex-direction: row; justify-content: space-around; - height: 76px; + height: 50.5px; .shared-block { color: #ffffff66; font-size: 12px; @@ -24,8 +30,8 @@ } } .card-text { - font-size: 22px; - margin-top: -9px; + font-size: 20px; + margin: auto; position: relative; } } @@ -33,7 +39,9 @@ .difficulty-skeleton { display: flex; - justify-content: space-between; + flex-direction: row; + justify-content: space-around; + height: 50.5px; @media (min-width: 376px) { flex-direction: row; } @@ -65,7 +73,7 @@ width: 100%; display: block; &:first-child { - margin: 14px auto 0; + margin: 10px auto 4px; max-width: 80px; } &:last-child { @@ -109,7 +117,7 @@ } .loading-container { - min-height: 76px; + min-height: 50.5px; } .main-title { @@ -133,7 +141,7 @@ text-align: center; flex-direction: column; justify-content: space-around; - padding: 24px 20px; + padding: 20px; } } @@ -151,4 +159,50 @@ .symbol { font-size: 13px; +} + +.epoch-progress { + width: 100%; + height: 22px; + margin-bottom: 12px; +} + +.epoch-blocks { + display: block; + width: 100%; + background: #2d3348; + + .rect { + fill: #2d3348; + + &.behind { + fill: #D81B60; + } + &.mined { + fill: url(#diff-gradient); + } + &.ahead { + fill: #1a9436; + } + + &.hover { + fill: #535e84; + &.behind { + fill: #e94d86; + } + &.mined { + fill: url(#diff-hover-gradient); + } + &.ahead { + fill: #29d951; + } + } + } +} + +.blocks-ahead { + color: #3bcc49; +} +.blocks-behind { + color: #D81B60; } \ No newline at end of file diff --git a/frontend/src/app/components/difficulty/difficulty.component.ts b/frontend/src/app/components/difficulty/difficulty.component.ts index 76a996acc..b246a14fe 100644 --- a/frontend/src/app/components/difficulty/difficulty.component.ts +++ b/frontend/src/app/components/difficulty/difficulty.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, HostListener, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; import { combineLatest, Observable, timer } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; import { StateService } from '../..//services/state.service'; @@ -7,16 +7,33 @@ interface EpochProgress { base: string; change: number; progress: number; + minedBlocks: number; remainingBlocks: number; + expectedBlocks: number; newDifficultyHeight: number; colorAdjustments: string; colorPreviousAdjustments: string; estimatedRetargetDate: number; + retargetDateString: string; previousRetarget: number; blocksUntilHalving: number; timeUntilHalving: number; + timeAvg: number; } +type BlockStatus = 'mined' | 'behind' | 'ahead' | 'next' | 'remaining'; + +interface DiffShape { + x: number; + y: number; + w: number; + h: number; + status: BlockStatus; + expected: boolean; +} + +const EPOCH_BLOCK_LENGTH = 2016; // Bitcoin mainnet + @Component({ selector: 'app-difficulty', templateUrl: './difficulty.component.html', @@ -24,15 +41,27 @@ interface EpochProgress { changeDetection: ChangeDetectionStrategy.OnPush, }) export class DifficultyComponent implements OnInit { - isLoadingWebSocket$: Observable; - difficultyEpoch$: Observable; - @Input() showProgress = true; @Input() showHalving = false; @Input() showTitle = true; + + isLoadingWebSocket$: Observable; + difficultyEpoch$: Observable; + + epochStart: number; + currentHeight: number; + currentIndex: number; + expectedHeight: number; + expectedIndex: number; + difference: number; + shapes: DiffShape[]; + + tooltipPosition = { x: 0, y: 0 }; + hoverSection: DiffShape | void; constructor( public stateService: StateService, + @Inject(LOCALE_ID) private locale: string, ) { } ngOnInit(): void { @@ -65,22 +94,110 @@ export class DifficultyComponent implements OnInit { const blocksUntilHalving = 210000 - (block.height % 210000); const timeUntilHalving = new Date().getTime() + (blocksUntilHalving * 600000); + const newEpochStart = Math.floor(this.stateService.latestBlockHeight / EPOCH_BLOCK_LENGTH) * EPOCH_BLOCK_LENGTH; + const newExpectedHeight = Math.floor(newEpochStart + da.expectedBlocks); + + if (newEpochStart !== this.epochStart || newExpectedHeight !== this.expectedHeight || this.currentHeight !== this.stateService.latestBlockHeight) { + this.epochStart = newEpochStart; + this.expectedHeight = newExpectedHeight; + this.currentHeight = this.stateService.latestBlockHeight; + this.currentIndex = this.currentHeight - this.epochStart; + this.expectedIndex = Math.min(this.expectedHeight - this.epochStart, 2016) - 1; + this.difference = this.currentIndex - this.expectedIndex; + + this.shapes = []; + this.shapes = this.shapes.concat(this.blocksToShapes( + 0, Math.min(this.currentIndex, this.expectedIndex), 'mined', true + )); + this.shapes = this.shapes.concat(this.blocksToShapes( + this.currentIndex + 1, this.expectedIndex, 'behind', true + )); + this.shapes = this.shapes.concat(this.blocksToShapes( + this.expectedIndex + 1, this.currentIndex, 'ahead', false + )); + if (this.currentIndex < 2015) { + this.shapes = this.shapes.concat(this.blocksToShapes( + this.currentIndex + 1, this.currentIndex + 1, 'next', (this.expectedIndex > this.currentIndex) + )); + } + this.shapes = this.shapes.concat(this.blocksToShapes( + Math.max(this.currentIndex + 2, this.expectedIndex + 1), 2105, 'remaining', false + )); + } + + + let retargetDateString; + if (da.remainingBlocks > 1870) { + retargetDateString = (new Date(da.estimatedRetargetDate)).toLocaleDateString(this.locale, { month: 'long', day: 'numeric' }); + } else { + retargetDateString = (new Date(da.estimatedRetargetDate)).toLocaleTimeString(this.locale, { month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }); + } const data = { base: `${da.progressPercent.toFixed(2)}%`, change: da.difficultyChange, progress: da.progressPercent, - remainingBlocks: da.remainingBlocks, + minedBlocks: this.currentIndex + 1, + remainingBlocks: da.remainingBlocks - 1, + expectedBlocks: Math.floor(da.expectedBlocks), colorAdjustments, colorPreviousAdjustments, newDifficultyHeight: da.nextRetargetHeight, estimatedRetargetDate: da.estimatedRetargetDate, + retargetDateString, previousRetarget: da.previousRetarget, blocksUntilHalving, timeUntilHalving, + timeAvg: da.timeAvg, }; return data; }) ); } + + blocksToShapes(start: number, end: number, status: BlockStatus, expected: boolean = false): DiffShape[] { + const startY = start % 9; + const startX = Math.floor(start / 9); + const endY = (end % 9); + const endX = Math.floor(end / 9); + + if (startX > endX) { + return []; + } + + if (startX === endX) { + return [{ + x: startX, y: startY, w: 1, h: 1 + endY - startY, status, expected + }]; + } + + const shapes = []; + shapes.push({ + x: startX, y: startY, w: 1, h: 9 - startY, status, expected + }); + shapes.push({ + x: endX, y: 0, w: 1, h: endY + 1, status, expected + }); + + if (startX < endX - 1) { + shapes.push({ + x: startX + 1, y: 0, w: endX - startX - 1, h: 9, status, expected + }); + } + + return shapes; + } + + @HostListener('pointermove', ['$event']) + onPointerMove(event) { + this.tooltipPosition = { x: event.clientX, y: event.clientY }; + } + + onHover(event, rect): void { + this.hoverSection = rect; + } + + onBlur(event): void { + this.hoverSection = null; + } } diff --git a/frontend/src/app/components/graphs/graphs.component.html b/frontend/src/app/components/graphs/graphs.component.html index af5136a38..105c6cbf2 100644 --- a/frontend/src/app/components/graphs/graphs.component.html +++ b/frontend/src/app/components/graphs/graphs.component.html @@ -1,10 +1,9 @@ -