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/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..2fc497650 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -217,7 +217,15 @@ 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); + } else { + res.json({ + ancestors: [] + }); + return; + } if (cpfpInfo) { res.json(cpfpInfo); return; 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..aa33f1ff7 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -2,7 +2,7 @@ import config from '../config'; import bitcoinApi 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 bitcoinApi.$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 bitcoinApi.$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 bitcoinApi.$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 bitcoinApi.$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); } - 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 bitcoinApi.$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/database-migration.ts b/backend/src/api/database-migration.ts index 6216e7f2b..b09380d2f 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -501,7 +501,7 @@ class DatabaseMigration { await this.updateToSchemaVersion(56); } - if (databaseSchemaVersion < 57) { + if (databaseSchemaVersion < 57 && isBitcoin === true) { await this.$executeQuery(`ALTER TABLE nodes MODIFY updated_at datetime NULL`); await this.updateToSchemaVersion(57); } diff --git a/backend/src/api/disk-cache.ts b/backend/src/api/disk-cache.ts index a75fd43cc..83d37fe3f 100644 --- a/backend/src/api/disk-cache.ts +++ b/backend/src/api/disk-cache.ts @@ -9,7 +9,7 @@ import { TransactionExtended } from '../mempool.interfaces'; import { Common } from './common'; class DiskCache { - private cacheSchemaVersion = 2; + private cacheSchemaVersion = 3; private static FILE_NAME = config.MEMPOOL.CACHE_DIR + '/cache.json'; private static FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/cache{number}.json'; @@ -62,9 +62,24 @@ class DiskCache { } wipeCache() { - fs.unlinkSync(DiskCache.FILE_NAME); + logger.notice(`Wipping 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++) { - fs.unlinkSync(DiskCache.FILE_NAMES.replace('{number}', i.toString())); + 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)}`); + } + } } } 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/mining/mining.ts b/backend/src/api/mining/mining.ts index 347752f99..7d87e004c 100644 --- a/backend/src/api/mining/mining.ts +++ b/backend/src/api/mining/mining.ts @@ -11,6 +11,8 @@ import DifficultyAdjustmentsRepository from '../../repositories/DifficultyAdjust import config from '../../config'; import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository'; import PricesRepository from '../../repositories/PricesRepository'; +import bitcoinApiFactory from '../bitcoin/bitcoin-api-factory'; +import { IEsploraApi } from '../bitcoin/esplora-api.interface'; class Mining { private blocksPriceIndexingRunning = false; @@ -189,8 +191,8 @@ class Mining { 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 bitcoinApiFactory.$getBlock(await bitcoinClient.getBlockHash(0)); + const genesisTimestamp = genesisBlock.timestamp * 1000; const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps(); const hashrates: any[] = []; @@ -292,8 +294,8 @@ class Mining { 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 bitcoinApiFactory.$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()); @@ -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 bitcoinApiFactory.$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, 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/index.ts b/backend/src/index.ts index 05c2ffa83..3ace7a5f2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -110,6 +110,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(); @@ -168,7 +169,6 @@ class Server { logger.debug(msg); } } - await poolsUpdater.updatePoolsJson(); await blocks.$updateBlocks(); await memPool.$updateMempool(); indexer.$run(); @@ -176,7 +176,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(); @@ -196,8 +203,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); diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index cb95be98a..a7937e01d 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; } diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index c7edb97cb..80df1ac92 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; @@ -833,6 +857,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/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..6493735ee 100644 --- a/backend/src/repositories/PricesRepository.ts +++ b/backend/src/repositories/PricesRepository.ts @@ -40,7 +40,7 @@ export const MAX_PRICES = { class PricesRepository { public async $savePrices(time: number, prices: IConversionRates): Promise { - if (prices.USD === 0) { + 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; 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/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/components/about/about.component.html b/frontend/src/app/components/about/about.component.html index 03323b6ed..23fed8dcd 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,17 @@