Merge branch 'master' into mononaut/optimize-mempool-block-7
This commit is contained in:
		
						commit
						b21fd0d37d
					
				
							
								
								
									
										1
									
								
								.github/CODEOWNERS
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.github/CODEOWNERS
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| backend/src/api/database-migration.ts	@wiz @softsimon | ||||
							
								
								
									
										4
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @ -9,7 +9,7 @@ jobs: | ||||
|     if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node: ["16.16.0", "18.5.0"] | ||||
|         node: ["16.16.0", "18.14.1"] | ||||
|         flavor: ["dev", "prod"] | ||||
|       fail-fast: false | ||||
|     runs-on: "ubuntu-latest" | ||||
| @ -55,7 +55,7 @@ jobs: | ||||
|     if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node: ["16.15.0", "18.5.0"] | ||||
|         node: ["16.16.0", "18.14.1"] | ||||
|         flavor: ["dev", "prod"] | ||||
|       fail-fast: false | ||||
|     runs-on: "ubuntu-latest" | ||||
|  | ||||
							
								
								
									
										5
									
								
								.github/workflows/cypress.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.github/workflows/cypress.yml
									
									
									
									
										vendored
									
									
								
							| @ -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/')" | ||||
|  | ||||
							
								
								
									
										1
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,5 @@ | ||||
| { | ||||
|   "editor.tabSize": 2, | ||||
|   "typescript.preferences.importModuleSpecifier": "relative", | ||||
|   "typescript.tsdk": "./backend/node_modules/typescript/lib" | ||||
| } | ||||
							
								
								
									
										2
									
								
								LICENSE
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								LICENSE
									
									
									
									
									
								
							| @ -1,5 +1,5 @@ | ||||
| The Mempool Open Source Project | ||||
| Copyright (c) 2019-2022 The Mempool Open Source Project Developers | ||||
| Copyright (c) 2019-2023 The Mempool Open Source Project Developers | ||||
| 
 | ||||
| This program is free software; you can redistribute it and/or modify it under | ||||
| the terms of (at your option) either: | ||||
|  | ||||
| @ -1,5 +1,7 @@ | ||||
| # The Mempool Open Source Project™ [](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. | ||||
|  | ||||
| @ -160,7 +160,7 @@ npm install -g ts-node nodemon | ||||
| Then, run the watcher: | ||||
| 
 | ||||
| ``` | ||||
| nodemon src/index.ts --ignore cache/ --ignore pools.json | ||||
| nodemon src/index.ts --ignore cache/ | ||||
| ``` | ||||
| 
 | ||||
| `nodemon` should be in npm's global binary folder. If needed, you can determine where that is with `npm -g bin`. | ||||
| @ -219,6 +219,16 @@ Generate block at regular interval (every 10 seconds in this example): | ||||
|    watch -n 10 "./src/bitcoin-cli -regtest -rpcport=8332 generatetoaddress 1 $address" | ||||
|    ``` | ||||
| 
 | ||||
| ### Mining pools update | ||||
| 
 | ||||
| By default, mining pools will be not automatically updated regularly (`config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING` is set to `false`).  | ||||
| 
 | ||||
| To manually update your mining pools, you can use the `--update-pools` command line flag when you run the nodejs backend. For example `npm run start --update-pools`. This will trigger the mining pools update and automatically re-index appropriate blocks. | ||||
| 
 | ||||
| You can enabled the automatic mining pools update by settings `config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING` to `true` in your `mempool-config.json`. | ||||
| 
 | ||||
| When a `coinbase tag` or `coinbase address` change is detected, all blocks tagged to the `unknown` mining pools (starting from height 130635) will be deleted from the `blocks` table. Additionaly, all blocks which were tagged to the pool which has been updated will also be deleted from the `blocks` table. Of course, those blocks will be automatically reindexed. | ||||
| 
 | ||||
| ### Re-index tables | ||||
| 
 | ||||
| You can manually force the nodejs backend to drop all data from a specified set of tables for future re-index. This is mostly useful for the mining dashboard and the lightning explorer. | ||||
| @ -235,4 +245,4 @@ Feb 13 14:55:27 [63246] WARN: <lightning> Indexed data for "hashrates" tables wi | ||||
| Feb 13 14:55:32 [63246] NOTICE: <lightning> Table hashrates has been truncated | ||||
| ``` | ||||
| 
 | ||||
| Reference: https://github.com/mempool/mempool/pull/1269 | ||||
| Reference: https://github.com/mempool/mempool/pull/1269 | ||||
|  | ||||
| @ -22,7 +22,7 @@ | ||||
|     "USER_AGENT": "mempool", | ||||
|     "STDOUT_LOG_MIN_PRIORITY": "debug", | ||||
|     "AUTOMATIC_BLOCK_REINDEXING": false, | ||||
|     "POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json", | ||||
|     "POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json", | ||||
|     "POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master", | ||||
|     "AUDIT": false, | ||||
|     "ADVANCED_GBT_AUDIT": false, | ||||
|  | ||||
| @ -27,7 +27,7 @@ | ||||
|     "package": "npm run build && rm -rf package && mv dist package && mv node_modules package && npm run package-rm-build-deps", | ||||
|     "package-rm-build-deps": "(cd package/node_modules; rm -r typescript @typescript-eslint)", | ||||
|     "start": "node --max-old-space-size=2048 dist/index.js", | ||||
|     "start-production": "node --max-old-space-size=4096 dist/index.js", | ||||
|     "start-production": "node --max-old-space-size=16384 dist/index.js", | ||||
|     "test": "./node_modules/.bin/jest --coverage", | ||||
|     "lint": "./node_modules/.bin/eslint . --ext .ts", | ||||
|     "lint:fix": "./node_modules/.bin/eslint . --ext .ts --fix", | ||||
|  | ||||
| @ -3,12 +3,11 @@ | ||||
|     "ENABLED": true, | ||||
|     "NETWORK": "__MEMPOOL_NETWORK__", | ||||
|     "BACKEND": "__MEMPOOL_BACKEND__", | ||||
|     "ENABLED": true, | ||||
|     "BLOCKS_SUMMARIES_INDEXING": true, | ||||
|     "HTTP_PORT": 1, | ||||
|     "SPAWN_CLUSTER_PROCS": 2, | ||||
|     "API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__", | ||||
|     "AUTOMATIC_BLOCK_REINDEXING": true, | ||||
|     "AUTOMATIC_BLOCK_REINDEXING": false, | ||||
|     "POLL_RATE_MS": 3, | ||||
|     "CACHE_DIR": "__MEMPOOL_CACHE_DIR__", | ||||
|     "CLEAR_PROTECTION_MINUTES": 4, | ||||
| @ -28,7 +27,8 @@ | ||||
|     "AUDIT": "__MEMPOOL_AUDIT__", | ||||
|     "ADVANCED_GBT_AUDIT": "__MEMPOOL_ADVANCED_GBT_AUDIT__", | ||||
|     "ADVANCED_GBT_MEMPOOL": "__MEMPOOL_ADVANCED_GBT_MEMPOOL__", | ||||
|     "CPFP_INDEXING": "__MEMPOOL_CPFP_INDEXING__" | ||||
|     "CPFP_INDEXING": "__MEMPOOL_CPFP_INDEXING__", | ||||
|     "MAX_BLOCKS_BULK_QUERY": "__MEMPOOL_MAX_BLOCKS_BULK_QUERY__" | ||||
|   }, | ||||
|   "CORE_RPC": { | ||||
|     "HOST": "__CORE_RPC_HOST__", | ||||
|  | ||||
| @ -36,11 +36,12 @@ describe('Mempool Backend Config', () => { | ||||
|         USER_AGENT: 'mempool', | ||||
|         STDOUT_LOG_MIN_PRIORITY: 'debug', | ||||
|         POOLS_JSON_TREE_URL: 'https://api.github.com/repos/mempool/mining-pools/git/trees/master', | ||||
|         POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json', | ||||
|         POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json', | ||||
|         AUDIT: false, | ||||
|         ADVANCED_GBT_AUDIT: false, | ||||
|         ADVANCED_GBT_MEMPOOL: false, | ||||
|         CPFP_INDEXING: false, | ||||
|         MAX_BLOCKS_BULK_QUERY: 0, | ||||
|       }); | ||||
| 
 | ||||
|       expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true }); | ||||
|  | ||||
| @ -119,7 +119,8 @@ class Audit { | ||||
|     } | ||||
| 
 | ||||
|     const numCensored = Object.keys(isCensored).length; | ||||
|     const score = matches.length > 0 ? (matches.length / (matches.length + numCensored)) : 0; | ||||
|     const numMatches = matches.length - 1; // adjust for coinbase tx
 | ||||
|     const score = numMatches > 0 ? (numMatches / (numMatches + numCensored)) : 0; | ||||
| 
 | ||||
|     return { | ||||
|       censored: Object.keys(isCensored), | ||||
|  | ||||
| @ -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; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -28,6 +28,7 @@ class BitcoinApi implements AbstractBitcoinApi { | ||||
|       size: block.size, | ||||
|       weight: block.weight, | ||||
|       previousblockhash: block.previousblockhash, | ||||
|       mediantime: block.mediantime, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -95,6 +95,8 @@ class BitcoinRoutes { | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/audit-summary', this.getBlockAuditSummary) | ||||
|       .post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this)) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this)) | ||||
|       ; | ||||
| 
 | ||||
|       if (config.MEMPOOL.BACKEND !== 'esplora') { | ||||
| @ -215,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; | ||||
| @ -402,6 +412,41 @@ class BitcoinRoutes { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async getBlocksByBulk(req: Request, res: Response) { | ||||
|     try { | ||||
|       if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) { // Liquid, Bisq - Not implemented
 | ||||
|         return res.status(404).send(`This API is only available for Bitcoin networks`); | ||||
|       } | ||||
|       if (config.MEMPOOL.MAX_BLOCKS_BULK_QUERY <= 0) { | ||||
|         return res.status(404).send(`This API is disabled. Set config.MEMPOOL.MAX_BLOCKS_BULK_QUERY to a positive number to enable it.`); | ||||
|       } | ||||
|       if (!Common.indexingEnabled()) { | ||||
|         return res.status(404).send(`Indexing is required for this API`); | ||||
|       } | ||||
| 
 | ||||
|       const from = parseInt(req.params.from, 10); | ||||
|       if (!req.params.from || from < 0) { | ||||
|         return res.status(400).send(`Parameter 'from' must be a block height (integer)`); | ||||
|       } | ||||
|       const to = req.params.to === undefined ? await bitcoinApi.$getBlockHeightTip() : parseInt(req.params.to, 10); | ||||
|       if (to < 0) { | ||||
|         return res.status(400).send(`Parameter 'to' must be a block height (integer)`); | ||||
|       } | ||||
|       if (from > to) { | ||||
|         return res.status(400).send(`Parameter 'to' must be a higher block height than 'from'`); | ||||
|       } | ||||
|       if ((to - from + 1) > config.MEMPOOL.MAX_BLOCKS_BULK_QUERY) { | ||||
|         return res.status(400).send(`You can only query ${config.MEMPOOL.MAX_BLOCKS_BULK_QUERY} blocks at once.`); | ||||
|       } | ||||
| 
 | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); | ||||
|       res.json(await blocks.$getBlocksBetweenHeight(from, to)); | ||||
| 
 | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async getLegacyBlocks(req: Request, res: Response) { | ||||
|     try { | ||||
|       const returnBlocks: IEsploraApi.Block[] = []; | ||||
|  | ||||
| @ -88,6 +88,7 @@ export namespace IEsploraApi { | ||||
|     size: number; | ||||
|     weight: number; | ||||
|     previousblockhash: string; | ||||
|     mediantime: number; | ||||
|   } | ||||
| 
 | ||||
|   export interface Address { | ||||
|  | ||||
| @ -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'; | ||||
| @ -25,6 +24,7 @@ import mining from './mining/mining'; | ||||
| import DifficultyAdjustmentsRepository from '../repositories/DifficultyAdjustmentsRepository'; | ||||
| import PricesRepository from '../repositories/PricesRepository'; | ||||
| import priceUpdater from '../tasks/price-updater'; | ||||
| import chainTips from './chain-tips'; | ||||
| 
 | ||||
| class Blocks { | ||||
|   private blocks: BlockExtended[] = []; | ||||
| @ -142,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, | ||||
| @ -165,33 +165,81 @@ class Blocks { | ||||
|    * @returns BlockExtended | ||||
|    */ | ||||
|   private async $getBlockExtended(block: IEsploraApi.Block, transactions: TransactionExtended[]): Promise<BlockExtended> { | ||||
|     const blockExtended: BlockExtended = Object.assign({ extras: {} }, block); | ||||
|     blockExtended.extras.reward = transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0); | ||||
|     blockExtended.extras.coinbaseTx = transactionUtils.stripCoinbaseTransaction(transactions[0]); | ||||
|     blockExtended.extras.coinbaseRaw = blockExtended.extras.coinbaseTx.vin[0].scriptsig; | ||||
|     blockExtended.extras.usd = priceUpdater.latestPrices.USD; | ||||
|     const coinbaseTx = transactionUtils.stripCoinbaseTransaction(transactions[0]); | ||||
|      | ||||
|     const blk: Partial<BlockExtended> = Object.assign({}, block); | ||||
|     const extras: Partial<BlockExtension> = {}; | ||||
| 
 | ||||
|     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) { | ||||
|       blockExtended.extras.medianFee = 0; // 50th percentiles
 | ||||
|       blockExtended.extras.feeRange = [0, 0, 0, 0, 0, 0, 0]; | ||||
|       blockExtended.extras.totalFees = 0; | ||||
|       blockExtended.extras.avgFee = 0; | ||||
|       blockExtended.extras.avgFeeRate = 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, [ | ||||
|         'feerate_percentiles', 'minfeerate', 'maxfeerate', 'totalfee', 'avgfee', 'avgfeerate' | ||||
|       ]); | ||||
|       blockExtended.extras.medianFee = stats.feerate_percentiles[2]; // 50th percentiles
 | ||||
|       blockExtended.extras.feeRange = [stats.minfeerate, stats.feerate_percentiles, stats.maxfeerate].flat(); | ||||
|       blockExtended.extras.totalFees = stats.totalfee; | ||||
|       blockExtended.extras.avgFee = stats.avgfee; | ||||
|       blockExtended.extras.avgFeeRate = stats.avgfeerate; | ||||
|       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()) { | ||||
|       extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(block.id); | ||||
|       if (extras.feePercentiles !== null) { | ||||
|         extras.medianFeeAmt = extras.feePercentiles[3]; | ||||
|       } | ||||
|     } | ||||
|    | ||||
|     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 { | ||||
|       extras.coinbaseAddress = null; | ||||
|       extras.coinbaseSignature = null; | ||||
|       extras.coinbaseSignatureAscii = null; | ||||
|     } | ||||
| 
 | ||||
|     const header = await bitcoinClient.getBlockHeader(block.id, false); | ||||
|     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); | ||||
|       extras.utxoSetSize = txoutset.txouts, | ||||
|       extras.totalInputAmt = Math.round(txoutset.block_info.prevout_spent * 100000000); | ||||
|     } else { | ||||
|       extras.utxoSetSize = null; | ||||
|       extras.totalInputAmt = null; | ||||
|     } | ||||
| 
 | ||||
|     if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { | ||||
|       let pool: PoolTag; | ||||
|       if (blockExtended.extras?.coinbaseTx !== undefined) { | ||||
|         pool = await this.$findBlockMiner(blockExtended.extras?.coinbaseTx); | ||||
|       if (coinbaseTx !== undefined) { | ||||
|         pool = await this.$findBlockMiner(coinbaseTx); | ||||
|       } else { | ||||
|         if (config.DATABASE.ENABLED === true) { | ||||
|           pool = await poolsRepository.$getUnknownPool(); | ||||
| @ -201,25 +249,27 @@ class Blocks { | ||||
|       } | ||||
| 
 | ||||
|       if (!pool) { // We should never have this situation in practise
 | ||||
|         logger.warn(`Cannot assign pool to block ${blockExtended.height} and 'unknown' pool does not exist. ` + | ||||
|         logger.warn(`Cannot assign pool to block ${blk.height} and 'unknown' pool does not exist. ` + | ||||
|           `Check your "pools" table entries`); | ||||
|       } else { | ||||
|         blockExtended.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) { | ||||
|           blockExtended.extras.matchRate = auditScore.matchRate; | ||||
|           extras.matchRate = auditScore.matchRate; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return blockExtended; | ||||
|     blk.extras = <BlockExtension>extras; | ||||
|     return <BlockExtended>blk; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
| @ -245,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); | ||||
| @ -431,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); | ||||
| 
 | ||||
| @ -479,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.`); | ||||
|         } | ||||
| @ -500,6 +553,7 @@ class Blocks { | ||||
|       } else { | ||||
|         this.currentBlockHeight++; | ||||
|         logger.debug(`New block found (#${this.currentBlockHeight})!`); | ||||
|         await chainTips.updateOrphanedBlocks(); | ||||
|       } | ||||
| 
 | ||||
|       const blockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight); | ||||
| @ -516,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(); | ||||
| @ -603,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); | ||||
| 
 | ||||
| @ -616,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<BlockExtended | IEsploraApi.Block> { | ||||
|     // Check the memory cache
 | ||||
| @ -629,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, | ||||
| @ -687,8 +724,19 @@ 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<BlockExtended[]> { | ||||
| 
 | ||||
|     let currentHeight = fromHeight !== undefined ? fromHeight : this.currentBlockHeight; | ||||
|     if (currentHeight > this.currentBlockHeight) { | ||||
|       limit -= currentHeight - this.currentBlockHeight; | ||||
| @ -700,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--; | ||||
|     } | ||||
| @ -728,6 +764,114 @@ class Blocks { | ||||
|     return returnBlocks; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Used for bulk block data query | ||||
|    *  | ||||
|    * @param fromHeight  | ||||
|    * @param toHeight  | ||||
|    */ | ||||
|   public async $getBlocksBetweenHeight(fromHeight: number, toHeight: number): Promise<any> { | ||||
|     if (!Common.indexingEnabled()) { | ||||
|       return []; | ||||
|     } | ||||
| 
 | ||||
|     const blocks: any[] = []; | ||||
| 
 | ||||
|     while (fromHeight <= toHeight) { | ||||
|       let block: BlockExtended | null = await blocksRepository.$getBlockByHeight(fromHeight); | ||||
|       if (!block) { | ||||
|         await this.$indexBlock(fromHeight); | ||||
|         block = await blocksRepository.$getBlockByHeight(fromHeight); | ||||
|         if (!block) { | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       // Cleanup fields before sending the response
 | ||||
|       const cleanBlock: any = { | ||||
|         height: block.height ?? null, | ||||
|         hash: block.id ?? null, | ||||
|         timestamp: block.timestamp ?? null, | ||||
|         median_timestamp: block.mediantime ?? null, | ||||
|         previous_block_hash: block.previousblockhash ?? null, | ||||
|         difficulty: block.difficulty ?? null, | ||||
|         header: block.extras.header ?? null, | ||||
|         version: block.version ?? null, | ||||
|         bits: block.bits ?? null, | ||||
|         nonce: block.nonce ?? null, | ||||
|         size: block.size ?? null, | ||||
|         weight: block.weight ?? null, | ||||
|         tx_count: block.tx_count ?? null, | ||||
|         merkle_root: block.merkle_root ?? 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) { | ||||
|         cleanBlock.fee_amt_percentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(cleanBlock.hash); | ||||
|         if (cleanBlock.fee_amt_percentiles === null) { | ||||
|           const block = await bitcoinClient.getBlock(cleanBlock.hash, 2); | ||||
|           const summary = this.summarizeBlock(block); | ||||
|           await BlocksSummariesRepository.$saveSummary({ height: block.height, mined: summary }); | ||||
|           cleanBlock.fee_amt_percentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(cleanBlock.hash); | ||||
|         } | ||||
|         if (cleanBlock.fee_amt_percentiles !== null) { | ||||
|           cleanBlock.median_fee_amt = cleanBlock.fee_amt_percentiles[3]; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       cleanBlock.fee_amt_percentiles = { | ||||
|         'min': cleanBlock.fee_amt_percentiles[0], | ||||
|         'perc_10': cleanBlock.fee_amt_percentiles[1], | ||||
|         'perc_25': cleanBlock.fee_amt_percentiles[2], | ||||
|         'perc_50': cleanBlock.fee_amt_percentiles[3], | ||||
|         'perc_75': cleanBlock.fee_amt_percentiles[4], | ||||
|         'perc_90': cleanBlock.fee_amt_percentiles[5], | ||||
|         'max': cleanBlock.fee_amt_percentiles[6], | ||||
|       }; | ||||
|       cleanBlock.fee_rate_percentiles = { | ||||
|         'min': cleanBlock.fee_rate_percentiles[0], | ||||
|         'perc_10': cleanBlock.fee_rate_percentiles[1], | ||||
|         'perc_25': cleanBlock.fee_rate_percentiles[2], | ||||
|         'perc_50': cleanBlock.fee_rate_percentiles[3], | ||||
|         'perc_75': cleanBlock.fee_rate_percentiles[4], | ||||
|         'perc_90': cleanBlock.fee_rate_percentiles[5], | ||||
|         'max': cleanBlock.fee_rate_percentiles[6], | ||||
|       }; | ||||
| 
 | ||||
|       // Re-org can happen after indexing so we need to always get the
 | ||||
|       // latest state from core
 | ||||
|       cleanBlock.orphans = chainTips.getOrphanedBlocksAtHeight(cleanBlock.height); | ||||
| 
 | ||||
|       blocks.push(cleanBlock); | ||||
|       fromHeight++; | ||||
|     } | ||||
| 
 | ||||
|     return blocks; | ||||
|   } | ||||
| 
 | ||||
|   public async $getBlockAuditSummary(hash: string): Promise<any> { | ||||
|     let summary; | ||||
|     if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { | ||||
|  | ||||
							
								
								
									
										61
									
								
								backend/src/api/chain-tips.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								backend/src/api/chain-tips.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,61 @@ | ||||
| import logger from '../logger'; | ||||
| import bitcoinClient from './bitcoin/bitcoin-client'; | ||||
| 
 | ||||
| export interface ChainTip { | ||||
|   height: number; | ||||
|   hash: string; | ||||
|   branchlen: number; | ||||
|   status: 'invalid' | 'active' | 'valid-fork' | 'valid-headers' | 'headers-only'; | ||||
| }; | ||||
| 
 | ||||
| export interface OrphanedBlock { | ||||
|   height: number; | ||||
|   hash: string; | ||||
|   status: 'valid-fork' | 'valid-headers' | 'headers-only'; | ||||
| } | ||||
| 
 | ||||
| class ChainTips { | ||||
|   private chainTips: ChainTip[] = []; | ||||
|   private orphanedBlocks: OrphanedBlock[] = []; | ||||
| 
 | ||||
|   public async updateOrphanedBlocks(): Promise<void> { | ||||
|     try { | ||||
|       this.chainTips = await bitcoinClient.getChainTips(); | ||||
|       this.orphanedBlocks = []; | ||||
| 
 | ||||
|       for (const chain of this.chainTips) { | ||||
|         if (chain.status === 'valid-fork' || chain.status === 'valid-headers') { | ||||
|           let block = await bitcoinClient.getBlock(chain.hash); | ||||
|           while (block && block.confirmations === -1) { | ||||
|             this.orphanedBlocks.push({ | ||||
|               height: block.height, | ||||
|               hash: block.hash, | ||||
|               status: chain.status | ||||
|             }); | ||||
|             block = await bitcoinClient.getBlock(block.previousblockhash); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       logger.debug(`Updated orphaned blocks cache. Found ${this.orphanedBlocks.length} orphaned blocks`); | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot get fetch orphaned blocks. Reason: ${e instanceof Error ? e.message : e}`); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public getOrphanedBlocksAtHeight(height: number | undefined): OrphanedBlock[] { | ||||
|     if (height === undefined) { | ||||
|       return []; | ||||
|     } | ||||
| 
 | ||||
|     const orphans: OrphanedBlock[] = []; | ||||
|     for (const block of this.orphanedBlocks) { | ||||
|       if (block.height === height) { | ||||
|         orphans.push(block); | ||||
|       } | ||||
|     } | ||||
|     return orphans; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new ChainTips(); | ||||
| @ -237,14 +237,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 +268,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 +278,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) { | ||||
|  | ||||
| @ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository'; | ||||
| import { RowDataPacket } from 'mysql2'; | ||||
| 
 | ||||
| class DatabaseMigration { | ||||
|   private static currentVersion = 52; | ||||
|   private static currentVersion = 57; | ||||
|   private queryTimeout = 3600_000; | ||||
|   private statisticsAddedIndexed = false; | ||||
|   private uniqueLogs: string[] = []; | ||||
| @ -62,8 +62,8 @@ class DatabaseMigration { | ||||
| 
 | ||||
|     if (databaseSchemaVersion <= 2) { | ||||
|       // Disable some spam logs when they're not relevant
 | ||||
|       this.uniqueLogs.push(this.blocksTruncatedMessage); | ||||
|       this.uniqueLogs.push(this.hashratesTruncatedMessage); | ||||
|       this.uniqueLog(logger.notice, this.blocksTruncatedMessage); | ||||
|       this.uniqueLog(logger.notice, this.hashratesTruncatedMessage); | ||||
|     } | ||||
| 
 | ||||
|     logger.debug('MIGRATIONS: Current state.schema_version ' + databaseSchemaVersion); | ||||
| @ -86,7 +86,7 @@ class DatabaseMigration { | ||||
|       try { | ||||
|         await this.$migrateTableSchemaFromVersion(databaseSchemaVersion); | ||||
|         if (databaseSchemaVersion === 0) { | ||||
|           logger.notice(`MIGRATIONS: OK. Database schema has been properly initialized to version ${DatabaseMigration.currentVersion} (latest version)`);           | ||||
|           logger.notice(`MIGRATIONS: OK. Database schema has been properly initialized to version ${DatabaseMigration.currentVersion} (latest version)`); | ||||
|         } else { | ||||
|           logger.notice(`MIGRATIONS: OK. Database schema have been migrated from version ${databaseSchemaVersion} to ${DatabaseMigration.currentVersion} (latest version)`); | ||||
|         } | ||||
| @ -300,7 +300,7 @@ class DatabaseMigration { | ||||
|       await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_base_fee_mtokens bigint(20) unsigned NOT NULL DEFAULT "0"'); | ||||
|       await this.updateToSchemaVersion(27); | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|     if (databaseSchemaVersion < 28 && isBitcoin === true) { | ||||
|       if (config.LIGHTNING.ENABLED) { | ||||
|         this.uniqueLog(logger.notice, `'lightning_stats' and 'node_stats' tables have been truncated.`); | ||||
| @ -464,10 +464,47 @@ class DatabaseMigration { | ||||
|         await this.$executeQuery('DROP TABLE IF EXISTS `transactions`'); | ||||
|         await this.$executeQuery('DROP TABLE IF EXISTS `cpfp_clusters`'); | ||||
|         await this.updateToSchemaVersion(52); | ||||
|       } catch(e) { | ||||
|       } catch (e) { | ||||
|         logger.warn('' + (e instanceof Error ? e.message : e)); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (databaseSchemaVersion < 53) { | ||||
|       await this.$executeQuery('ALTER TABLE statistics MODIFY mempool_byte_weight bigint(20) UNSIGNED NOT NULL'); | ||||
|       await this.updateToSchemaVersion(53); | ||||
|     } | ||||
| 
 | ||||
|     if (databaseSchemaVersion < 54) { | ||||
|       this.uniqueLog(logger.notice, `'prices' table has been truncated`); | ||||
|       await this.$executeQuery(`TRUNCATE prices`); | ||||
|       if (isBitcoin === true) { | ||||
|         this.uniqueLog(logger.notice, `'blocks_prices' table has been truncated`); | ||||
|         await this.$executeQuery(`TRUNCATE blocks_prices`); | ||||
|       } | ||||
|       await this.updateToSchemaVersion(54); | ||||
|     } | ||||
| 
 | ||||
|     if (databaseSchemaVersion < 55) { | ||||
|       await this.$executeQuery(this.getAdditionalBlocksDataQuery()); | ||||
|       this.uniqueLog(logger.notice, this.blocksTruncatedMessage); | ||||
|       await this.$executeQuery('TRUNCATE blocks;'); // Need to re-index
 | ||||
|       await this.updateToSchemaVersion(55); | ||||
|     } | ||||
| 
 | ||||
|     if (databaseSchemaVersion < 56) { | ||||
|       await this.$executeQuery('ALTER TABLE pools ADD unique_id int NOT NULL DEFAULT -1'); | ||||
|       await this.$executeQuery('TRUNCATE TABLE `blocks`'); | ||||
|       this.uniqueLog(logger.notice, this.blocksTruncatedMessage); | ||||
|       await this.$executeQuery('DELETE FROM `pools`'); | ||||
|       await this.$executeQuery('ALTER TABLE pools AUTO_INCREMENT = 1'); | ||||
|       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); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
| @ -591,7 +628,7 @@ class DatabaseMigration { | ||||
|       queries.push(`INSERT INTO state(name, number, string) VALUES ('last_hashrates_indexing', 0, NULL)`); | ||||
|     } | ||||
| 
 | ||||
|     if (version < 9  && isBitcoin === true) { | ||||
|     if (version < 9 && isBitcoin === true) { | ||||
|       queries.push(`INSERT INTO state(name, number, string) VALUES ('last_weekly_hashrates_indexing', 0, NULL)`); | ||||
|     } | ||||
| 
 | ||||
| @ -741,6 +778,28 @@ class DatabaseMigration { | ||||
|     ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
 | ||||
|   } | ||||
| 
 | ||||
|   private getAdditionalBlocksDataQuery(): string { | ||||
|     return `ALTER TABLE blocks
 | ||||
|       ADD median_timestamp timestamp NOT NULL, | ||||
|       ADD coinbase_address varchar(100) NULL, | ||||
|       ADD coinbase_signature varchar(500) NULL, | ||||
|       ADD coinbase_signature_ascii varchar(500) NULL, | ||||
|       ADD avg_tx_size double unsigned NOT NULL, | ||||
|       ADD total_inputs int unsigned NOT NULL, | ||||
|       ADD total_outputs int unsigned NOT NULL, | ||||
|       ADD total_output_amt bigint unsigned NOT NULL, | ||||
|       ADD fee_percentiles longtext NULL, | ||||
|       ADD median_fee_amt int unsigned NULL, | ||||
|       ADD segwit_total_txs int unsigned NOT NULL, | ||||
|       ADD segwit_total_size int unsigned NOT NULL, | ||||
|       ADD segwit_total_weight int unsigned NOT NULL, | ||||
|       ADD header varchar(160) NOT NULL, | ||||
|       ADD utxoset_change int NOT NULL, | ||||
|       ADD utxoset_size int unsigned NULL, | ||||
|       ADD total_input_amt bigint unsigned NULL | ||||
|     `;
 | ||||
|   } | ||||
| 
 | ||||
|   private getCreateDailyStatsTableQuery(): string { | ||||
|     return `CREATE TABLE IF NOT EXISTS hashrates (
 | ||||
|       hashrate_timestamp timestamp NOT NULL, | ||||
| @ -958,26 +1017,16 @@ class DatabaseMigration { | ||||
|     ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
 | ||||
|   } | ||||
| 
 | ||||
|   public async $truncateIndexedData(tables: string[]) { | ||||
|     const allowedTables = ['blocks', 'hashrates', 'prices']; | ||||
|   public async $blocksReindexingTruncate(): Promise<void> { | ||||
|     logger.warn(`Truncating pools, blocks and hashrates for re-indexing (using '--reindex-blocks'). You can cancel this command within 5 seconds`); | ||||
|     await Common.sleep$(5000); | ||||
| 
 | ||||
|     try { | ||||
|       for (const table of tables) { | ||||
|         if (!allowedTables.includes(table)) { | ||||
|           logger.debug(`Table ${table} cannot to be re-indexed (not allowed)`); | ||||
|           continue; | ||||
|         } | ||||
| 
 | ||||
|         await this.$executeQuery(`TRUNCATE ${table}`, true); | ||||
|         if (table === 'hashrates') { | ||||
|           await this.$executeQuery('UPDATE state set number = 0 where name = "last_hashrates_indexing"', true); | ||||
|         } | ||||
|         logger.notice(`Table ${table} has been truncated`); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       logger.warn(`Unable to erase indexed data`); | ||||
|     } | ||||
|   } | ||||
|     await this.$executeQuery(`TRUNCATE blocks`); | ||||
|     await this.$executeQuery(`TRUNCATE hashrates`); | ||||
|     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<void> { | ||||
|     try { | ||||
|  | ||||
| @ -9,7 +9,7 @@ import { TransactionExtended } from '../mempool.interfaces'; | ||||
| import { Common } from './common'; | ||||
| 
 | ||||
| class DiskCache { | ||||
|   private cacheSchemaVersion = 1; | ||||
|   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)}`); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -559,6 +559,17 @@ class ChannelsApi { | ||||
|     const policy1: Partial<ILightningApi.RoutingPolicy> = channel.node1_policy || {}; | ||||
|     const policy2: Partial<ILightningApi.RoutingPolicy> = 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, | ||||
|  | ||||
| @ -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, | ||||
| @ -362,7 +374,13 @@ class NodesApi { | ||||
|   public async $searchNodeByPublicKeyOrAlias(search: string) { | ||||
|     try { | ||||
|       const publicKeySearch = search.replace('%', '') + '%'; | ||||
|       const aliasSearch = search.replace(/[-_.]/g, ' ').replace(/[^a-zA-Z0-9 ]/g, '').split(' ').map((search) => '+' + search + '*').join(' '); | ||||
|       const aliasSearch = search | ||||
|         .replace(/[-_.]/g, ' ') // Replace all -_. characters with empty space. Eg: "ln.nicehash" becomes "ln nicehash".  
 | ||||
|         .replace(/[^a-zA-Z0-9 ]/g, '') // Remove all special characters and keep just A to Z, 0 to 9.
 | ||||
|         .split(' ') | ||||
|         .filter(key => key.length) | ||||
|         .map((search) => '+' + search + '*').join(' '); | ||||
|       // %keyword% is wildcard search and can't be indexed so it's slower as the node database grow. keyword% can be indexed but then you can't search for "Nicehash" and get result for ln.nicehash.com. So we use fulltext index for words "ln, nicehash, com" and nicehash* will find it instantly.
 | ||||
|       const query = `SELECT public_key, alias, capacity, channels, status FROM nodes WHERE public_key LIKE ? OR MATCH alias_search AGAINST (? IN BOOLEAN MODE) ORDER BY capacity DESC LIMIT 10`; | ||||
|       const [rows]: any = await DB.query(query, [publicKeySearch, aliasSearch]); | ||||
|       return rows; | ||||
| @ -624,6 +642,11 @@ class NodesApi { | ||||
|    */ | ||||
|   public async $saveNode(node: ILightningApi.Node): Promise<void> { | ||||
|     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, | ||||
|  | ||||
| @ -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: { | ||||
|  | ||||
| @ -1,13 +1,13 @@ | ||||
| import { Application, Request, Response } from 'express'; | ||||
| import config from "../../config"; | ||||
| import logger from '../../logger'; | ||||
| import audits from '../audit'; | ||||
| import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository'; | ||||
| import BlocksRepository from '../../repositories/BlocksRepository'; | ||||
| import DifficultyAdjustmentsRepository from '../../repositories/DifficultyAdjustmentsRepository'; | ||||
| import HashratesRepository from '../../repositories/HashratesRepository'; | ||||
| import bitcoinClient from '../bitcoin/bitcoin-client'; | ||||
| import mining from "./mining"; | ||||
| import PricesRepository from '../../repositories/PricesRepository'; | ||||
| 
 | ||||
| class MiningRoutes { | ||||
|   public initRoutes(app: Application) { | ||||
| @ -32,9 +32,27 @@ class MiningRoutes { | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/score/:hash', this.$getBlockAuditScore) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/:hash', this.$getBlockAudit) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/timestamp/:timestamp', this.$getHeightFromTimestamp) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'historical-price', this.$getHistoricalPrice) | ||||
|     ; | ||||
|   } | ||||
| 
 | ||||
|   private async $getHistoricalPrice(req: Request, res: Response): Promise<void> { | ||||
|     try { | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); | ||||
|       if (req.query.timestamp) { | ||||
|         res.status(200).send(await PricesRepository.$getNearestHistoricalPrice( | ||||
|           parseInt(<string>req.query.timestamp ?? 0, 10) | ||||
|         )); | ||||
|       } else { | ||||
|         res.status(200).send(await PricesRepository.$getHistoricalPrices()); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getPool(req: Request, res: Response): Promise<void> { | ||||
|     try { | ||||
|       const stats = await mining.$getPoolStat(req.params.slug); | ||||
|  | ||||
| @ -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 { | ||||
|   blocksPriceIndexingRunning = false; | ||||
| @ -100,6 +102,7 @@ class Mining { | ||||
|         rank: rank++, | ||||
|         emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0, | ||||
|         slug: poolInfo.slug, | ||||
|         avgMatchRate: poolInfo.avgMatchRate !== null ? Math.round(100 * poolInfo.avgMatchRate) / 100 : null, | ||||
|       }; | ||||
|       poolsStats.push(poolStat); | ||||
|     }); | ||||
| @ -171,7 +174,7 @@ class Mining { | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * [INDEXING] Generate weekly mining pool hashrate history | ||||
|    * Generate weekly mining pool hashrate history | ||||
|    */ | ||||
|   public async $generatePoolHashrateHistory(): Promise<void> { | ||||
|     const now = new Date(); | ||||
| @ -188,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[] = []; | ||||
| @ -278,7 +281,7 @@ class Mining { | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * [INDEXING] Generate daily hashrate data | ||||
|    * Generate daily hashrate data | ||||
|    */ | ||||
|   public async $generateNetworkHashrateHistory(): Promise<void> { | ||||
|     // We only run this once a day around midnight
 | ||||
| @ -291,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()); | ||||
| @ -393,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, | ||||
| @ -458,7 +461,7 @@ class Mining { | ||||
|   /** | ||||
|    * Create a link between blocks and the latest price at when they were mined | ||||
|    */ | ||||
|   public async $indexBlockPrices() { | ||||
|   public async $indexBlockPrices(): Promise<void> { | ||||
|     if (this.blocksPriceIndexingRunning === true) { | ||||
|       return; | ||||
|     } | ||||
| @ -519,6 +522,41 @@ class Mining { | ||||
|     this.blocksPriceIndexingRunning = false; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Index core coinstatsindex | ||||
|    */ | ||||
|   public async $indexCoinStatsIndex(): Promise<void> { | ||||
|     let timer = new Date().getTime() / 1000; | ||||
|     let totalIndexed = 0; | ||||
| 
 | ||||
|     const blockchainInfo = await bitcoinClient.getBlockchainInfo(); | ||||
|     let currentBlockHeight = blockchainInfo.blocks; | ||||
| 
 | ||||
|     while (currentBlockHeight > 0) { | ||||
|       const indexedBlocks = await BlocksRepository.$getBlocksMissingCoinStatsIndex( | ||||
|         currentBlockHeight, currentBlockHeight - 10000); | ||||
|          | ||||
|       for (const block of indexedBlocks) { | ||||
|         const txoutset = await bitcoinClient.getTxoutSetinfo('none', block.height); | ||||
|         await BlocksRepository.$updateCoinStatsIndexData(block.hash, txoutset.txouts, | ||||
|           Math.round(txoutset.block_info.prevout_spent * 100000000));         | ||||
|         ++totalIndexed; | ||||
| 
 | ||||
|         const elapsedSeconds = Math.max(1, new Date().getTime() / 1000 - timer); | ||||
|         if (elapsedSeconds > 5) { | ||||
|           logger.info(`Indexing coinstatsindex data for block #${block.height}. Indexed ${totalIndexed} blocks.`, logger.tags.mining); | ||||
|           timer = new Date().getTime() / 1000; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       currentBlockHeight -= 10000; | ||||
|     } | ||||
| 
 | ||||
|     if (totalIndexed) { | ||||
|       logger.info(`Indexing missing coinstatsindex data completed`, logger.tags.mining); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private getDateMidnight(date: Date): Date { | ||||
|     date.setUTCHours(0); | ||||
|     date.setUTCMinutes(0); | ||||
|  | ||||
| @ -1,289 +1,161 @@ | ||||
| import DB from '../database'; | ||||
| import logger from '../logger'; | ||||
| import config from '../config'; | ||||
| import BlocksRepository from '../repositories/BlocksRepository'; | ||||
| 
 | ||||
| interface Pool { | ||||
|   name: string; | ||||
|   link: string; | ||||
|   regexes: string[]; | ||||
|   addresses: string[]; | ||||
|   slug: string; | ||||
| } | ||||
| 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': '[]', | ||||
|     'addresses': '[]', | ||||
|     'slug': 'unknown' | ||||
|   }; | ||||
|   slugWarnFlag = false; | ||||
|   private uniqueLogs: string[] = []; | ||||
| 
 | ||||
|   private uniqueLog(loggerFunction: any, msg: string): void { | ||||
|     if (this.uniqueLogs.includes(msg)) { | ||||
|       return; | ||||
|     } | ||||
|     this.uniqueLogs.push(msg); | ||||
|     loggerFunction(msg); | ||||
|   } | ||||
| 
 | ||||
|   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; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Parse the pools.json file, consolidate the data and dump it into the database | ||||
|    * Populate our db with updated mining pool definition | ||||
|    * @param pools  | ||||
|    */ | ||||
|   public async migratePoolsJson(poolsJson: object): Promise<void> { | ||||
|     if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) { | ||||
|       return; | ||||
|     } | ||||
|   public async migratePoolsJson(): Promise<void> { | ||||
|     // 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(); | ||||
| 
 | ||||
|     // First we save every entries without paying attention to pool duplication
 | ||||
|     const poolsDuplicated: Pool[] = []; | ||||
|     await this.$insertUnknownPool(); | ||||
| 
 | ||||
|     const coinbaseTags = Object.entries(poolsJson['coinbase_tags']); | ||||
|     for (let i = 0; i < coinbaseTags.length; ++i) { | ||||
|       poolsDuplicated.push({ | ||||
|         'name': (<Pool>coinbaseTags[i][1]).name, | ||||
|         'link': (<Pool>coinbaseTags[i][1]).link, | ||||
|         'regexes': [coinbaseTags[i][0]], | ||||
|         'addresses': [], | ||||
|         'slug': '' | ||||
|       }); | ||||
|     } | ||||
|     const addressesTags = Object.entries(poolsJson['payout_addresses']); | ||||
|     for (let i = 0; i < addressesTags.length; ++i) { | ||||
|       poolsDuplicated.push({ | ||||
|         'name': (<Pool>addressesTags[i][1]).name, | ||||
|         'link': (<Pool>addressesTags[i][1]).link, | ||||
|         'regexes': [], | ||||
|         'addresses': [addressesTags[i][0]], | ||||
|         'slug': '' | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     // Then, we find unique mining pool names
 | ||||
|     const poolNames: string[] = []; | ||||
|     for (let i = 0; i < poolsDuplicated.length; ++i) { | ||||
|       if (poolNames.indexOf(poolsDuplicated[i].name) === -1) { | ||||
|         poolNames.push(poolsDuplicated[i].name); | ||||
|     for (const pool of this.miningPools) { | ||||
|       if (!pool.id) { | ||||
|         logger.info(`Mining pool ${pool.name} has no unique 'id' defined. Skipping.`); | ||||
|         continue; | ||||
|       } | ||||
|     } | ||||
|     logger.debug(`Found ${poolNames.length} unique mining pools`, logger.tags.mining); | ||||
| 
 | ||||
|     // Get existing pools from the db
 | ||||
|     let existingPools; | ||||
|     try { | ||||
|       if (config.DATABASE.ENABLED === true) { | ||||
|         [existingPools] = await DB.query({ sql: 'SELECT * FROM pools;', timeout: 120000 }); | ||||
|       const poolDB = await PoolsRepository.$getPoolByUniqueId(pool.id, false); | ||||
|       if (!poolDB) { | ||||
|         // New mining pool
 | ||||
|         const slug = pool.name.replace(/[^a-z0-9]/gi, '').toLowerCase(); | ||||
|         logger.debug(`Inserting new mining pool ${pool.name}`); | ||||
|         await PoolsRepository.$insertNewMiningPool(pool, slug); | ||||
|         await this.$deleteUnknownBlocks(); | ||||
|       } else { | ||||
|         existingPools = []; | ||||
|       } | ||||
|     } catch (e) { | ||||
|       logger.err('Cannot get existing pools from the database, skipping pools.json import', logger.tags.mining); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.miningPools = []; | ||||
| 
 | ||||
|     // Finally, we generate the final consolidated pools data
 | ||||
|     const finalPoolDataAdd: Pool[] = []; | ||||
|     const finalPoolDataUpdate: Pool[] = []; | ||||
|     const finalPoolDataRename: Pool[] = []; | ||||
|     for (let i = 0; i < poolNames.length; ++i) { | ||||
|       let allAddresses: string[] = []; | ||||
|       let allRegexes: string[] = []; | ||||
|       const match = poolsDuplicated.filter((pool: Pool) => pool.name === poolNames[i]); | ||||
| 
 | ||||
|       for (let y = 0; y < match.length; ++y) { | ||||
|         allAddresses = allAddresses.concat(match[y].addresses); | ||||
|         allRegexes = allRegexes.concat(match[y].regexes); | ||||
|       } | ||||
| 
 | ||||
|       const finalPoolName = poolNames[i].replace(`'`, `''`); // To support single quote in names when doing db queries
 | ||||
| 
 | ||||
|       let slug: string | undefined; | ||||
|       try { | ||||
|         slug = poolsJson['slugs'][poolNames[i]]; | ||||
|       } catch (e) { | ||||
|         if (this.slugWarnFlag === false) { | ||||
|           logger.warn(`pools.json does not seem to contain the 'slugs' object`, logger.tags.mining); | ||||
|           this.slugWarnFlag = true; | ||||
|         if (poolDB.name !== pool.name) { | ||||
|           // Pool has been renamed
 | ||||
|           const newSlug = pool.name.replace(/[^a-z0-9]/gi, '').toLowerCase(); | ||||
|           logger.warn(`Renaming ${poolDB.name} mining pool to ${pool.name}. Slug has been updated. Maybe you want to make a redirection from 'https://mempool.space/mining/pool/${poolDB.slug}' to 'https://mempool.space/mining/pool/${newSlug}`); | ||||
|           await PoolsRepository.$renameMiningPool(poolDB.id, newSlug, pool.name); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       if (slug === undefined) { | ||||
|         // Only keep alphanumerical
 | ||||
|         slug = poolNames[i].replace(/[^a-z0-9]/gi, '').toLowerCase(); | ||||
|         logger.warn(`No slug found for '${poolNames[i]}', generating it => '${slug}'`, logger.tags.mining); | ||||
|       } | ||||
| 
 | ||||
|       const poolObj = { | ||||
|         'name': finalPoolName, | ||||
|         'link': match[0].link, | ||||
|         'regexes': allRegexes, | ||||
|         'addresses': allAddresses, | ||||
|         'slug': slug | ||||
|       }; | ||||
| 
 | ||||
|       const existingPool = existingPools.find((pool) => pool.name === poolNames[i]); | ||||
|       if (existingPool !== undefined) { | ||||
|         // Check if any data was actually updated
 | ||||
|         const equals = (a, b) => | ||||
|           a.length === b.length && | ||||
|           a.every((v, i) => v === b[i]); | ||||
|         if (!equals(JSON.parse(existingPool.addresses), poolObj.addresses) || !equals(JSON.parse(existingPool.regexes), poolObj.regexes)) { | ||||
|           finalPoolDataUpdate.push(poolObj); | ||||
|         if (poolDB.link !== pool.link) { | ||||
|           // Pool link has changed
 | ||||
|           logger.debug(`Updating link for ${pool.name} mining pool`); | ||||
|           await PoolsRepository.$updateMiningPoolLink(poolDB.id, pool.link); | ||||
|         } | ||||
|       } else if (config.DATABASE.ENABLED) { | ||||
|         // Double check that if we're not just renaming a pool (same address same regex)
 | ||||
|         const [poolToRename]: any[] = await DB.query(` | ||||
|           SELECT * FROM pools | ||||
|           WHERE addresses = ? OR regexes = ?`,
 | ||||
|           [JSON.stringify(poolObj.addresses), JSON.stringify(poolObj.regexes)] | ||||
|         ); | ||||
|         if (poolToRename && poolToRename.length > 0) { | ||||
|           // We're actually renaming an existing pool
 | ||||
|           finalPoolDataRename.push({ | ||||
|             'name': poolObj.name, | ||||
|             'link': poolObj.link, | ||||
|             'regexes': allRegexes, | ||||
|             'addresses': allAddresses, | ||||
|             'slug': slug | ||||
|           }); | ||||
|           logger.debug(`Rename '${poolToRename[0].name}' mining pool to ${poolObj.name}`, logger.tags.mining); | ||||
|         } else { | ||||
|           logger.debug(`Add '${finalPoolName}' mining pool`, logger.tags.mining); | ||||
|           finalPoolDataAdd.push(poolObj); | ||||
|         if (JSON.stringify(pool.addresses) !== poolDB.addresses || | ||||
|           JSON.stringify(pool.regexes) !== poolDB.regexes) { | ||||
|           // Pool addresses changed or coinbase tags changed
 | ||||
|           logger.notice(`Updating addresses and/or coinbase tags for ${pool.name} mining pool. If 'AUTOMATIC_BLOCK_REINDEXING' is enabled, we will re-index its blocks and 'unknown' blocks`); | ||||
|           await PoolsRepository.$updateMiningPoolTags(poolDB.id, pool.addresses, pool.regexes); | ||||
|           await this.$deleteBlocksForPool(poolDB); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       this.miningPools.push({ | ||||
|         'name': finalPoolName, | ||||
|         'link': match[0].link, | ||||
|         'regexes': JSON.stringify(allRegexes), | ||||
|         'addresses': JSON.stringify(allAddresses), | ||||
|         'slug': slug | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     if (config.DATABASE.ENABLED === false) { // Don't run db operations
 | ||||
|       logger.info('Mining pools.json import completed (no database)', logger.tags.mining); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (finalPoolDataAdd.length > 0 || finalPoolDataUpdate.length > 0 || | ||||
|       finalPoolDataRename.length > 0 | ||||
|     ) {     | ||||
|       logger.debug(`Update pools table now`, logger.tags.mining); | ||||
| 
 | ||||
|       // Add new mining pools into the database
 | ||||
|       let queryAdd: string = 'INSERT INTO pools(name, link, regexes, addresses, slug) VALUES '; | ||||
|       for (let i = 0; i < finalPoolDataAdd.length; ++i) { | ||||
|         queryAdd += `('${finalPoolDataAdd[i].name}', '${finalPoolDataAdd[i].link}',
 | ||||
|         '${JSON.stringify(finalPoolDataAdd[i].regexes)}', '${JSON.stringify(finalPoolDataAdd[i].addresses)}', | ||||
|         ${JSON.stringify(finalPoolDataAdd[i].slug)}),`;
 | ||||
|       } | ||||
|       queryAdd = queryAdd.slice(0, -1) + ';'; | ||||
| 
 | ||||
|       // Updated existing mining pools in the database
 | ||||
|       const updateQueries: string[] = []; | ||||
|       for (let i = 0; i < finalPoolDataUpdate.length; ++i) { | ||||
|         updateQueries.push(` | ||||
|           UPDATE pools | ||||
|           SET name='${finalPoolDataUpdate[i].name}', link='${finalPoolDataUpdate[i].link}', | ||||
|           regexes='${JSON.stringify(finalPoolDataUpdate[i].regexes)}', addresses='${JSON.stringify(finalPoolDataUpdate[i].addresses)}', | ||||
|           slug='${finalPoolDataUpdate[i].slug}' | ||||
|           WHERE name='${finalPoolDataUpdate[i].name}' | ||||
|         ;`);
 | ||||
|       } | ||||
| 
 | ||||
|       // Rename mining pools
 | ||||
|       const renameQueries: string[] = []; | ||||
|       for (let i = 0; i < finalPoolDataRename.length; ++i) { | ||||
|         renameQueries.push(` | ||||
|           UPDATE pools | ||||
|           SET name='${finalPoolDataRename[i].name}', link='${finalPoolDataRename[i].link}', | ||||
|             slug='${finalPoolDataRename[i].slug}' | ||||
|           WHERE regexes='${JSON.stringify(finalPoolDataRename[i].regexes)}' | ||||
|             AND addresses='${JSON.stringify(finalPoolDataRename[i].addresses)}' | ||||
|         ;`);
 | ||||
|       } | ||||
| 
 | ||||
|       try { | ||||
|         if (finalPoolDataAdd.length > 0 || updateQueries.length > 0) { | ||||
|           await this.$deleteBlocskToReindex(finalPoolDataUpdate); | ||||
|         } | ||||
| 
 | ||||
|         if (finalPoolDataAdd.length > 0) { | ||||
|           await DB.query({ sql: queryAdd, timeout: 120000 }); | ||||
|         } | ||||
|         for (const query of updateQueries) { | ||||
|           await DB.query({ sql: query, timeout: 120000 }); | ||||
|         } | ||||
|         for (const query of renameQueries) { | ||||
|           await DB.query({ sql: query, timeout: 120000 }); | ||||
|         } | ||||
|         await this.insertUnknownPool(); | ||||
|         logger.info('Mining pools.json import completed', logger.tags.mining); | ||||
|       } catch (e) { | ||||
|         logger.err(`Cannot import pools in the database`, logger.tags.mining); | ||||
|         throw e; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     try { | ||||
|       await this.insertUnknownPool(); | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot insert unknown pool in the database`, logger.tags.mining); | ||||
|       throw e; | ||||
|     } | ||||
|     logger.info('Mining pools-v2.json import completed'); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Manually add the 'unknown pool' | ||||
|    */ | ||||
|   private async insertUnknownPool() { | ||||
|   public async $insertUnknownPool(): Promise<void> { | ||||
|     if (!config.DATABASE.ENABLED) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     try { | ||||
|       const [rows]: any[] = await DB.query({ sql: 'SELECT name from pools where name="Unknown"', timeout: 120000 }); | ||||
|       if (rows.length === 0) { | ||||
|         await DB.query({ | ||||
|           sql: `INSERT INTO pools(name, link, regexes, addresses, slug)
 | ||||
|           VALUES("Unknown", "https://learnmeabitcoin.com/technical/coinbase-transaction", "[]", "[]", "unknown"); | ||||
|           sql: `INSERT INTO pools(name, link, regexes, addresses, slug, unique_id)
 | ||||
|           VALUES("${this.unknownPool.name}", "${this.unknownPool.link}", "[]", "[]", "${this.unknownPool.slug}", 0); | ||||
|         `});
 | ||||
|       } else { | ||||
|         await DB.query(`UPDATE pools
 | ||||
|           SET name='Unknown', link='https://learnmeabitcoin.com/technical/coinbase-transaction', | ||||
|           SET name='${this.unknownPool.name}', link='${this.unknownPool.link}', | ||||
|           regexes='[]', addresses='[]', | ||||
|           slug='unknown' | ||||
|           WHERE name='Unknown' | ||||
|           slug='${this.unknownPool.slug}', | ||||
|           unique_id=0 | ||||
|           WHERE slug='${this.unknownPool.slug}' | ||||
|         `);
 | ||||
|       } | ||||
|     } catch (e) { | ||||
|       logger.err('Unable to insert "Unknown" mining pool', logger.tags.mining); | ||||
|       logger.err(`Unable to insert or update "Unknown" mining pool. Reason: ${e instanceof Error ? e.message : e}`); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Delete blocks which needs to be reindexed | ||||
|    * Delete indexed blocks for an updated mining pool | ||||
|    *  | ||||
|    * @param pool  | ||||
|    */ | ||||
|    private async $deleteBlocskToReindex(finalPoolDataUpdate: any[]) { | ||||
|   private async $deleteBlocksForPool(pool: PoolTag): Promise<void> { | ||||
|     if (config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING === false) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const blockCount = await BlocksRepository.$blockCount(null, null); | ||||
|     if (blockCount === 0) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     for (const updatedPool of finalPoolDataUpdate) { | ||||
|       const [pool]: any[] = await DB.query(`SELECT id, name from pools where slug = "${updatedPool.slug}"`); | ||||
|       if (pool.length > 0) { | ||||
|         logger.notice(`Deleting blocks from ${pool[0].name} mining pool for future re-indexing`, logger.tags.mining); | ||||
|         await DB.query(`DELETE FROM blocks WHERE pool_id = ${pool[0].id}`); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // Ignore early days of Bitcoin as there were not mining pool yet
 | ||||
|     logger.notice(`Deleting blocks with unknown mining pool from height 130635 for future re-indexing`, logger.tags.mining); | ||||
|     // Get oldest blocks mined by the pool and assume pools-v2.json updates only concern most recent years
 | ||||
|     // Ignore early days of Bitcoin as there were no mining pool yet
 | ||||
|     const [oldestPoolBlock]: any[] = await DB.query(` | ||||
|       SELECT height | ||||
|       FROM blocks | ||||
|       WHERE pool_id = ? | ||||
|       ORDER BY height | ||||
|       LIMIT 1`,
 | ||||
|       [pool.id] | ||||
|     ); | ||||
|     const oldestBlockHeight = oldestPoolBlock.length ?? 0 > 0 ? oldestPoolBlock[0].height : 130635; | ||||
|     const [unknownPool] = await DB.query(`SELECT id from pools where slug = "unknown"`); | ||||
|     await DB.query(`DELETE FROM blocks WHERE pool_id = ${unknownPool[0].id} AND height > 130635`); | ||||
|     this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height ${oldestBlockHeight} for re-indexing`); | ||||
|     await DB.query(` | ||||
|       DELETE FROM blocks | ||||
|       WHERE pool_id = ? AND height >= ${oldestBlockHeight}`,
 | ||||
|       [unknownPool[0].id] | ||||
|     ); | ||||
|     logger.notice(`Deleting blocks from ${pool.name} mining pool for re-indexing`); | ||||
|     await DB.query(` | ||||
|       DELETE FROM blocks | ||||
|       WHERE pool_id = ?`,
 | ||||
|       [pool.id] | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|     logger.notice(`Truncating hashrates for future re-indexing`, logger.tags.mining); | ||||
|     await DB.query(`DELETE FROM hashrates`); | ||||
|   private async $deleteUnknownBlocks(): Promise<void> { | ||||
|     const [unknownPool] = await DB.query(`SELECT id from pools where slug = "unknown"`); | ||||
|     this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height 130635 for re-indexing`); | ||||
|     await DB.query(` | ||||
|       DELETE FROM blocks | ||||
|       WHERE pool_id = ? AND height >= 130635`,
 | ||||
|       [unknownPool[0].id] | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -14,6 +14,7 @@ class TransactionUtils { | ||||
|       vout: tx.vout | ||||
|         .map((vout) => ({ | ||||
|           scriptpubkey_address: vout.scriptpubkey_address, | ||||
|           scriptpubkey_asm: vout.scriptpubkey_asm, | ||||
|           value: vout.value | ||||
|         })) | ||||
|         .filter((vout) => vout.value) | ||||
|  | ||||
| @ -32,6 +32,7 @@ interface IConfig { | ||||
|     ADVANCED_GBT_AUDIT: boolean; | ||||
|     ADVANCED_GBT_MEMPOOL: boolean; | ||||
|     CPFP_INDEXING: boolean; | ||||
|     MAX_BLOCKS_BULK_QUERY: number; | ||||
|   }; | ||||
|   ESPLORA: { | ||||
|     REST_API_URL: string; | ||||
| @ -147,12 +148,13 @@ const defaults: IConfig = { | ||||
|     'USER_AGENT': 'mempool', | ||||
|     'STDOUT_LOG_MIN_PRIORITY': 'debug', | ||||
|     'AUTOMATIC_BLOCK_REINDEXING': false, | ||||
|     'POOLS_JSON_URL': 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json', | ||||
|     'POOLS_JSON_URL': 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json', | ||||
|     'POOLS_JSON_TREE_URL': 'https://api.github.com/repos/mempool/mining-pools/git/trees/master', | ||||
|     'AUDIT': false, | ||||
|     'ADVANCED_GBT_AUDIT': false, | ||||
|     'ADVANCED_GBT_MEMPOOL': false, | ||||
|     'CPFP_INDEXING': false, | ||||
|     'MAX_BLOCKS_BULK_QUERY': 0, | ||||
|   }, | ||||
|   'ESPLORA': { | ||||
|     'REST_API_URL': 'http://127.0.0.1:3000', | ||||
|  | ||||
| @ -24,7 +24,8 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr | ||||
| 
 | ||||
|   private checkDBFlag() { | ||||
|     if (config.DATABASE.ENABLED === false) { | ||||
|       logger.err('Trying to use DB feature but config.DATABASE.ENABLED is set to false, please open an issue'); | ||||
|       const stack = new Error().stack; | ||||
|       logger.err(`Trying to use DB feature but config.DATABASE.ENABLED is set to false, please open an issue.\nStack trace: ${stack}}`); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -36,6 +36,7 @@ import bitcoinRoutes from './api/bitcoin/bitcoin.routes'; | ||||
| import fundingTxFetcher from './tasks/lightning/sync-tasks/funding-tx-fetcher'; | ||||
| import forensicsService from './tasks/lightning/forensics.service'; | ||||
| import priceUpdater from './tasks/price-updater'; | ||||
| import chainTips from './api/chain-tips'; | ||||
| import { AxiosError } from 'axios'; | ||||
| 
 | ||||
| class Server { | ||||
| @ -82,11 +83,8 @@ class Server { | ||||
|     if (config.DATABASE.ENABLED) { | ||||
|       await DB.checkDbConnection(); | ||||
|       try { | ||||
|         if (process.env.npm_config_reindex !== undefined) { // Re-index requests
 | ||||
|           const tables = process.env.npm_config_reindex.split(','); | ||||
|           logger.warn(`Indexed data for "${process.env.npm_config_reindex}" tables will be erased in 5 seconds (using '--reindex')`); | ||||
|           await Common.sleep$(5000); | ||||
|           await databaseMigration.$truncateIndexedData(tables); | ||||
|         if (process.env.npm_config_reindex_blocks === 'true') { // Re-index requests
 | ||||
|           await databaseMigration.$blocksReindexingTruncate(); | ||||
|         } | ||||
|         await databaseMigration.$initializeOrMigrateDatabase(); | ||||
|         if (Common.indexingEnabled()) { | ||||
| @ -115,6 +113,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(); | ||||
| @ -133,6 +132,7 @@ class Server { | ||||
|     } | ||||
| 
 | ||||
|     priceUpdater.$run(); | ||||
|     await chainTips.updateOrphanedBlocks(); | ||||
| 
 | ||||
|     this.setUpHttpApiRoutes(); | ||||
| 
 | ||||
| @ -172,7 +172,6 @@ class Server { | ||||
|           logger.debug(msg); | ||||
|         } | ||||
|       } | ||||
|       await poolsUpdater.updatePoolsJson(); | ||||
|       await blocks.$updateBlocks(); | ||||
|       await memPool.$updateMempool(); | ||||
|       indexer.$run(); | ||||
| @ -180,7 +179,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(); | ||||
| @ -200,8 +206,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); | ||||
|  | ||||
| @ -8,18 +8,67 @@ import bitcoinClient from './api/bitcoin/bitcoin-client'; | ||||
| import priceUpdater from './tasks/price-updater'; | ||||
| import PricesRepository from './repositories/PricesRepository'; | ||||
| 
 | ||||
| export interface CoreIndex { | ||||
|   name: string; | ||||
|   synced: boolean; | ||||
|   best_block_height: number; | ||||
| } | ||||
| 
 | ||||
| class Indexer { | ||||
|   runIndexer = true; | ||||
|   indexerRunning = false; | ||||
|   tasksRunning: string[] = []; | ||||
|   coreIndexes: CoreIndex[] = []; | ||||
| 
 | ||||
|   public reindex() { | ||||
|   /** | ||||
|    * Check which core index is available for indexing | ||||
|    */ | ||||
|   public async checkAvailableCoreIndexes(): Promise<void> { | ||||
|     const updatedCoreIndexes: CoreIndex[] = []; | ||||
| 
 | ||||
|     const indexes: any = await bitcoinClient.getIndexInfo(); | ||||
|     for (const indexName in indexes) { | ||||
|       const newState = { | ||||
|         name: indexName, | ||||
|         synced: indexes[indexName].synced, | ||||
|         best_block_height: indexes[indexName].best_block_height, | ||||
|       }; | ||||
|       logger.info(`Core index '${indexName}' is ${indexes[indexName].synced ? 'synced' : 'not synced'}. Best block height is ${indexes[indexName].best_block_height}`);       | ||||
|       updatedCoreIndexes.push(newState); | ||||
| 
 | ||||
|       if (indexName === 'coinstatsindex' && newState.synced === true) { | ||||
|         const previousState = this.isCoreIndexReady('coinstatsindex'); | ||||
|         // if (!previousState || previousState.synced === false) {
 | ||||
|           this.runSingleTask('coinStatsIndex'); | ||||
|         // }
 | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     this.coreIndexes = updatedCoreIndexes; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Return the best block height if a core index is available, or 0 if not | ||||
|    *  | ||||
|    * @param name  | ||||
|    * @returns  | ||||
|    */ | ||||
|   public isCoreIndexReady(name: string): CoreIndex | null { | ||||
|     for (const index of this.coreIndexes) { | ||||
|       if (index.name === name && index.synced === true) { | ||||
|         return index; | ||||
|       } | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   public reindex(): void { | ||||
|     if (Common.indexingEnabled()) { | ||||
|       this.runIndexer = true; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async runSingleTask(task: 'blocksPrices') { | ||||
|   public async runSingleTask(task: 'blocksPrices' | 'coinStatsIndex'): Promise<void> { | ||||
|     if (!Common.indexingEnabled()) { | ||||
|       return; | ||||
|     } | ||||
| @ -28,20 +77,27 @@ 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`); | ||||
|         setTimeout(() => { | ||||
|           this.tasksRunning = this.tasksRunning.filter(runningTask => runningTask != task) | ||||
|           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`); | ||||
|         await mining.$indexBlockPrices(); | ||||
|         this.tasksRunning = this.tasksRunning.filter(runningTask => runningTask != task) | ||||
|         this.tasksRunning = this.tasksRunning.filter(runningTask => runningTask !== task); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (task === 'coinStatsIndex' && !this.tasksRunning.includes(task)) { | ||||
|       this.tasksRunning.push(task); | ||||
|       logger.debug(`Indexing coinStatsIndex now`); | ||||
|       await mining.$indexCoinStatsIndex(); | ||||
|       this.tasksRunning = this.tasksRunning.filter(runningTask => runningTask !== task); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $run() { | ||||
|   public async $run(): Promise<void> { | ||||
|     if (!Common.indexingEnabled() || this.runIndexer === false || | ||||
|       this.indexerRunning === true || mempool.hasPriority() | ||||
|     ) { | ||||
| @ -57,7 +113,9 @@ class Indexer { | ||||
|     this.runIndexer = false; | ||||
|     this.indexerRunning = true; | ||||
| 
 | ||||
|     logger.debug(`Running mining indexer`); | ||||
|     logger.info(`Running mining indexer`); | ||||
| 
 | ||||
|     await this.checkAvailableCoreIndexes(); | ||||
| 
 | ||||
|     try { | ||||
|       await priceUpdater.$run(); | ||||
| @ -93,7 +151,7 @@ class Indexer { | ||||
|     setTimeout(() => this.reindex(), runEvery); | ||||
|   } | ||||
| 
 | ||||
|   async $resetHashratesIndexingState() { | ||||
|   async $resetHashratesIndexingState(): Promise<void> { | ||||
|     try { | ||||
|       await HashratesRepository.$setLatestRun('last_hashrates_indexing', 0); | ||||
|       await HashratesRepository.$setLatestRun('last_weekly_hashrates_indexing', 0); | ||||
|  | ||||
| @ -1,8 +1,10 @@ | ||||
| import { IEsploraApi } from './api/bitcoin/esplora-api.interface'; | ||||
| import { HeapNode } from "./utils/pairing-heap"; | ||||
| import { OrphanedBlock } from './api/chain-tips'; | ||||
| 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
 | ||||
| @ -16,6 +18,7 @@ export interface PoolInfo { | ||||
|   link: string; | ||||
|   blockCount: number; | ||||
|   slug: string; | ||||
|   avgMatchRate: number | null; | ||||
| } | ||||
| 
 | ||||
| export interface PoolStats extends PoolInfo { | ||||
| @ -63,6 +66,7 @@ interface VinStrippedToScriptsig { | ||||
| 
 | ||||
| interface VoutStrippedToScriptPubkey { | ||||
|   scriptpubkey_address: string | undefined; | ||||
|   scriptpubkey_asm: string | undefined; | ||||
|   value: number; | ||||
| } | ||||
| 
 | ||||
| @ -144,23 +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; | ||||
|   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; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 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; | ||||
| } | ||||
|  | ||||
| @ -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,27 +9,90 @@ 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 { | ||||
|   /** | ||||
|    * Save indexed block data in the database | ||||
|    */ | ||||
|   public async $saveBlockInDatabase(block: BlockExtended) { | ||||
|     const truncatedCoinbaseSignature = block?.extras?.coinbaseSignature?.substring(0, 500); | ||||
|     const truncatedCoinbaseSignatureAscii = block?.extras?.coinbaseSignatureAscii?.substring(0, 500); | ||||
| 
 | ||||
|     try { | ||||
|       const query = `INSERT INTO blocks(
 | ||||
|         height,           hash,                blockTimestamp, size, | ||||
|         weight,           tx_count,            coinbase_raw,   difficulty, | ||||
|         pool_id,          fees,                fee_span,       median_fee, | ||||
|         reward,           version,             bits,           nonce, | ||||
|         merkle_root,      previous_block_hash, avg_fee,        avg_fee_rate | ||||
|         height,             hash,                blockTimestamp,    size, | ||||
|         weight,             tx_count,            coinbase_raw,      difficulty, | ||||
|         pool_id,            fees,                fee_span,          median_fee, | ||||
|         reward,             version,             bits,              nonce, | ||||
|         merkle_root,        previous_block_hash, avg_fee,           avg_fee_rate, | ||||
|         median_timestamp,   header,              coinbase_address, | ||||
|         coinbase_signature, utxoset_size,        utxoset_change,    avg_tx_size, | ||||
|         total_inputs,       total_outputs,       total_input_amt,   total_output_amt, | ||||
|         fee_percentiles,    segwit_total_txs,    segwit_total_size, segwit_total_weight, | ||||
|         median_fee_amt,     coinbase_signature_ascii | ||||
|       ) VALUE ( | ||||
|         ?, ?, FROM_UNIXTIME(?), ?, | ||||
|         ?, ?, ?, ?, | ||||
|         ?, ?, ?, ?, | ||||
|         ?, ?, ?, ?, | ||||
|         ?, ?, ?, ? | ||||
|         ?, ?, ?, ?, | ||||
|         FROM_UNIXTIME(?), ?, ?, | ||||
|         ?, ?, ?, ?, | ||||
|         ?, ?, ?, ?, | ||||
|         ?, ?, ?, ?, | ||||
|         ?, ? | ||||
|       )`;
 | ||||
| 
 | ||||
|       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, | ||||
| @ -40,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, | ||||
| @ -52,19 +114,63 @@ class BlocksRepository { | ||||
|         block.previousblockhash, | ||||
|         block.extras.avgFee, | ||||
|         block.extras.avgFeeRate, | ||||
|         block.mediantime, | ||||
|         block.extras.header, | ||||
|         block.extras.coinbaseAddress, | ||||
|         truncatedCoinbaseSignature, | ||||
|         block.extras.utxoSetSize, | ||||
|         block.extras.utxoSetChange, | ||||
|         block.extras.avgTxSize, | ||||
|         block.extras.totalInputs, | ||||
|         block.extras.totalOutputs, | ||||
|         block.extras.totalInputAmt, | ||||
|         block.extras.totalOutputAmt, | ||||
|         block.extras.feePercentiles ? JSON.stringify(block.extras.feePercentiles) : null, | ||||
|         block.extras.segwitTotalTxs, | ||||
|         block.extras.segwitTotalSize, | ||||
|         block.extras.segwitTotalWeight, | ||||
|         block.extras.medianFeeAmt, | ||||
|         truncatedCoinbaseSignatureAscii, | ||||
|       ]; | ||||
| 
 | ||||
|       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; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Save newly indexed data from core coinstatsindex | ||||
|    *  | ||||
|    * @param utxoSetSize  | ||||
|    * @param totalInputAmt  | ||||
|    */ | ||||
|   public async $updateCoinStatsIndexData(blockHash: string, utxoSetSize: number, | ||||
|     totalInputAmt: number | ||||
|   ) : Promise<void> { | ||||
|     try { | ||||
|       const query = ` | ||||
|         UPDATE blocks | ||||
|         SET utxoset_size = ?, total_input_amt = ? | ||||
|         WHERE hash = ? | ||||
|       `;
 | ||||
|       const params: any[] = [ | ||||
|         utxoSetSize, | ||||
|         totalInputAmt, | ||||
|         blockHash | ||||
|       ]; | ||||
|       await DB.query(query, params); | ||||
|     } catch (e: any) { | ||||
|       logger.err('Cannot update indexed block coinstatsindex. Reason: ' + (e instanceof Error ? e.message : e)); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get all block height that have not been indexed between [startHeight, endHeight] | ||||
|    */ | ||||
| @ -250,34 +356,17 @@ class BlocksRepository { | ||||
|   /** | ||||
|    * Get blocks mined by a specific mining pool | ||||
|    */ | ||||
|   public async $getBlocksByPool(slug: string, startHeight?: number): Promise<object[]> { | ||||
|   public async $getBlocksByPool(slug: string, startHeight?: number): Promise<BlockExtended[]> { | ||||
|     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); | ||||
| 
 | ||||
| @ -290,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 <object[]>rows) { | ||||
|         blocks.push(prepareBlock(block)); | ||||
|       for (const block of rows) { | ||||
|         blocks.push(await this.formatDbBlockIntoExtendedBlock(block)); | ||||
|       } | ||||
| 
 | ||||
|       return blocks; | ||||
| @ -307,46 +396,21 @@ class BlocksRepository { | ||||
|   /** | ||||
|    * Get one block by height | ||||
|    */ | ||||
|   public async $getBlockByHeight(height: number): Promise<object | null> { | ||||
|   public async $getBlockByHeight(height: number): Promise<BlockExtended | null> { | ||||
|     try { | ||||
|       const [rows]: any[] = await DB.query(`SELECT
 | ||||
|         blocks.height, | ||||
|         hash, | ||||
|         hash as id, | ||||
|         UNIX_TIMESTAMP(blocks.blockTimestamp) as blockTimestamp, | ||||
|         size, | ||||
|         weight, | ||||
|         tx_count, | ||||
|         coinbase_raw, | ||||
|         difficulty, | ||||
|         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, | ||||
|         fees, | ||||
|         fee_span, | ||||
|         median_fee, | ||||
|         reward, | ||||
|         version, | ||||
|         bits, | ||||
|         nonce, | ||||
|         merkle_root, | ||||
|         previous_block_hash as previousblockhash, | ||||
|         avg_fee, | ||||
|         avg_fee_rate | ||||
|       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); | ||||
|       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; | ||||
| @ -359,10 +423,7 @@ class BlocksRepository { | ||||
|   public async $getBlockByHash(hash: string): Promise<object | null> { | ||||
|     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 = ?; | ||||
| @ -372,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; | ||||
| @ -465,8 +525,15 @@ class BlocksRepository { | ||||
|   public async $validateChain(): Promise<boolean> { | ||||
|     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; | ||||
| @ -521,7 +588,7 @@ class BlocksRepository { | ||||
|         CAST(AVG(blocks.height) as INT) as avgHeight, | ||||
|         CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp, | ||||
|         CAST(AVG(fees) as INT) as avgFees, | ||||
|         prices.* | ||||
|         prices.USD | ||||
|         FROM blocks | ||||
|         JOIN blocks_prices on blocks_prices.height = blocks.height | ||||
|         JOIN prices on prices.id = blocks_prices.price_id | ||||
| @ -550,7 +617,7 @@ class BlocksRepository { | ||||
|         CAST(AVG(blocks.height) as INT) as avgHeight, | ||||
|         CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp, | ||||
|         CAST(AVG(reward) as INT) as avgRewards, | ||||
|         prices.* | ||||
|         prices.USD | ||||
|         FROM blocks | ||||
|         JOIN blocks_prices on blocks_prices.height = blocks.height | ||||
|         JOIN prices on prices.id = blocks_prices.price_id | ||||
| @ -694,7 +761,6 @@ class BlocksRepository { | ||||
|       logger.err('Cannot fetch CPFP unindexed blocks. Reason: ' + (e instanceof Error ? e.message : e)); | ||||
|       throw e; | ||||
|     } | ||||
|     return []; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
| @ -741,7 +807,7 @@ class BlocksRepository { | ||||
|     try { | ||||
|       let query = `INSERT INTO blocks_prices(height, price_id) VALUES`; | ||||
|       for (const price of blockPrices) { | ||||
|         query += ` (${price.height}, ${price.priceId}),` | ||||
|         query += ` (${price.height}, ${price.priceId}),`; | ||||
|       } | ||||
|       query = query.slice(0, -1); | ||||
|       await DB.query(query); | ||||
| @ -754,6 +820,132 @@ class BlocksRepository { | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get all indexed blocsk with missing coinstatsindex data | ||||
|    */ | ||||
|   public async $getBlocksMissingCoinStatsIndex(maxHeight: number, minHeight: number): Promise<any> { | ||||
|     try { | ||||
|       const [blocks] = await DB.query(` | ||||
|         SELECT height, hash | ||||
|         FROM blocks | ||||
|         WHERE height >= ${minHeight} AND height <= ${maxHeight} AND | ||||
|           (utxoset_size IS NULL OR total_input_amt IS NULL) | ||||
|       `);
 | ||||
|       return blocks; | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot get blocks with missing coinstatsindex. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Save indexed median fee to avoid recomputing it later | ||||
|    *  | ||||
|    * @param id  | ||||
|    * @param feePercentiles  | ||||
|    */ | ||||
|   public async $saveFeePercentilesForBlockId(id: string, feePercentiles: number[]): Promise<void> { | ||||
|     try { | ||||
|       await DB.query(` | ||||
|         UPDATE blocks SET fee_percentiles = ?, median_fee_amt = ? | ||||
|         WHERE hash = ?`,
 | ||||
|         [JSON.stringify(feePercentiles), feePercentiles[3], id] | ||||
|       ); | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot update block fee_percentiles. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|       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<BlockExtended> { | ||||
|     const blk: Partial<BlockExtended> = {}; | ||||
|     const extras: Partial<BlockExtension> = {}; | ||||
| 
 | ||||
|     // 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 = <BlockExtension>extras; | ||||
|     return <BlockExtended>blk; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new BlocksRepository(); | ||||
|  | ||||
| @ -80,6 +80,48 @@ class BlocksSummariesRepository { | ||||
|       logger.err('Cannot delete indexed blocks summaries. Reason: ' + (e instanceof Error ? e.message : e)); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get the fee percentiles if the block has already been indexed, [] otherwise | ||||
|    *  | ||||
|    * @param id  | ||||
|    */ | ||||
|   public async $getFeePercentilesByBlockId(id: string): Promise<number[] | null> { | ||||
|     try { | ||||
|       const [rows]: any[] = await DB.query(` | ||||
|         SELECT transactions | ||||
|         FROM blocks_summaries | ||||
|         WHERE id = ?`,
 | ||||
|         [id] | ||||
|       ); | ||||
|       if (rows === null || rows.length === 0) { | ||||
|         return null; | ||||
|       } | ||||
| 
 | ||||
|       const transactions = JSON.parse(rows[0].transactions); | ||||
|       if (transactions === null) { | ||||
|         return null; | ||||
|       } | ||||
| 
 | ||||
|       transactions.shift(); // Ignore coinbase
 | ||||
|       transactions.sort((a: any, b: any) => a.fee - b.fee); | ||||
|       const fees = transactions.map((t: any) => t.fee); | ||||
| 
 | ||||
|       return [ | ||||
|         fees[0] ?? 0, // min
 | ||||
|         fees[Math.max(0, Math.floor(fees.length * 0.1) - 1)] ?? 0, // 10th
 | ||||
|         fees[Math.max(0, Math.floor(fees.length * 0.25) - 1)] ?? 0, // 25th
 | ||||
|         fees[Math.max(0, Math.floor(fees.length * 0.5) - 1)] ?? 0, // median
 | ||||
|         fees[Math.max(0, Math.floor(fees.length * 0.75) - 1)] ?? 0, // 75th
 | ||||
|         fees[Math.max(0, Math.floor(fees.length * 0.9) - 1)] ?? 0, // 90th
 | ||||
|         fees[fees.length - 1] ?? 0, // max
 | ||||
|       ]; | ||||
| 
 | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot get block summaries transactions. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new BlocksSummariesRepository(); | ||||
|  | ||||
| @ -1,4 +1,5 @@ | ||||
| import { Common } from '../api/common'; | ||||
| import poolsParser from '../api/pools-parser'; | ||||
| import config from '../config'; | ||||
| import DB from '../database'; | ||||
| import logger from '../logger'; | ||||
| @ -9,7 +10,7 @@ class PoolsRepository { | ||||
|    * Get all pools tagging info | ||||
|    */ | ||||
|   public async $getPools(): Promise<PoolTag[]> { | ||||
|     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 <PoolTag[]>rows; | ||||
|   } | ||||
| 
 | ||||
| @ -17,7 +18,11 @@ class PoolsRepository { | ||||
|    * Get unknown pool tagging info | ||||
|    */ | ||||
|   public async $getUnknownPool(): Promise<PoolTag> { | ||||
|     const [rows] = 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, unique_id as uniqueId, name, slug FROM pools where name = "Unknown"'); | ||||
|     } | ||||
|     return <PoolTag>rows[0]; | ||||
|   } | ||||
| 
 | ||||
| @ -27,16 +32,25 @@ class PoolsRepository { | ||||
|   public async $getPoolsInfo(interval: string | null = null): Promise<PoolInfo[]> { | ||||
|     interval = Common.getSqlInterval(interval); | ||||
| 
 | ||||
|     let query = `SELECT COUNT(height) as blockCount, pool_id as poolId, pools.name as name, pools.link as link, slug
 | ||||
|     let query = ` | ||||
|       SELECT | ||||
|         COUNT(blocks.height) As blockCount, | ||||
|           pool_id AS poolId, | ||||
|           pools.name AS name, | ||||
|           pools.link AS link, | ||||
|           slug, | ||||
|           AVG(blocks_audits.match_rate) AS avgMatchRate | ||||
|       FROM blocks | ||||
|       JOIN pools on pools.id = pool_id`;
 | ||||
|       JOIN pools on pools.id = pool_id | ||||
|       LEFT JOIN blocks_audits ON blocks_audits.height = blocks.height | ||||
|     `;
 | ||||
| 
 | ||||
|     if (interval) { | ||||
|       query += ` WHERE blocks.blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; | ||||
|     } | ||||
| 
 | ||||
|     query += ` GROUP BY pool_id
 | ||||
|       ORDER BY COUNT(height) DESC`;
 | ||||
|       ORDER BY COUNT(blocks.height) DESC`;
 | ||||
| 
 | ||||
|     try { | ||||
|       const [rows] = await DB.query(query); | ||||
| @ -50,7 +64,7 @@ class PoolsRepository { | ||||
|   /** | ||||
|    * Get basic pool info and block count between two timestamp | ||||
|    */ | ||||
|    public async $getPoolsInfoBetween(from: number, to: number): Promise<PoolInfo[]> { | ||||
|   public async $getPoolsInfoBetween(from: number, to: number): Promise<PoolInfo[]> { | ||||
|     const query = `SELECT COUNT(height) as blockCount, pools.id as poolId, pools.name as poolName
 | ||||
|       FROM pools | ||||
|       LEFT JOIN blocks on pools.id = blocks.pool_id AND blocks.blockTimestamp BETWEEN FROM_UNIXTIME(?) AND FROM_UNIXTIME(?) | ||||
| @ -66,9 +80,9 @@ class PoolsRepository { | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get mining pool statistics for one pool | ||||
|    * Get a mining pool info | ||||
|    */ | ||||
|    public async $getPool(slug: string): Promise<PoolTag | null> { | ||||
|   public async $getPool(slug: string, parse: boolean = true): Promise<PoolTag | null> { | ||||
|     const query = ` | ||||
|       SELECT * | ||||
|       FROM pools | ||||
| @ -81,10 +95,12 @@ class PoolsRepository { | ||||
|         return null; | ||||
|       } | ||||
| 
 | ||||
|       rows[0].regexes = JSON.parse(rows[0].regexes); | ||||
|       if (parse) { | ||||
|         rows[0].regexes = JSON.parse(rows[0].regexes); | ||||
|       } | ||||
|       if (['testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { | ||||
|         rows[0].addresses = []; // pools.json only contains mainnet addresses
 | ||||
|       } else { | ||||
|         rows[0].addresses = []; // pools-v2.json only contains mainnet addresses
 | ||||
|       } else if (parse) { | ||||
|         rows[0].addresses = JSON.parse(rows[0].addresses); | ||||
|       } | ||||
| 
 | ||||
| @ -94,6 +110,116 @@ class PoolsRepository { | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get a mining pool info by its unique id | ||||
|    */ | ||||
|   public async $getPoolByUniqueId(id: number, parse: boolean = true): Promise<PoolTag | null> { | ||||
|     const query = ` | ||||
|       SELECT * | ||||
|       FROM pools | ||||
|       WHERE pools.unique_id = ?`;
 | ||||
| 
 | ||||
|     try { | ||||
|       const [rows]: any[] = await DB.query(query, [id]); | ||||
| 
 | ||||
|       if (rows.length < 1) { | ||||
|         return null; | ||||
|       } | ||||
| 
 | ||||
|       if (parse) { | ||||
|         rows[0].regexes = JSON.parse(rows[0].regexes); | ||||
|       } | ||||
|       if (['testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { | ||||
|         rows[0].addresses = []; // pools.json only contains mainnet addresses
 | ||||
|       } else if (parse) { | ||||
|         rows[0].addresses = JSON.parse(rows[0].addresses); | ||||
|       } | ||||
| 
 | ||||
|       return rows[0]; | ||||
|     } catch (e) { | ||||
|       logger.err('Cannot get pool from db. Reason: ' + (e instanceof Error ? e.message : e)); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Insert a new mining pool in the database | ||||
|    *  | ||||
|    * @param pool  | ||||
|    */ | ||||
|   public async $insertNewMiningPool(pool: any, slug: string): Promise<void> { | ||||
|     try { | ||||
|       await DB.query(` | ||||
|         INSERT INTO pools | ||||
|         SET name = ?, link = ?, addresses = ?, regexes = ?, slug = ?, unique_id = ?`,
 | ||||
|         [pool.name, pool.link, JSON.stringify(pool.addresses), JSON.stringify(pool.regexes), slug, pool.id] | ||||
|       ); | ||||
|     } catch (e: any) { | ||||
|       logger.err(`Cannot insert new mining pool into db. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Rename an existing mining pool | ||||
|    *  | ||||
|    * @param dbId | ||||
|    * @param newSlug | ||||
|    * @param newName  | ||||
|    */ | ||||
|   public async $renameMiningPool(dbId: number, newSlug: string, newName: string): Promise<void> { | ||||
|     try { | ||||
|       await DB.query(` | ||||
|         UPDATE pools | ||||
|         SET slug = ?, name = ? | ||||
|         WHERE id = ?`,
 | ||||
|         [newSlug, newName, dbId] | ||||
|       ); | ||||
|     } catch (e: any) { | ||||
|       logger.err(`Cannot rename mining pool id ${dbId}. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Update an exisiting mining pool link | ||||
|    *  | ||||
|    * @param dbId  | ||||
|    * @param newLink  | ||||
|    */ | ||||
|   public async $updateMiningPoolLink(dbId: number, newLink: string): Promise<void> { | ||||
|     try { | ||||
|       await DB.query(` | ||||
|         UPDATE pools | ||||
|         SET link = ? | ||||
|         WHERE id = ?`,
 | ||||
|         [newLink, dbId] | ||||
|       ); | ||||
|     } catch (e: any) { | ||||
|       logger.err(`Cannot update link for mining pool id ${dbId}. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|     } | ||||
| 
 | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Update an existing mining pool addresses or coinbase tags | ||||
|    *  | ||||
|    * @param dbId  | ||||
|    * @param addresses  | ||||
|    * @param regexes  | ||||
|    */ | ||||
|   public async $updateMiningPoolTags(dbId: number, addresses: string, regexes: string): Promise<void> { | ||||
|     try { | ||||
|       await DB.query(` | ||||
|         UPDATE pools | ||||
|         SET addresses = ?, regexes = ? | ||||
|         WHERE id = ?`,
 | ||||
|         [JSON.stringify(addresses), JSON.stringify(regexes), dbId] | ||||
|       ); | ||||
|     } catch (e: any) { | ||||
|       logger.err(`Cannot update mining pool id ${dbId}. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default new PoolsRepository(); | ||||
|  | ||||
| @ -3,14 +3,57 @@ import logger from '../logger'; | ||||
| import { IConversionRates } from '../mempool.interfaces'; | ||||
| import priceUpdater from '../tasks/price-updater'; | ||||
| 
 | ||||
| export interface ApiPrice { | ||||
|   time?: number, | ||||
|   USD: number, | ||||
|   EUR: number, | ||||
|   GBP: number, | ||||
|   CAD: number, | ||||
|   CHF: number, | ||||
|   AUD: number, | ||||
|   JPY: number, | ||||
| } | ||||
| 
 | ||||
| export interface ExchangeRates { | ||||
|   USDEUR: number, | ||||
|   USDGBP: number, | ||||
|   USDCAD: number, | ||||
|   USDCHF: number, | ||||
|   USDAUD: number, | ||||
|   USDJPY: number, | ||||
| } | ||||
| 
 | ||||
| export interface Conversion { | ||||
|   prices: ApiPrice[], | ||||
|   exchangeRates: ExchangeRates; | ||||
| } | ||||
| 
 | ||||
| export const MAX_PRICES = { | ||||
|   USD: 100000000, | ||||
|   EUR: 100000000, | ||||
|   GBP: 100000000, | ||||
|   CAD: 100000000, | ||||
|   CHF: 100000000, | ||||
|   AUD: 100000000, | ||||
|   JPY: 10000000000, | ||||
| }; | ||||
| 
 | ||||
| class PricesRepository { | ||||
|   public async $savePrices(time: number, prices: IConversionRates): Promise<void> { | ||||
|     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; | ||||
|     } | ||||
| 
 | ||||
|     // Sanity check
 | ||||
|     for (const currency of Object.keys(prices)) { | ||||
|       if (prices[currency] < -1 || prices[currency] > MAX_PRICES[currency]) { // We use -1 to mark a "missing data, so it's a valid entry"
 | ||||
|         logger.info(`Ignore BTC${currency} price of ${prices[currency]}`); | ||||
|         prices[currency] = 0; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       await DB.query(` | ||||
|         INSERT INTO prices(time,             USD, EUR, GBP, CAD, CHF, AUD, JPY) | ||||
| @ -60,6 +103,73 @@ class PricesRepository { | ||||
|     } | ||||
|     return rates[0]; | ||||
|   } | ||||
| 
 | ||||
|   public async $getNearestHistoricalPrice(timestamp: number | undefined): Promise<Conversion | null> { | ||||
|     try { | ||||
|       const [rates]: any[] = await DB.query(` | ||||
|         SELECT *, UNIX_TIMESTAMP(time) AS time | ||||
|         FROM prices | ||||
|         WHERE UNIX_TIMESTAMP(time) < ? | ||||
|         ORDER BY time DESC | ||||
|         LIMIT 1`,
 | ||||
|         [timestamp] | ||||
|       ); | ||||
|       if (!rates) { | ||||
|         throw Error(`Cannot get single historical price from the database`); | ||||
|       } | ||||
| 
 | ||||
|       // Compute fiat exchange rates
 | ||||
|       const latestPrice = await this.$getLatestConversionRates(); | ||||
|       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, | ||||
|       }; | ||||
| 
 | ||||
|       return { | ||||
|         prices: rates, | ||||
|         exchangeRates: exchangeRates | ||||
|       }; | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot fetch single historical prices from the db. Reason ${e instanceof Error ? e.message : e}`); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getHistoricalPrices(): Promise<Conversion | null> { | ||||
|     try { | ||||
|       const [rates]: any[] = await DB.query(` | ||||
|         SELECT *, UNIX_TIMESTAMP(time) AS time | ||||
|         FROM prices | ||||
|         ORDER BY time DESC | ||||
|       `);
 | ||||
|       if (!rates) { | ||||
|         throw Error(`Cannot get average historical price from the database`); | ||||
|       } | ||||
| 
 | ||||
|       // Compute fiat exchange rates
 | ||||
|       const latestPrice: ApiPrice = rates[0]; | ||||
|       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, | ||||
|       }; | ||||
| 
 | ||||
|       return { | ||||
|         prices: rates, | ||||
|         exchangeRates: exchangeRates | ||||
|       }; | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot fetch historical prices from the db. Reason ${e instanceof Error ? e.message : e}`); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new PricesRepository(); | ||||
|  | ||||
| @ -88,5 +88,7 @@ module.exports = { | ||||
|   verifyTxOutProof: 'verifytxoutproof', // bitcoind v0.11.0+
 | ||||
|   walletLock: 'walletlock', | ||||
|   walletPassphrase: 'walletpassphrase', | ||||
|   walletPassphraseChange: 'walletpassphrasechange' | ||||
| } | ||||
|   walletPassphraseChange: 'walletpassphrasechange', | ||||
|   getTxoutSetinfo: 'gettxoutsetinfo', | ||||
|   getIndexInfo: 'getindexinfo', | ||||
| }; | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -8,7 +8,7 @@ import { SocksProxyAgent } from 'socks-proxy-agent'; | ||||
| import * as https from 'https'; | ||||
| 
 | ||||
| /** | ||||
|  * Maintain the most recent version of pools.json | ||||
|  * Maintain the most recent version of pools-v2.json | ||||
|  */ | ||||
| class PoolsUpdater { | ||||
|   lastRun: number = 0; | ||||
| @ -31,14 +31,8 @@ class PoolsUpdater { | ||||
| 
 | ||||
|     this.lastRun = now; | ||||
| 
 | ||||
|     if (config.SOCKS5PROXY.ENABLED) { | ||||
|       logger.info(`Updating latest mining pools from ${this.poolsUrl} over the Tor network`, logger.tags.mining); | ||||
|     } else { | ||||
|       logger.info(`Updating latest mining pools from ${this.poolsUrl} over clearnet`, logger.tags.mining); | ||||
|     } | ||||
| 
 | ||||
|     try { | ||||
|       const githubSha = await this.fetchPoolsSha(); // Fetch pools.json sha from github
 | ||||
|       const githubSha = await this.fetchPoolsSha(); // Fetch pools-v2.json sha from github
 | ||||
|       if (githubSha === undefined) { | ||||
|         return; | ||||
|       } | ||||
| @ -47,32 +41,57 @@ class PoolsUpdater { | ||||
|         this.currentSha = await this.getShaFromDb(); | ||||
|       } | ||||
| 
 | ||||
|       logger.debug(`Pools.json sha | Current: ${this.currentSha} | Github: ${githubSha}`); | ||||
|       logger.debug(`pools-v2.json sha | Current: ${this.currentSha} | Github: ${githubSha}`); | ||||
|       if (this.currentSha !== undefined && 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
 | ||||
|         config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING !== true && // Automatic pools update is disabled
 | ||||
|         !process.env.npm_config_update_pools // We're not manually updating mining pool
 | ||||
|       ) { | ||||
|         logger.warn(`Updated mining pools data is available (${githubSha}) but AUTOMATIC_BLOCK_REINDEXING is disabled`); | ||||
|         logger.info(`You can update your mining pools using the --update-pools command flag. You may want to clear your nginx cache as well if applicable`); | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       const network = config.SOCKS5PROXY.ENABLED ? 'tor' : 'clearnet'; | ||||
|       if (this.currentSha === undefined) { | ||||
|         logger.info(`Downloading pools.json for the first time from ${this.poolsUrl}`, logger.tags.mining); | ||||
|         logger.info(`Downloading pools-v2.json for the first time from ${this.poolsUrl} over ${network}`, logger.tags.mining); | ||||
|       } else { | ||||
|         logger.warn(`Pools.json is outdated, fetch latest from ${this.poolsUrl}`, logger.tags.mining); | ||||
|         logger.warn(`pools-v2.json is outdated, fetch latest from ${this.poolsUrl} over ${network}`, logger.tags.mining); | ||||
|       } | ||||
|       const poolsJson = await this.query(this.poolsUrl); | ||||
|       if (poolsJson === undefined) { | ||||
|         return; | ||||
|       } | ||||
|       await poolsParser.migratePoolsJson(poolsJson); | ||||
|       await this.updateDBSha(githubSha); | ||||
|       logger.notice(`PoolsUpdater completed`, logger.tags.mining); | ||||
|       poolsParser.setMiningPools(poolsJson); | ||||
| 
 | ||||
|       if (config.DATABASE.ENABLED === false) { // Don't run db operations
 | ||||
|         logger.info('Mining pools-v2.json import completed (no database)'); | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       try { | ||||
|         await DB.query('START TRANSACTION;'); | ||||
|         await poolsParser.migratePoolsJson(); | ||||
|         await this.updateDBSha(githubSha); | ||||
|         await DB.query('COMMIT;'); | ||||
|       } catch (e) { | ||||
|         logger.err(`Could not migrate mining pools, rolling back. Exception: ${JSON.stringify(e)}`, logger.tags.mining); | ||||
|         await DB.query('ROLLBACK;'); | ||||
|       } | ||||
|       logger.notice('PoolsUpdater completed'); | ||||
| 
 | ||||
|     } catch (e) { | ||||
|       this.lastRun = now - (oneWeek - oneDay); // Try again in 24h instead of waiting next week
 | ||||
|       logger.err(`PoolsUpdater failed. Will try again in 24h. Reason: ${e instanceof Error ? e.message : e}`, logger.tags.mining); | ||||
|       logger.err(`PoolsUpdater failed. Will try again in 24h. Exception: ${JSON.stringify(e)}`, logger.tags.mining); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Fetch our latest pools.json sha from the db | ||||
|    * Fetch our latest pools-v2.json sha from the db | ||||
|    */ | ||||
|   private async updateDBSha(githubSha: string): Promise<void> { | ||||
|     this.currentSha = githubSha; | ||||
| @ -81,46 +100,46 @@ class PoolsUpdater { | ||||
|         await DB.query('DELETE FROM state where name="pools_json_sha"'); | ||||
|         await DB.query(`INSERT INTO state VALUES('pools_json_sha', NULL, '${githubSha}')`); | ||||
|       } catch (e) { | ||||
|         logger.err('Cannot save github pools.json sha into the db. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); | ||||
|         logger.err('Cannot save github pools-v2.json sha into the db. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Fetch our latest pools.json sha from the db | ||||
|    * Fetch our latest pools-v2.json sha from the db | ||||
|    */ | ||||
|   private async getShaFromDb(): Promise<string | undefined> { | ||||
|     try { | ||||
|       const [rows]: any[] = await DB.query('SELECT string FROM state WHERE name="pools_json_sha"'); | ||||
|       return (rows.length > 0 ? rows[0].string : undefined); | ||||
|     } catch (e) { | ||||
|       logger.err('Cannot fetch pools.json sha from db. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); | ||||
|       logger.err('Cannot fetch pools-v2.json sha from db. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); | ||||
|       return undefined; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Fetch our latest pools.json sha from github | ||||
|    * Fetch our latest pools-v2.json sha from github | ||||
|    */ | ||||
|   private async fetchPoolsSha(): Promise<string | undefined> { | ||||
|     const response = await this.query(this.treeUrl); | ||||
| 
 | ||||
|     if (response !== undefined) { | ||||
|       for (const file of response['tree']) { | ||||
|         if (file['path'] === 'pools.json') { | ||||
|         if (file['path'] === 'pools-v2.json') { | ||||
|           return file['sha']; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     logger.err(`Cannot find "pools.json" in git tree (${this.treeUrl})`, logger.tags.mining); | ||||
|     logger.err(`Cannot find "pools-v2.json" in git tree (${this.treeUrl})`, logger.tags.mining); | ||||
|     return undefined; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Http request wrapper | ||||
|    */ | ||||
|   private async query(path): Promise<object | undefined> { | ||||
|   private async query(path): Promise<any[] | undefined> { | ||||
|     type axiosOptions = { | ||||
|       headers: { | ||||
|         'User-Agent': string | ||||
|  | ||||
| @ -3,7 +3,7 @@ import path from 'path'; | ||||
| import config from '../config'; | ||||
| import logger from '../logger'; | ||||
| import { IConversionRates } from '../mempool.interfaces'; | ||||
| import PricesRepository from '../repositories/PricesRepository'; | ||||
| import PricesRepository, { 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'; | ||||
| @ -46,13 +46,13 @@ class PriceUpdater { | ||||
| 
 | ||||
|   public getEmptyPricesObj(): IConversionRates { | ||||
|     return { | ||||
|       USD: 0, | ||||
|       EUR: 0, | ||||
|       GBP: 0, | ||||
|       CAD: 0, | ||||
|       CHF: 0, | ||||
|       AUD: 0, | ||||
|       JPY: 0, | ||||
|       USD: -1, | ||||
|       EUR: -1, | ||||
|       GBP: -1, | ||||
|       CAD: -1, | ||||
|       CHF: -1, | ||||
|       AUD: -1, | ||||
|       JPY: -1, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
| @ -115,7 +115,7 @@ class PriceUpdater { | ||||
|         if (feed.currencies.includes(currency)) { | ||||
|           try { | ||||
|             const price = await feed.$fetchPrice(currency); | ||||
|             if (price > 0) { | ||||
|             if (price > -1 && price < MAX_PRICES[currency]) { | ||||
|               prices.push(price); | ||||
|             } | ||||
|             logger.debug(`${feed.name} BTC/${currency} price: ${price}`, logger.tags.mining); | ||||
| @ -239,7 +239,7 @@ class PriceUpdater { | ||||
| 
 | ||||
|         for (const currency of this.currencies) { | ||||
|           const price = historicalEntry[time][currency]; | ||||
|           if (price > 0) { | ||||
|           if (price > -1 && price < MAX_PRICES[currency]) { | ||||
|             grouped[time][currency].push(typeof price === 'string' ? parseInt(price, 10) : price); | ||||
|           } | ||||
|         } | ||||
|  | ||||
| @ -1,33 +0,0 @@ | ||||
| import { BlockExtended } from '../mempool.interfaces'; | ||||
| 
 | ||||
| export function prepareBlock(block: any): BlockExtended { | ||||
|   return <BlockExtended>{ | ||||
|     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, | ||||
|     } | ||||
|   }; | ||||
| } | ||||
| @ -102,15 +102,16 @@ Below we list all settings from `mempool-config.json` and the corresponding over | ||||
|     "MEMPOOL_BLOCKS_AMOUNT": 8, | ||||
|     "BLOCKS_SUMMARIES_INDEXING": false, | ||||
|     "USE_SECOND_NODE_FOR_MINFEE": false, | ||||
|     "EXTERNAL_ASSETS": ["https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json"], | ||||
|     "EXTERNAL_ASSETS": [], | ||||
|     "STDOUT_LOG_MIN_PRIORITY": "info", | ||||
|     "INDEXING_BLOCKS_AMOUNT": false, | ||||
|     "AUTOMATIC_BLOCK_REINDEXING": false, | ||||
|     "POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json", | ||||
|     "POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json", | ||||
|     "POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master", | ||||
|     "ADVANCED_GBT_AUDIT": false, | ||||
|     "ADVANCED_GBT_MEMPOOL": false, | ||||
|     "CPFP_INDEXING": false, | ||||
|     "MAX_BLOCKS_BULK_QUERY": 0, | ||||
|   }, | ||||
| ``` | ||||
| 
 | ||||
| @ -141,6 +142,7 @@ Corresponding `docker-compose.yml` overrides: | ||||
|       MEMPOOL_ADVANCED_GBT_AUDIT: "" | ||||
|       MEMPOOL_ADVANCED_GBT_MEMPOOL: "" | ||||
|       MEMPOOL_CPFP_INDEXING: "" | ||||
|       MAX_BLOCKS_BULK_QUERY: "" | ||||
|       ... | ||||
| ``` | ||||
| 
 | ||||
|  | ||||
| @ -25,7 +25,8 @@ | ||||
|     "AUDIT": __MEMPOOL_AUDIT__, | ||||
|     "ADVANCED_GBT_AUDIT": __MEMPOOL_ADVANCED_GBT_AUDIT__, | ||||
|     "ADVANCED_GBT_MEMPOOL": __MEMPOOL_ADVANCED_GBT_MEMPOOL__, | ||||
|     "CPFP_INDEXING": __MEMPOOL_CPFP_INDEXING__ | ||||
|     "CPFP_INDEXING": __MEMPOOL_CPFP_INDEXING__, | ||||
|     "MAX_BLOCKS_BULK_QUERY": __MEMPOOL__MAX_BLOCKS_BULK_QUERY__ | ||||
|   }, | ||||
|   "CORE_RPC": { | ||||
|     "HOST": "__CORE_RPC_HOST__", | ||||
|  | ||||
| @ -24,12 +24,13 @@ __MEMPOOL_USER_AGENT__=${MEMPOOL_USER_AGENT:=mempool} | ||||
| __MEMPOOL_STDOUT_LOG_MIN_PRIORITY__=${MEMPOOL_STDOUT_LOG_MIN_PRIORITY:=info} | ||||
| __MEMPOOL_INDEXING_BLOCKS_AMOUNT__=${MEMPOOL_INDEXING_BLOCKS_AMOUNT:=false} | ||||
| __MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__=${MEMPOOL_AUTOMATIC_BLOCK_REINDEXING:=false} | ||||
| __MEMPOOL_POOLS_JSON_URL__=${MEMPOOL_POOLS_JSON_URL:=https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json} | ||||
| __MEMPOOL_POOLS_JSON_URL__=${MEMPOOL_POOLS_JSON_URL:=https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json} | ||||
| __MEMPOOL_POOLS_JSON_TREE_URL__=${MEMPOOL_POOLS_JSON_TREE_URL:=https://api.github.com/repos/mempool/mining-pools/git/trees/master} | ||||
| __MEMPOOL_AUDIT__=${MEMPOOL_AUDIT:=false} | ||||
| __MEMPOOL_ADVANCED_GBT_AUDIT__=${MEMPOOL_ADVANCED_GBT_AUDIT:=false} | ||||
| __MEMPOOL_ADVANCED_GBT_MEMPOOL__=${MEMPOOL_ADVANCED_GBT_MEMPOOL:=false} | ||||
| __MEMPOOL_CPFP_INDEXING__=${MEMPOOL_CPFP_INDEXING:=false} | ||||
| __MEMPOOL_MAX_BLOCKS_BULK_QUERY__=${MEMPOOL_MAX_BLOCKS_BULK_QUERY:=0} | ||||
| 
 | ||||
| # CORE_RPC | ||||
| __CORE_RPC_HOST__=${CORE_RPC_HOST:=127.0.0.1} | ||||
| @ -142,6 +143,7 @@ sed -i "s!__MEMPOOL_AUDIT__!${__MEMPOOL_AUDIT__}!g" mempool-config.json | ||||
| sed -i "s!__MEMPOOL_ADVANCED_GBT_MEMPOOL__!${__MEMPOOL_ADVANCED_GBT_MEMPOOL__}!g" mempool-config.json | ||||
| sed -i "s!__MEMPOOL_ADVANCED_GBT_AUDIT__!${__MEMPOOL_ADVANCED_GBT_AUDIT__}!g" mempool-config.json | ||||
| sed -i "s!__MEMPOOL_CPFP_INDEXING__!${__MEMPOOL_CPFP_INDEXING__}!g" mempool-config.json | ||||
| sed -i "s!__MEMPOOL_MAX_BLOCKS_BULK_QUERY__!${__MEMPOOL_MAX_BLOCKS_BULK_QUERY__}!g" mempool-config.json | ||||
| 
 | ||||
| sed -i "s/__CORE_RPC_HOST__/${__CORE_RPC_HOST__}/g" mempool-config.json | ||||
| sed -i "s/__CORE_RPC_PORT__/${__CORE_RPC_PORT__}/g" mempool-config.json | ||||
|  | ||||
| @ -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} | ||||
|  | ||||
							
								
								
									
										1
									
								
								frontend/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								frontend/.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -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 | ||||
|  | ||||
| @ -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}', | ||||
|   }, | ||||
| }) | ||||
| }); | ||||
|  | ||||
| @ -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(); | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| describe('Liquid', () => { | ||||
|   const baseModule = Cypress.env("BASE_MODULE"); | ||||
|   const baseModule = Cypress.env('BASE_MODULE'); | ||||
|   const basePath = ''; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| describe('Liquid Testnet', () => { | ||||
|   const baseModule = Cypress.env("BASE_MODULE"); | ||||
|   const baseModule = Cypress.env('BASE_MODULE'); | ||||
|   const basePath = '/testnet'; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|  | ||||
| @ -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'); | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| const baseModule = Cypress.env("BASE_MODULE"); | ||||
| const baseModule = Cypress.env('BASE_MODULE'); | ||||
| 
 | ||||
| describe('Mainnet - Mining Features', () => { | ||||
|   beforeEach(() => { | ||||
|  | ||||
| @ -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' | ||||
|         } | ||||
|       }); | ||||
| 
 | ||||
|  | ||||
| @ -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'); | ||||
|  | ||||
| @ -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; | ||||
| } | ||||
| @ -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 | ||||
| } | ||||
|  | ||||
							
								
								
									
										44
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										44
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -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", | ||||
|  | ||||
| @ -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 | ||||
|   } | ||||
| } | ||||
| } | ||||
| @ -72,7 +72,7 @@ export const chartColors = [ | ||||
| ]; | ||||
| 
 | ||||
| export const poolsColor = { | ||||
|   'unknown': '#9C9C9C', | ||||
|   'unknown': '#FDD835', | ||||
| }; | ||||
| 
 | ||||
| export const feeLevels = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, | ||||
|  | ||||
| @ -7,6 +7,7 @@ import { AppComponent } from './components/app/app.component'; | ||||
| import { ElectrsApiService } from './services/electrs-api.service'; | ||||
| import { StateService } from './services/state.service'; | ||||
| import { CacheService } from './services/cache.service'; | ||||
| import { PriceService } from './services/price.service'; | ||||
| import { EnterpriseService } from './services/enterprise.service'; | ||||
| import { WebsocketService } from './services/websocket.service'; | ||||
| import { AudioService } from './services/audio.service'; | ||||
| @ -26,6 +27,7 @@ const providers = [ | ||||
|   ElectrsApiService, | ||||
|   StateService, | ||||
|   CacheService, | ||||
|   PriceService, | ||||
|   WebsocketService, | ||||
|   AudioService, | ||||
|   SeoService, | ||||
|  | ||||
| @ -13,19 +13,9 @@ | ||||
|     <p i18n>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.</p> | ||||
|   </div> | ||||
| 
 | ||||
|   <div class="social-icons"> | ||||
|     <a target="_blank" href="https://github.com/mempool/mempool"> | ||||
|       <svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="github" class="svg-inline--fa fa-github fa-w-16 fa-4x" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path></svg> | ||||
|     </a> | ||||
|     <a target="_blank" href="https://twitter.com/mempool"> | ||||
|       <svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="twitter" class="svg-inline--fa fa-twitter fa-w-16 fa-4x" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"></path></svg> | ||||
|     </a> | ||||
|     <a target="_blank" href="https://matrix.to/#/#mempool:bitcoin.kyoto"> | ||||
|       <svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="matrix" class="svg-inline--fa fa-matrix fa-w-16 fa-4x" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1536 1792"><path fill="currentColor" d="M40.467 163.152v1465.696H145.92V1664H0V128h145.92v35.152zm450.757 464.64v74.14h2.069c19.79-28.356 43.717-50.215 71.483-65.575 27.765-15.656 59.963-23.336 96-23.336 34.56 0 66.165 6.795 94.818 20.086 28.652 13.293 50.216 37.22 65.28 70.893 16.246-23.926 38.4-45.194 66.166-63.507 27.766-18.314 60.848-27.472 98.954-27.472 28.948 0 55.828 3.545 80.64 10.635 24.812 7.088 45.785 18.314 63.508 33.968 17.722 15.656 31.31 35.742 41.354 60.85 9.747 25.107 14.768 55.236 14.768 90.683v366.573h-150.35V865.28c0-18.314-.59-35.741-2.068-51.987-1.476-16.247-5.316-30.426-11.52-42.24-6.499-12.112-15.656-21.563-28.062-28.653-12.405-7.088-29.242-10.634-50.214-10.634-21.268 0-38.4 4.135-51.397 12.112-12.997 8.27-23.336 18.608-30.72 31.901-7.386 12.997-12.407 27.765-14.77 44.602-2.363 16.542-3.84 33.379-3.84 50.216v305.133H692.971v-307.2c0-16.247-.294-32.197-1.18-48.149-.591-15.95-3.84-30.424-9.157-44.011-5.317-13.293-14.178-24.223-26.585-32.197-12.406-7.976-30.425-12.112-54.646-12.112-7.088 0-16.542 1.478-28.062 4.726-11.52 3.25-23.04 9.157-33.968 18.02-10.93 8.86-20.383 21.563-28.063 38.103-7.68 16.543-11.52 38.4-11.52 65.28v317.834H349.44V627.792zm1004.309 1001.056V163.152H1390.08V128H1536v1536h-145.92v-35.152z"/></svg> | ||||
|     </a> | ||||
|   </div> | ||||
|   <video src="/resources/mempool-promo.mp4" poster="/resources/mempool-promo.jpg" controls loop playsinline [autoplay]="true" [muted]="true"></video> | ||||
| 
 | ||||
|   <div class="enterprise-sponsor"> | ||||
|   <div class="enterprise-sponsor" id="enterprise-sponsors"> | ||||
|     <h3 i18n="about.sponsors.enterprise.withRocket">Enterprise Sponsors 🚀</h3> | ||||
|     <div class="wrapper"> | ||||
|       <a href="https://spiral.xyz/" target="_blank" title="Spiral"> | ||||
| @ -173,7 +163,7 @@ | ||||
|     </div> | ||||
|   </div> | ||||
| 
 | ||||
|   <div class="community-sponsor"> | ||||
|   <div class="community-sponsor" id="community-sponsors"> | ||||
|     <h3 i18n="about.sponsors.withHeart">Community Sponsors ❤️</h3> | ||||
| 
 | ||||
|     <div class="wrapper"> | ||||
| @ -187,7 +177,7 @@ | ||||
|     </div> | ||||
|   </div> | ||||
| 
 | ||||
|   <div class="community-integrations-sponsor"> | ||||
|   <div class="community-integrations-sponsor" id="community-integrations"> | ||||
|     <h3 i18n="about.community-integrations">Community Integrations</h3> | ||||
|     <div class="wrapper"> | ||||
|       <a href="https://github.com/getumbrel/umbrel" target="_blank" title="Umbrel"> | ||||
| @ -281,7 +271,7 @@ | ||||
|     </div> | ||||
|   </div> | ||||
| 
 | ||||
|   <div class="alliances"> | ||||
|   <div class="alliances" id="community-alliances"> | ||||
|     <h3 i18n="about.alliances">Community Alliances</h3> | ||||
|     <div class="wrapper"> | ||||
|       <a href="https://liquid.net/" title="Liquid Network"> | ||||
| @ -297,7 +287,7 @@ | ||||
|   </div> | ||||
| 
 | ||||
|   <ng-container *ngIf="translators$ | async | keyvalue as translators else loadingSponsors"> | ||||
|     <div class="project-translators"> | ||||
|     <div class="project-translators" id="project-translators"> | ||||
|       <h3 i18n="about.translators">Project Translators</h3> | ||||
|       <div class="wrapper"> | ||||
|         <ng-template ngFor let-translator [ngForOf]="translators"> | ||||
| @ -311,7 +301,7 @@ | ||||
|   </ng-container> | ||||
| 
 | ||||
|   <ng-container *ngIf="allContributors$ | async as contributors else loadingSponsors"> | ||||
|     <div class="contributors"> | ||||
|     <div class="contributors" id="project-contributors"> | ||||
|       <h3 i18n="about.contributors">Project Contributors</h3> | ||||
|       <div class="wrapper"> | ||||
|         <ng-template ngFor let-contributor [ngForOf]="contributors.regular"> | ||||
| @ -323,7 +313,7 @@ | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="maintainers" *ngIf="contributors.core.length"> | ||||
|     <div class="maintainers" *ngIf="contributors.core.length" id="project-members"> | ||||
|       <h3 i18n="about.project_members">Project Members</h3> | ||||
|       <div class="wrapper"> | ||||
|         <ng-template ngFor let-contributor [ngForOf]="contributors.core"> | ||||
| @ -336,7 +326,7 @@ | ||||
|     </div> | ||||
|   </ng-container> | ||||
| 
 | ||||
|   <div class="maintainers"> | ||||
|   <div class="maintainers" id="project-maintainers"> | ||||
|     <h3 i18n="about.maintainers">Project Maintainers</h3> | ||||
|     <div class="wrapper"> | ||||
|         <a href="https://twitter.com/softsimon_" target="_blank" title="softsimon"> | ||||
| @ -352,7 +342,7 @@ | ||||
| 
 | ||||
|   <div class="copyright"> | ||||
|     <div class="title"> | ||||
|       Copyright © 2019-2022<br> | ||||
|       Copyright © 2019-2023<br> | ||||
|       The Mempool Open Source Project | ||||
|     </div> | ||||
|     <p> | ||||
| @ -383,6 +373,17 @@ | ||||
|   <div class="footer-links"> | ||||
|     <a href="/3rdpartylicenses.txt">Third-party Licenses</a> | ||||
|     <a [routerLink]="['/terms-of-service']" i18n="shared.terms-of-service|Terms of Service">Terms of Service</a> | ||||
|     <div class="social-icons"> | ||||
|       <a target="_blank" href="https://github.com/mempool/mempool"> | ||||
|         <svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="github" class="svg-inline--fa fa-github fa-w-16 fa-2x" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path></svg> | ||||
|       </a> | ||||
|       <a target="_blank" href="https://twitter.com/mempool"> | ||||
|         <svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="twitter" class="svg-inline--fa fa-twitter fa-w-16 fa-2x" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"></path></svg> | ||||
|       </a> | ||||
|       <a target="_blank" href="https://matrix.to/#/#mempool:bitcoin.kyoto"> | ||||
|         <svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="matrix" class="svg-inline--fa fa-matrix fa-w-16 fa-2x" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1536 1792"><path fill="currentColor" d="M40.467 163.152v1465.696H145.92V1664H0V128h145.92v35.152zm450.757 464.64v74.14h2.069c19.79-28.356 43.717-50.215 71.483-65.575 27.765-15.656 59.963-23.336 96-23.336 34.56 0 66.165 6.795 94.818 20.086 28.652 13.293 50.216 37.22 65.28 70.893 16.246-23.926 38.4-45.194 66.166-63.507 27.766-18.314 60.848-27.472 98.954-27.472 28.948 0 55.828 3.545 80.64 10.635 24.812 7.088 45.785 18.314 63.508 33.968 17.722 15.656 31.31 35.742 41.354 60.85 9.747 25.107 14.768 55.236 14.768 90.683v366.573h-150.35V865.28c0-18.314-.59-35.741-2.068-51.987-1.476-16.247-5.316-30.426-11.52-42.24-6.499-12.112-15.656-21.563-28.062-28.653-12.405-7.088-29.242-10.634-50.214-10.634-21.268 0-38.4 4.135-51.397 12.112-12.997 8.27-23.336 18.608-30.72 31.901-7.386 12.997-12.407 27.765-14.77 44.602-2.363 16.542-3.84 33.379-3.84 50.216v305.133H692.971v-307.2c0-16.247-.294-32.197-1.18-48.149-.591-15.95-3.84-30.424-9.157-44.011-5.317-13.293-14.178-24.223-26.585-32.197-12.406-7.976-30.425-12.112-54.646-12.112-7.088 0-16.542 1.478-28.062 4.726-11.52 3.25-23.04 9.157-33.968 18.02-10.93 8.86-20.383 21.563-28.063 38.103-7.68 16.543-11.52 38.4-11.52 65.28v317.834H349.44V627.792zm1004.309 1001.056V163.152H1390.08V128H1536v1536h-145.92v-35.152z"/></svg> | ||||
|       </a> | ||||
|     </div> | ||||
|   </div> | ||||
| 
 | ||||
|   <div class="footer-version" *ngIf="officialMempoolSpace"> | ||||
|  | ||||
| @ -34,6 +34,13 @@ | ||||
|     padding: 10px 15px 15px; | ||||
|   } | ||||
| 
 | ||||
|   video { | ||||
|     width: 640px; | ||||
|     height: 360px; | ||||
|     max-width: 90%; | ||||
|     margin-top: 0; | ||||
|   } | ||||
| 
 | ||||
|   .social-icons { | ||||
|     a { | ||||
|       margin: auto 10px; | ||||
| @ -46,6 +53,7 @@ | ||||
|   .maintainers { | ||||
|     margin-top: 68px; | ||||
|     margin-bottom: 68px; | ||||
|     scroll-margin: 30px; | ||||
|   } | ||||
| 
 | ||||
|   .maintainers { | ||||
| @ -117,6 +125,7 @@ | ||||
|   .project-translators, | ||||
|   .community-integrations-sponsor, | ||||
|   .maintainers { | ||||
|     scroll-margin: 30px; | ||||
|     .wrapper { | ||||
|       display: inline-block; | ||||
|       a { | ||||
| @ -145,6 +154,13 @@ | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .project-translators .wrapper { | ||||
|     a img { | ||||
|       width: 72px; | ||||
|       height: 72px; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .copyright { | ||||
|     text-align: left; | ||||
|     max-width: 620px; | ||||
| @ -179,6 +195,11 @@ | ||||
|         margin: 20px auto 30px; | ||||
|       } | ||||
|     } | ||||
|     .social-icons { | ||||
|       a { | ||||
|         margin: 45px 10px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .footer-version { | ||||
|  | ||||
| @ -5,9 +5,10 @@ import { StateService } from '../../services/state.service'; | ||||
| import { Observable } from 'rxjs'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { IBackendInfo } from '../../interfaces/websocket.interface'; | ||||
| import { Router } from '@angular/router'; | ||||
| import { map } from 'rxjs/operators'; | ||||
| import { Router, ActivatedRoute } from '@angular/router'; | ||||
| import { map, tap } from 'rxjs/operators'; | ||||
| import { ITranslators } from '../../interfaces/node-api.interface'; | ||||
| import { DOCUMENT } from '@angular/common'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-about', | ||||
| @ -31,7 +32,9 @@ export class AboutComponent implements OnInit { | ||||
|     public stateService: StateService, | ||||
|     private apiService: ApiService, | ||||
|     private router: Router, | ||||
|     private route: ActivatedRoute, | ||||
|     @Inject(LOCALE_ID) public locale: string, | ||||
|     @Inject(DOCUMENT) private document: Document, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit() { | ||||
| @ -39,17 +42,21 @@ export class AboutComponent implements OnInit { | ||||
|     this.seoService.setTitle($localize`:@@004b222ff9ef9dd4771b777950ca1d0e4cd4348a:About`); | ||||
|     this.websocketService.want(['blocks']); | ||||
| 
 | ||||
|     this.sponsors$ = this.apiService.getDonation$(); | ||||
|     this.sponsors$ = this.apiService.getDonation$() | ||||
|       .pipe( | ||||
|         tap(() => this.goToAnchor()) | ||||
|       ); | ||||
|     this.translators$ = this.apiService.getTranslators$() | ||||
|       .pipe( | ||||
|         map((translators) => { | ||||
|           for (const t in translators) { | ||||
|             if (translators[t] === '') { | ||||
|               delete translators[t] | ||||
|               delete translators[t]; | ||||
|             } | ||||
|           } | ||||
|           return translators; | ||||
|         }) | ||||
|         }), | ||||
|         tap(() => this.goToAnchor()) | ||||
|       ); | ||||
|     this.allContributors$ = this.apiService.getContributor$().pipe( | ||||
|       map((contributors) => { | ||||
| @ -57,9 +64,24 @@ export class AboutComponent implements OnInit { | ||||
|           regular: contributors.filter((user) => !user.core_constributor), | ||||
|           core: contributors.filter((user) => user.core_constributor), | ||||
|         }; | ||||
|       }) | ||||
|       }), | ||||
|       tap(() => this.goToAnchor()) | ||||
|     ); | ||||
|   } | ||||
|    | ||||
|   ngAfterViewInit() { | ||||
|     this.goToAnchor(); | ||||
|   } | ||||
| 
 | ||||
|   goToAnchor() { | ||||
|     setTimeout(() => { | ||||
|       if (this.route.snapshot.fragment) { | ||||
|         if (this.document.getElementById(this.route.snapshot.fragment)) { | ||||
|           this.document.getElementById(this.route.snapshot.fragment).scrollIntoView({behavior: 'smooth'}); | ||||
|         } | ||||
|       } | ||||
|     }, 1); | ||||
|   } | ||||
| 
 | ||||
|   sponsor(): void { | ||||
|     if (this.officialMempoolSpace && this.stateService.env.BASE_MODULE === 'mempool') { | ||||
|  | ||||
| @ -1,7 +1,19 @@ | ||||
| <ng-container *ngIf="!noFiat && (viewFiat$ | async) && (conversions$ | async) as conversions; else viewFiatVin"> | ||||
|   <span class="fiat">{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ (conversions ? conversions[currency] : 0) * satoshis / 100000000 | fiatCurrency : digitsInfo : currency }}</span> | ||||
|   <span class="fiat" *ngIf="blockConversion; else noblockconversion"> | ||||
|     {{ addPlus && satoshis >= 0 ? '+' : '' }} | ||||
|     {{ | ||||
|       ( | ||||
|         (blockConversion.price[currency] >= 0 ? blockConversion.price[currency] : null) ?? | ||||
|         (blockConversion.price['USD'] * blockConversion.exchangeRates['USD' + currency]) ?? 0 | ||||
|       ) * satoshis / 100000000 | fiatCurrency : digitsInfo : currency | ||||
|     }} | ||||
|   </span> | ||||
|   <ng-template #noblockconversion> | ||||
|     <span class="fiat">{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ (conversions ? conversions[currency] : 0) * satoshis / 100000000 | fiatCurrency : digitsInfo : currency }}</span> | ||||
|   </ng-template> | ||||
| </ng-container> | ||||
| <ng-template #viewFiatVin> | ||||
| 
 | ||||
|   <ng-template #viewFiatVin> | ||||
|   <ng-template [ngIf]="(network === 'liquid' || network === 'liquidtestnet') && (satoshis === undefined || satoshis === null)" [ngIfElse]="default"> | ||||
|     <span i18n="shared.confidential">Confidential</span> | ||||
|   </ng-template> | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| import { Component, OnInit, OnDestroy, Input, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { Observable, Subscription } from 'rxjs'; | ||||
| import { Price } from '../../services/price.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-amount', | ||||
| @ -21,6 +22,7 @@ export class AmountComponent implements OnInit, OnDestroy { | ||||
|   @Input() digitsInfo = '1.8-8'; | ||||
|   @Input() noFiat = false; | ||||
|   @Input() addPlus = false; | ||||
|   @Input() blockConversion: Price; | ||||
| 
 | ||||
|   constructor( | ||||
|     private stateService: StateService, | ||||
|  | ||||
| @ -10,7 +10,7 @@ | ||||
|     </div>   | ||||
| 
 | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}"> | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 144" [class.active]="radioGroupForm.get('dateSpan').value === '24h'"> | ||||
|           <input type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 24h | ||||
|         </label> | ||||
|  | ||||
| @ -78,3 +78,8 @@ | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .disabled { | ||||
|   pointer-events: none; | ||||
|   opacity: 0.5; | ||||
| } | ||||
| @ -10,7 +10,7 @@ | ||||
|     </div>   | ||||
| 
 | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}"> | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 4320" [class.active]="radioGroupForm.get('dateSpan').value === '1m'"> | ||||
|           <input type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]" formControlName="dateSpan"> 1M | ||||
|         </label> | ||||
|  | ||||
| @ -78,3 +78,8 @@ | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .disabled { | ||||
|   pointer-events: none; | ||||
|   opacity: 0.5; | ||||
| } | ||||
| @ -1,19 +1,17 @@ | ||||
| import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; | ||||
| import { EChartsOption, graphic } from 'echarts'; | ||||
| import { Observable, Subscription } from 'rxjs'; | ||||
| import { Observable } from 'rxjs'; | ||||
| import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { formatCurrency, formatNumber, getCurrencySymbol } from '@angular/common'; | ||||
| import { formatNumber } from '@angular/common'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; | ||||
| import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { download, formatterXAxis } from '../../shared/graphs.utils'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| import { MiningService } from '../../services/mining.service'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { FiatShortenerPipe } from '../../shared/pipes/fiat-shortener.pipe'; | ||||
| import { FiatCurrencyPipe } from '../../shared/pipes/fiat-currency.pipe'; | ||||
| import { fiatCurrencies } from '../../app.constants'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-block-fees-graph', | ||||
| @ -47,7 +45,6 @@ export class BlockFeesGraphComponent implements OnInit { | ||||
|   timespan = ''; | ||||
|   chartInstance: any = undefined; | ||||
| 
 | ||||
|   currencySubscription: Subscription; | ||||
|   currency: string; | ||||
| 
 | ||||
|   constructor( | ||||
| @ -57,21 +54,13 @@ export class BlockFeesGraphComponent implements OnInit { | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private storageService: StorageService, | ||||
|     private miningService: MiningService, | ||||
|     private stateService: StateService, | ||||
|     private route: ActivatedRoute, | ||||
|     private fiatShortenerPipe: FiatShortenerPipe, | ||||
|     private fiatCurrencyPipe: FiatCurrencyPipe, | ||||
|   ) { | ||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); | ||||
|     this.radioGroupForm.controls.dateSpan.setValue('1y'); | ||||
| 
 | ||||
|     this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => { | ||||
|       if (fiat && fiatCurrencies[fiat]?.indexed) { | ||||
|         this.currency = fiat; | ||||
|       } else { | ||||
|         this.currency = 'USD'; | ||||
|       } | ||||
|     }); | ||||
|     this.currency = 'USD'; | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
| @ -92,6 +81,7 @@ export class BlockFeesGraphComponent implements OnInit { | ||||
|       .pipe( | ||||
|         startWith(this.radioGroupForm.controls.dateSpan.value), | ||||
|         switchMap((timespan) => { | ||||
|           this.isLoading = true; | ||||
|           this.storageService.setValue('miningWindowPreference', timespan); | ||||
|           this.timespan = timespan; | ||||
|           this.isLoading = true; | ||||
|  | ||||
| @ -10,5 +10,6 @@ | ||||
|     [cursorPosition]="tooltipPosition" | ||||
|     [clickable]="!!selectedTx" | ||||
|     [auditEnabled]="auditHighlighting" | ||||
|     [blockConversion]="blockConversion" | ||||
|   ></app-block-overview-tooltip> | ||||
| </div> | ||||
|  | ||||
| @ -5,6 +5,7 @@ import BlockScene from './block-scene'; | ||||
| import TxSprite from './tx-sprite'; | ||||
| import TxView from './tx-view'; | ||||
| import { Position } from './sprite-types'; | ||||
| import { Price } from '../../services/price.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-block-overview-graph', | ||||
| @ -21,6 +22,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|   @Input() mirrorTxid: string | void; | ||||
|   @Input() unavailable: boolean = false; | ||||
|   @Input() auditHighlighting: boolean = false; | ||||
|   @Input() blockConversion: Price; | ||||
|   @Output() txClickEvent = new EventEmitter<TransactionStripped>(); | ||||
|   @Output() txHoverEvent = new EventEmitter<string>(); | ||||
|   @Output() readyEvent = new EventEmitter(); | ||||
|  | ||||
| @ -16,11 +16,11 @@ | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td class="td-width" i18n="dashboard.latest-transactions.amount">Amount</td> | ||||
|         <td><app-amount [satoshis]="value"></app-amount></td> | ||||
|         <td><app-amount [blockConversion]="blockConversion" [satoshis]="value" [noFiat]="true"></app-amount></td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td> | ||||
|         <td>{{ fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span>   <span class="fiat"><app-fiat [value]="fee"></app-fiat></span></td> | ||||
|         <td>{{ fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span>   <span class="fiat"><app-fiat [blockConversion]="blockConversion" [value]="fee"></app-fiat></span></td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td class="td-width" i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td> | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| import { Component, ElementRef, ViewChild, Input, OnChanges, ChangeDetectionStrategy } from '@angular/core'; | ||||
| import { TransactionStripped } from '../../interfaces/websocket.interface'; | ||||
| import { Position } from '../../components/block-overview-graph/sprite-types.js'; | ||||
| import { Price } from '../../services/price.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-block-overview-tooltip', | ||||
| @ -12,6 +13,7 @@ export class BlockOverviewTooltipComponent implements OnChanges { | ||||
|   @Input() cursorPosition: Position; | ||||
|   @Input() clickable: boolean; | ||||
|   @Input() auditEnabled: boolean = false; | ||||
|   @Input() blockConversion: Price; | ||||
| 
 | ||||
|   txid = ''; | ||||
|   fee = 0; | ||||
|  | ||||
| @ -10,7 +10,7 @@ | ||||
|     </div>   | ||||
| 
 | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}"> | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 144" [class.active]="radioGroupForm.get('dateSpan').value === '24h'"> | ||||
|           <input type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 24h | ||||
|         </label> | ||||
|  | ||||
| @ -78,3 +78,8 @@ | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .disabled { | ||||
|   pointer-events: none; | ||||
|   opacity: 0.5; | ||||
| } | ||||
| @ -11,7 +11,7 @@ | ||||
|     </div>   | ||||
|    | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}"> | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 4320" [class.active]="radioGroupForm.get('dateSpan').value === '1m'"> | ||||
|           <input type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" formControlName="dateSpan"> 1M | ||||
|         </label> | ||||
| @ -31,7 +31,7 @@ | ||||
|           <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" formControlName="dateSpan"> 3Y | ||||
|         </label> | ||||
|         <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === 'all'"> | ||||
|           <input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> ALL | ||||
|           <input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" formControlName="dateSpan"> ALL | ||||
|         </label> | ||||
|       </div> | ||||
|     </form> | ||||
|  | ||||
| @ -78,3 +78,8 @@ | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .disabled { | ||||
|   pointer-events: none; | ||||
|   opacity: 0.5; | ||||
| } | ||||
| @ -1,19 +1,17 @@ | ||||
| import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; | ||||
| import { EChartsOption, graphic } from 'echarts'; | ||||
| import { Observable, Subscription } from 'rxjs'; | ||||
| import { Observable } from 'rxjs'; | ||||
| import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { formatNumber } from '@angular/common'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; | ||||
| import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils'; | ||||
| import { download, formatterXAxis } from '../../shared/graphs.utils'; | ||||
| import { MiningService } from '../../services/mining.service'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { FiatShortenerPipe } from '../../shared/pipes/fiat-shortener.pipe'; | ||||
| import { FiatCurrencyPipe } from '../../shared/pipes/fiat-currency.pipe'; | ||||
| import { fiatCurrencies } from '../../app.constants'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-block-rewards-graph', | ||||
| @ -47,7 +45,6 @@ export class BlockRewardsGraphComponent implements OnInit { | ||||
|   timespan = ''; | ||||
|   chartInstance: any = undefined; | ||||
| 
 | ||||
|   currencySubscription: Subscription; | ||||
|   currency: string; | ||||
| 
 | ||||
|   constructor( | ||||
| @ -56,19 +53,12 @@ export class BlockRewardsGraphComponent implements OnInit { | ||||
|     private apiService: ApiService, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private miningService: MiningService, | ||||
|     private stateService: StateService, | ||||
|     private storageService: StorageService, | ||||
|     private route: ActivatedRoute, | ||||
|     private fiatShortenerPipe: FiatShortenerPipe, | ||||
|     private fiatCurrencyPipe: FiatCurrencyPipe, | ||||
|   ) { | ||||
|     this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => { | ||||
|       if (fiat && fiatCurrencies[fiat]?.indexed) { | ||||
|         this.currency = fiat; | ||||
|       } else { | ||||
|         this.currency = 'USD'; | ||||
|       } | ||||
|     }); | ||||
|     this.currency = 'USD'; | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
| @ -80,7 +70,7 @@ export class BlockRewardsGraphComponent implements OnInit { | ||||
|     this.route | ||||
|       .fragment | ||||
|       .subscribe((fragment) => { | ||||
|         if (['3m', '6m', '1y', '2y', '3y', 'all'].indexOf(fragment) > -1) { | ||||
|         if (['1m', '3m', '6m', '1y', '2y', '3y', 'all'].indexOf(fragment) > -1) { | ||||
|           this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); | ||||
|         } | ||||
|       }); | ||||
|  | ||||
| @ -9,7 +9,7 @@ | ||||
|     </div>   | ||||
| 
 | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(blockSizesWeightsObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}"> | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 144" [class.active]="radioGroupForm.get('dateSpan').value === '24h'"> | ||||
|           <input type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 24h | ||||
|         </label> | ||||
|  | ||||
| @ -78,3 +78,8 @@ | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .disabled { | ||||
|   pointer-events: none; | ||||
|   opacity: 0.5; | ||||
| } | ||||
| @ -8,7 +8,7 @@ | ||||
|         <div class="block-titles"> | ||||
|           <h1 class="title"> | ||||
|             <ng-template [ngIf]="blockHeight === 0"><ng-container i18n="@@2303359202781425764">Genesis</ng-container></ng-template> | ||||
|             <ng-template [ngIf]="blockHeight" i18n="shared.block-title">{{ blockHeight }}</ng-template> | ||||
|             <ng-template [ngIf]="blockHeight">{{ blockHeight }}</ng-template> | ||||
|           </h1> | ||||
|           <div class="blockhash" *ngIf="blockHash"> | ||||
|             <h2 class="truncate right">{{ blockHash.slice(0,32) }}</h2> | ||||
|  | ||||
| @ -108,6 +108,7 @@ | ||||
|             [blockLimit]="stateService.blockVSize" | ||||
|             [orientation]="'top'" | ||||
|             [flip]="false" | ||||
|             [blockConversion]="blockConversion" | ||||
|             (txClickEvent)="onTxClick($event)" | ||||
|           ></app-block-overview-graph> | ||||
|           <ng-container *ngTemplateOutlet="emptyBlockInfo"></ng-container> | ||||
| @ -124,7 +125,13 @@ | ||||
|       </tr> | ||||
|       <tr *ngIf="block?.extras?.medianFee != undefined"> | ||||
|         <td class="td-width" i18n="block.median-fee">Median fee</td> | ||||
|         <td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td> | ||||
|         <td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> | ||||
|           <span class="fiat"> | ||||
|             <app-fiat [blockConversion]="blockConversion" [value]="block?.extras?.medianFee * 140" digitsInfo="1.2-2" | ||||
|               i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" | ||||
|               placement="bottom"></app-fiat> | ||||
|           </span> | ||||
|         </td> | ||||
|       </tr> | ||||
|       <ng-template [ngIf]="fees !== undefined" [ngIfElse]="loadingFees"> | ||||
|         <tr> | ||||
| @ -132,13 +139,13 @@ | ||||
|           <td *ngIf="network !== 'liquid' && network !== 'liquidtestnet'; else liquidTotalFees"> | ||||
|             <app-amount [satoshis]="block.extras.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount> | ||||
|             <span class="fiat"> | ||||
|               <app-fiat [value]="block.extras.totalFees" digitsInfo="1.0-0"></app-fiat> | ||||
|               <app-fiat [blockConversion]="blockConversion" [value]="block.extras.totalFees" digitsInfo="1.0-0"></app-fiat> | ||||
|             </span> | ||||
|           </td> | ||||
|           <ng-template #liquidTotalFees> | ||||
|             <td> | ||||
|               <app-amount [satoshis]="fees * 100000000" digitsInfo="1.2-2" [noFiat]="true"></app-amount>  <app-fiat | ||||
|                 [value]="fees * 100000000" digitsInfo="1.2-2"></app-fiat> | ||||
|               [blockConversion]="blockConversion" [value]="fees * 100000000" digitsInfo="1.2-2"></app-fiat> | ||||
|             </td> | ||||
|           </ng-template> | ||||
|         </tr> | ||||
| @ -147,7 +154,7 @@ | ||||
|           <td> | ||||
|             <app-amount [satoshis]="block.extras.reward" digitsInfo="1.2-3" [noFiat]="true"></app-amount> | ||||
|             <span class="fiat"> | ||||
|               <app-fiat [value]="(blockSubsidy + fees) * 100000000" digitsInfo="1.0-0"></app-fiat> | ||||
|               <app-fiat [blockConversion]="blockConversion" [value]="(blockSubsidy + fees) * 100000000" digitsInfo="1.0-0"></app-fiat> | ||||
|             </span> | ||||
|           </td> | ||||
|         </tr> | ||||
|  | ||||
| @ -13,6 +13,7 @@ import { BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; | ||||
| import { detectWebGL } from '../../shared/graphs.utils'; | ||||
| import { PriceService, Price } from '../../services/price.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-block', | ||||
| @ -81,6 +82,9 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|   timeLtr: boolean; | ||||
|   childChangeSubscription: Subscription; | ||||
|   auditPrefSubscription: Subscription; | ||||
|    | ||||
|   priceSubscription: Subscription; | ||||
|   blockConversion: Price; | ||||
| 
 | ||||
|   @ViewChildren('blockGraphProjected') blockGraphProjected: QueryList<BlockOverviewGraphComponent>; | ||||
|   @ViewChildren('blockGraphActual') blockGraphActual: QueryList<BlockOverviewGraphComponent>; | ||||
| @ -94,7 +98,8 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|     private seoService: SeoService, | ||||
|     private websocketService: WebsocketService, | ||||
|     private relativeUrlPipe: RelativeUrlPipe, | ||||
|     private apiService: ApiService | ||||
|     private apiService: ApiService, | ||||
|     private priceService: PriceService, | ||||
|   ) { | ||||
|     this.webGlEnabled = detectWebGL(); | ||||
|   } | ||||
| @ -432,6 +437,19 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     if (this.priceSubscription) { | ||||
|       this.priceSubscription.unsubscribe(); | ||||
|     } | ||||
|     this.priceSubscription = block$.pipe( | ||||
|       switchMap((block) => { | ||||
|         return this.priceService.getBlockPrice$(block.timestamp).pipe( | ||||
|           tap((price) => { | ||||
|             this.blockConversion = price; | ||||
|           }) | ||||
|         ); | ||||
|       }) | ||||
|     ).subscribe(); | ||||
|   } | ||||
| 
 | ||||
|   ngAfterViewInit(): void { | ||||
| @ -453,6 +471,7 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|     this.auditSubscription?.unsubscribe(); | ||||
|     this.unsubscribeNextBlockSubscriptions(); | ||||
|     this.childChangeSubscription?.unsubscribe(); | ||||
|     this.priceSubscription?.unsubscribe(); | ||||
|   } | ||||
| 
 | ||||
|   unsubscribeNextBlockSubscriptions() { | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| <div class="blocks-container blockchain-blocks-container" [class.time-ltr]="timeLtr" | ||||
|   [style.left]="static ? (offset || 0) + 'px' : null" | ||||
|   *ngIf="(loadingBlocks$ | async) === false; else loadingBlocksTemplate"> | ||||
|   *ngIf="static || (loadingBlocks$ | async) === false; else loadingBlocksTemplate"> | ||||
|   <div *ngFor="let block of blocks; let i = index; trackBy: trackByBlocksFn"> | ||||
|     <ng-container *ngIf="block && !block.loading && !block.placeholder; else placeholderBlock"> | ||||
|     <ng-container *ngIf="connected && block && !block.loading && !block.placeholder; else placeholderBlock"> | ||||
|       <div [attr.data-cy]="'bitcoin-block-offset-' + offset + '-index-' + i" | ||||
|         class="text-center bitcoin-block mined-block blockchain-blocks-offset-{{ offset }}-index-{{ i }}" | ||||
|         id="bitcoin-block-{{ block.height }}" [ngStyle]="blockStyles[i]" | ||||
| @ -14,20 +14,26 @@ | ||||
|             }}</a> | ||||
|         </div> | ||||
|         <div class="block-body"> | ||||
|           <div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fees"> | ||||
|           <div *ngIf="block?.extras; else emptyfees" [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fees"> | ||||
|             ~{{ block?.extras?.medianFee | number:feeRounding }} <ng-container | ||||
|               i18n="shared.sat-vbyte|sat/vB">sat/vB</ng-container> | ||||
|           </div> | ||||
|           <ng-template #emptyfees> | ||||
|             <div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fees"> | ||||
|                 | ||||
|             </div> | ||||
|           </ng-template> | ||||
|           <div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-fee-span'" class="fee-span" | ||||
|             *ngIf="block?.extras?.feeRange"> | ||||
|             *ngIf="block?.extras?.feeRange; else emptyfeespan"> | ||||
|             {{ block?.extras?.feeRange?.[1] | number:feeRounding }} - {{ | ||||
|             block?.extras?.feeRange[block?.extras?.feeRange?.length - 1] | number:feeRounding }} <ng-container | ||||
|               i18n="shared.sat-vbyte|sat/vB">sat/vB</ng-container> | ||||
|           </div> | ||||
|           <div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-fee-span'" class="fee-span" | ||||
|             *ngIf="!block?.extras?.feeRange"> | ||||
|               | ||||
|           </div> | ||||
|           <ng-template #emptyfeespan> | ||||
|             <div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fee-span"> | ||||
|                 | ||||
|             </div> | ||||
|           </ng-template> | ||||
|           <div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-total-fees'" *ngIf="showMiningInfo" | ||||
|             class="block-size"> | ||||
|             <app-amount [satoshis]="block.extras?.totalFees ?? 0" digitsInfo="1.2-3" [noFiat]="true"></app-amount> | ||||
| @ -37,10 +43,8 @@ | ||||
|           <div [attr.data-cy]="'bitcoin-block-' + i + '-transactions'" class="transaction-count"> | ||||
|             <ng-container | ||||
|               *ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container> | ||||
|             <ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} | ||||
|               transaction</ng-template> | ||||
|             <ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} | ||||
|               transactions</ng-template> | ||||
|             <ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template> | ||||
|             <ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template> | ||||
|           </div> | ||||
|           <div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-time'" class="time-difference"> | ||||
|             <app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since></div> | ||||
| @ -53,19 +57,19 @@ | ||||
|       </div> | ||||
|     </ng-container> | ||||
|     <ng-template #placeholderBlock> | ||||
|       <ng-container *ngIf="block && block.placeholder; else loadingBlock"> | ||||
|       <ng-container *ngIf="block && block.placeholder && connected && !loadingTip; else loadingBlock"> | ||||
|         <div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i" | ||||
|           class="text-center bitcoin-block mined-block placeholder-block blockchain-blocks-{{ i }}" | ||||
|           id="bitcoin-block-{{ block.height }}" [ngStyle]="blockStyles[i]"> | ||||
| 
 | ||||
|   | ||||
|         </div> | ||||
|       </ng-container> | ||||
|     </ng-template> | ||||
|     <ng-template #loadingBlock> | ||||
|       <ng-container *ngIf="block && block.loading"> | ||||
|         <div class="flashing"> | ||||
|       <ng-container *ngIf="!connected || loadingTip || (block && block.loading)"> | ||||
|         <div class="flashing loading"> | ||||
|           <div class="text-center bitcoin-block mined-block" id="bitcoin-block-{{ block.height }}" | ||||
|             [ngStyle]="blockStyles[i]"></div> | ||||
|             [ngStyle]="convertStyleForLoadingBlock(blockStyles[i])"></div> | ||||
|         </div> | ||||
|       </ng-container> | ||||
|     </ng-template> | ||||
|  | ||||
| @ -137,6 +137,10 @@ | ||||
|   opacity: 1; | ||||
| } | ||||
| 
 | ||||
| .loading .bitcoin-block.mined-block { | ||||
|   background: #2d3348; | ||||
| } | ||||
| 
 | ||||
| @keyframes opacityPulse { | ||||
|   0% {opacity: 0.7;} | ||||
|   50% {opacity: 1.0;} | ||||
|  | ||||
| @ -22,6 +22,8 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { | ||||
|   @Input() offset: number = 0; | ||||
|   @Input() height: number = 0; | ||||
|   @Input() count: number = 8; | ||||
|   @Input() loadingTip: boolean = false; | ||||
|   @Input() connected: boolean = true; | ||||
|    | ||||
|   specialBlocks = specialBlocks; | ||||
|   network = ''; | ||||
| @ -288,6 +290,13 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   convertStyleForLoadingBlock(style) { | ||||
|     return { | ||||
|       ...style, | ||||
|       background: "#2d3348", | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   getStyleForLoadingBlock(index: number, animateEnterFrom: number = 0) { | ||||
|     const addLeft = animateEnterFrom || 0; | ||||
| 
 | ||||
|  | ||||
| @ -6,7 +6,7 @@ | ||||
|         <app-mempool-blocks [hidden]="pageIndex > 0"></app-mempool-blocks> | ||||
|         <app-blockchain-blocks [hidden]="pageIndex > 0"></app-blockchain-blocks> | ||||
|         <ng-container *ngFor="let page of pages; trackBy: trackByPageFn"> | ||||
|           <app-blockchain-blocks [static]="true" [offset]="page.offset" [height]="page.height" [count]="blocksPerPage"></app-blockchain-blocks> | ||||
|           <app-blockchain-blocks [static]="true" [offset]="page.offset" [height]="page.height" [count]="blocksPerPage" [loadingTip]="loadingTip" [connected]="connected"></app-blockchain-blocks> | ||||
|         </ng-container> | ||||
|       </div> | ||||
|       <div id="divider" [hidden]="pageIndex > 0"> | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, OnChanges, SimpleChanges } from '@angular/core'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| import { firstValueFrom, Subscription } from 'rxjs'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| 
 | ||||
| @Component({ | ||||
| @ -18,6 +18,9 @@ export class BlockchainComponent implements OnInit, OnDestroy { | ||||
|   timeLtrSubscription: Subscription; | ||||
|   timeLtr: boolean = this.stateService.timeLtr.value; | ||||
|   ltrTransitionEnabled = false; | ||||
|   connectionStateSubscription: Subscription; | ||||
|   loadingTip: boolean = true; | ||||
|   connected: boolean = true; | ||||
| 
 | ||||
|   constructor( | ||||
|     public stateService: StateService, | ||||
| @ -28,10 +31,17 @@ export class BlockchainComponent implements OnInit, OnDestroy { | ||||
|     this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { | ||||
|       this.timeLtr = !!ltr; | ||||
|     }); | ||||
|     this.connectionStateSubscription = this.stateService.connectionState$.subscribe(state => { | ||||
|       this.connected = (state === 2); | ||||
|     }) | ||||
|     firstValueFrom(this.stateService.chainTip$).then(tip => { | ||||
|       this.loadingTip = false; | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy() { | ||||
|     this.timeLtrSubscription.unsubscribe(); | ||||
|     this.connectionStateSubscription.unsubscribe(); | ||||
|   } | ||||
| 
 | ||||
|   trackByPageFn(index: number, item: { index: number }) { | ||||
|  | ||||
| @ -31,7 +31,7 @@ | ||||
|     </div>   | ||||
| 
 | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}"> | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 12960" [class.active]="radioGroupForm.get('dateSpan').value === '3m'"> | ||||
|           <input type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'3m'" formControlName="dateSpan"> 3M | ||||
|         </label> | ||||
|  | ||||
| @ -131,4 +131,9 @@ | ||||
|   display: block; | ||||
|   max-width: 80px; | ||||
|   margin: 15px auto 3px; | ||||
| } | ||||
| 
 | ||||
| .disabled { | ||||
|   pointer-events: none; | ||||
|   opacity: 0.5; | ||||
| } | ||||
| @ -11,7 +11,7 @@ | ||||
|     </div>   | ||||
| 
 | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}"> | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 25920" [class.active]="radioGroupForm.get('dateSpan').value === '6m'"> | ||||
|           <input type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'6m'" formControlName="dateSpan"> 6M | ||||
|         </label> | ||||
|  | ||||
| @ -82,3 +82,8 @@ | ||||
| .loadingGraphs.widget { | ||||
|   top: 75%; | ||||
| } | ||||
| 
 | ||||
| .disabled { | ||||
|   pointer-events: none; | ||||
|   opacity: 0.5; | ||||
| } | ||||
| @ -5,7 +5,7 @@ import { delay, map, retryWhen, share, startWith, switchMap, tap } from 'rxjs/op | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; | ||||
| import { poolsColor } from '../../app.constants'; | ||||
| import { chartColors, poolsColor } from '../../app.constants'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| import { MiningService } from '../../services/mining.service'; | ||||
| import { download } from '../../shared/graphs.utils'; | ||||
| @ -173,6 +173,7 @@ export class HashrateChartPoolsComponent implements OnInit { | ||||
|     this.chartOptions = { | ||||
|       title: title, | ||||
|       animation: false, | ||||
|       color: chartColors.filter(color => color !== '#FDD835'), | ||||
|       grid: { | ||||
|         right: this.right, | ||||
|         left: this.left, | ||||
|  | ||||
| @ -40,7 +40,7 @@ | ||||
|     </div>   | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" | ||||
|       *ngIf="!widget && (miningStatsObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}"> | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 144" [class.active]="radioGroupForm.get('dateSpan').value === '24h'"> | ||||
|           <input type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'24h'" formControlName="dateSpan"> 24h | ||||
|         </label> | ||||
| @ -92,6 +92,8 @@ | ||||
|           <th class="" i18n="mining.pool-name">Pool</th> | ||||
|           <th class="" *ngIf="this.miningWindowPreference === '24h'" i18n="mining.hashrate">Hashrate</th> | ||||
|           <th class="" i18n="master-page.blocks">Blocks</th> | ||||
|           <th *ngIf="auditAvailable" class="health text-right widget" i18n="latest-blocks.avg_health" | ||||
|             i18n-ngbTooltip="latest-blocks.avg_health" ngbTooltip="Avg Health" placement="bottom" #health [disableTooltip]="!isEllipsisActive(health)">Avg Health</th> | ||||
|           <th class="d-none d-md-block" i18n="mining.empty-blocks">Empty blocks</th> | ||||
|         </tr> | ||||
|       </thead> | ||||
| @ -102,9 +104,23 @@ | ||||
|             <img width="25" height="25" src="{{ pool.logo }}" [alt]="pool.name + ' mining pool logo'" onError="this.src = '/resources/mining-pools/default.svg'"> | ||||
|           </td> | ||||
|           <td class=""><a [routerLink]="[('/mining/pool/' + pool.slug) | relativeUrl]">{{ pool.name }}</a></td> | ||||
|           <td class="" *ngIf="this.miningWindowPreference === '24h' && !isLoading">{{ pool.lastEstimatedHashrate }} {{ | ||||
|           <td class="" *ngIf="this.miningWindowPreference === '24h'">{{ pool.lastEstimatedHashrate }} {{ | ||||
|             miningStats.miningUnits.hashrateUnit }}</td> | ||||
|           <td class="">{{ pool['blockText'] }}</td> | ||||
|           <td class="d-flex justify-content-center"> | ||||
|             {{ pool.blockCount }}<span class="d-none d-md-block"> ({{ pool.share }}%)</span> | ||||
|           </td> | ||||
|           <td *ngIf="auditAvailable" class="health text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"> | ||||
|             <a | ||||
|               class="health-badge badge" | ||||
|               [class.badge-success]="pool.avgMatchRate >= 99" | ||||
|               [class.badge-warning]="pool.avgMatchRate >= 75 && pool.avgMatchRate < 99" | ||||
|               [class.badge-danger]="pool.avgMatchRate < 75" | ||||
|               *ngIf="pool.avgMatchRate != null; else nullHealth" | ||||
|             >{{ pool.avgMatchRate }}%</a> | ||||
|             <ng-template #nullHealth> | ||||
|               <span class="health-badge badge badge-secondary" i18n="unknown">Unknown</span> | ||||
|             </ng-template> | ||||
|           </td> | ||||
|           <td class="d-none d-md-block">{{ pool.emptyBlocks }} ({{ pool.emptyBlockRatio }}%)</td> | ||||
|         </tr> | ||||
|         <tr style="border-top: 1px solid #555"> | ||||
|  | ||||
| @ -139,3 +139,8 @@ | ||||
|   max-width: 80px; | ||||
|   margin: 15px auto 3px; | ||||
| } | ||||
| 
 | ||||
| .disabled { | ||||
|   pointer-events: none; | ||||
|   opacity: 0.5; | ||||
| } | ||||
| @ -4,7 +4,6 @@ import { ActivatedRoute, Router } from '@angular/router'; | ||||
| import { EChartsOption, PieSeriesOption } from 'echarts'; | ||||
| import { concat, Observable } from 'rxjs'; | ||||
| import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; | ||||
| import { SinglePoolStats } from '../../interfaces/node-api.interface'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { StorageService } from '../..//services/storage.service'; | ||||
| import { MiningService, MiningStats } from '../../services/mining.service'; | ||||
| @ -26,6 +25,8 @@ export class PoolRankingComponent implements OnInit { | ||||
|   miningWindowPreference: string; | ||||
|   radioGroupForm: UntypedFormGroup; | ||||
| 
 | ||||
|   auditAvailable = false; | ||||
|   indexingAvailable = false; | ||||
|   isLoading = true; | ||||
|   chartOptions: EChartsOption = {}; | ||||
|   chartInitOptions = { | ||||
| @ -60,6 +61,10 @@ export class PoolRankingComponent implements OnInit { | ||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||
|     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); | ||||
| 
 | ||||
|     this.indexingAvailable = (this.stateService.env.BASE_MODULE === 'mempool' && | ||||
|       this.stateService.env.MINING_DASHBOARD === true); | ||||
|     this.auditAvailable = this.indexingAvailable && this.stateService.env.AUDIT; | ||||
| 
 | ||||
|     this.route | ||||
|       .fragment | ||||
|       .subscribe((fragment) => { | ||||
| @ -73,6 +78,7 @@ export class PoolRankingComponent implements OnInit { | ||||
|         .pipe( | ||||
|           startWith(this.radioGroupForm.controls.dateSpan.value), // (trigger when the page loads)
 | ||||
|           tap((value) => { | ||||
|             this.isLoading = true; | ||||
|             this.timespan = value; | ||||
|             if (!this.widget) { | ||||
|               this.storageService.setValue('miningWindowPreference', value); | ||||
| @ -92,7 +98,6 @@ export class PoolRankingComponent implements OnInit { | ||||
|       ) | ||||
|       .pipe( | ||||
|         map(data => { | ||||
|           data.pools = data.pools.map((pool: SinglePoolStats) => this.formatPoolUI(pool)); | ||||
|           data['minersLuck'] = (100 * (data.blockCount / 1008)).toFixed(2); // luck 1w
 | ||||
|           return data; | ||||
|         }), | ||||
| @ -104,11 +109,6 @@ export class PoolRankingComponent implements OnInit { | ||||
|       ); | ||||
|   } | ||||
| 
 | ||||
|   formatPoolUI(pool: SinglePoolStats) { | ||||
|     pool['blockText'] = pool.blockCount.toString() + ` (${pool.share}%)`; | ||||
|     return pool; | ||||
|   } | ||||
| 
 | ||||
|   generatePoolsChartSerieData(miningStats) { | ||||
|     let poolShareThreshold = 0.5; | ||||
|     if (isMobile()) { | ||||
| @ -219,7 +219,7 @@ export class PoolRankingComponent implements OnInit { | ||||
| 
 | ||||
|     this.chartOptions = { | ||||
|       animation: false, | ||||
|       color: chartColors, | ||||
|       color: chartColors.filter(color => color !== '#FDD835'), | ||||
|       tooltip: { | ||||
|         trigger: 'item', | ||||
|         textStyle: { | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| <div class="holder" [ngStyle]="{'width': size, 'height': size}"> | ||||
|   <img *ngIf="imageUrl" [src]="imageUrl"> | ||||
|   <canvas #canvas></canvas> | ||||
|   <canvas #canvas [style]="{'border': border + 'px solid white'}"></canvas> | ||||
| </div> | ||||
|  | ||||
| @ -12,6 +12,7 @@ export class QrcodeComponent implements AfterViewInit { | ||||
|   @Input() data: string; | ||||
|   @Input() size = 125; | ||||
|   @Input() imageUrl: string; | ||||
|   @Input() border = 0; | ||||
|   @ViewChild('canvas') canvas: ElementRef; | ||||
| 
 | ||||
|   qrcodeObject: any; | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user