Merge branch 'master' into update_gha
This commit is contained in:
		
						commit
						33775f32e2
					
				
							
								
								
									
										8
									
								
								.github/workflows/on-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/on-tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -68,24 +68,24 @@ jobs: | ||||
|         run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin | ||||
| 
 | ||||
|       - name: Checkout project | ||||
|         uses: actions/checkout@629c2de402a417ea7690ca6ce3f33229e27606a5 # v2 | ||||
|         uses: actions/checkout@e2f20e631ae6d7dd3b768f56a5d2af784dd54791 # v2.5.0 | ||||
| 
 | ||||
|       - name: Init repo for Dockerization | ||||
|         run: docker/init.sh "$TAG" | ||||
| 
 | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@27d0a4f181a40b142cce983c5393082c365d1480 # v1 | ||||
|         uses: docker/setup-qemu-action@e81a89b1732b9c48d79cd809d8d81d79c4647a18 # v2.1.0 | ||||
|         id: qemu | ||||
| 
 | ||||
|       - name: Setup Docker buildx action | ||||
|         uses: docker/setup-buildx-action@94ab11c41e45d028884a99163086648e898eed25 # v1 | ||||
|         uses: docker/setup-buildx-action@8c0edbc76e98fa90f69d9a2c020dcb50019dc325 # v2.2.1 | ||||
|         id: buildx | ||||
| 
 | ||||
|       - name: Available platforms | ||||
|         run: echo ${{ steps.buildx.outputs.platforms }} | ||||
| 
 | ||||
|       - name: Cache Docker layers | ||||
|         uses: actions/cache@661fd3eb7f2f20d8c7c84bc2b0509efd7a826628 # v2 | ||||
|         uses: actions/cache@9b0c1fce7a93df8e3bb8926b0d6e9d89e92f20a7 # v3.0.11 | ||||
|         id: cache | ||||
|         with: | ||||
|           path: /tmp/.buildx-cache | ||||
|  | ||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -3,3 +3,5 @@ data | ||||
| docker-compose.yml | ||||
| backend/mempool-config.json | ||||
| *.swp | ||||
| frontend/src/resources/config.template.js | ||||
| frontend/src/resources/config.js | ||||
|  | ||||
| @ -2,6 +2,7 @@ | ||||
|   "MEMPOOL": { | ||||
|     "NETWORK": "mainnet", | ||||
|     "BACKEND": "electrum", | ||||
|     "ENABLED": true, | ||||
|     "HTTP_PORT": 8999, | ||||
|     "SPAWN_CLUSTER_PROCS": 0, | ||||
|     "API_URL_PREFIX": "/api/v1/", | ||||
| @ -23,7 +24,9 @@ | ||||
|     "STDOUT_LOG_MIN_PRIORITY": "debug", | ||||
|     "AUTOMATIC_BLOCK_REINDEXING": false, | ||||
|     "POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json", | ||||
|     "POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master" | ||||
|     "POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master", | ||||
|     "ADVANCED_TRANSACTION_SELECTION": false, | ||||
|     "TRANSACTION_INDEXING": false | ||||
|   }, | ||||
|   "CORE_RPC": { | ||||
|     "HOST": "127.0.0.1", | ||||
| @ -80,7 +83,8 @@ | ||||
|     "BACKEND": "lnd", | ||||
|     "STATS_REFRESH_INTERVAL": 600, | ||||
|     "GRAPH_REFRESH_INTERVAL": 600, | ||||
|     "LOGGER_UPDATE_INTERVAL": 30 | ||||
|     "LOGGER_UPDATE_INTERVAL": 30, | ||||
|     "FORENSICS_INTERVAL": 43200 | ||||
|   }, | ||||
|   "LND": { | ||||
|     "TLS_CERT_PATH": "tls.cert", | ||||
|  | ||||
| @ -1,7 +1,9 @@ | ||||
| { | ||||
|   "MEMPOOL": { | ||||
|     "ENABLED": true, | ||||
|     "NETWORK": "__MEMPOOL_NETWORK__", | ||||
|     "BACKEND": "__MEMPOOL_BACKEND__", | ||||
|     "ENABLED": true, | ||||
|     "BLOCKS_SUMMARIES_INDEXING": true, | ||||
|     "HTTP_PORT": 1, | ||||
|     "SPAWN_CLUSTER_PROCS": 2, | ||||
| @ -23,7 +25,9 @@ | ||||
|     "STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__", | ||||
|     "INDEXING_BLOCKS_AMOUNT": 14, | ||||
|     "POOLS_JSON_TREE_URL": "__POOLS_JSON_TREE_URL__", | ||||
|     "POOLS_JSON_URL": "__POOLS_JSON_URL__" | ||||
|     "POOLS_JSON_URL": "__POOLS_JSON_URL__", | ||||
|     "ADVANCED_TRANSACTION_SELECTION": "__ADVANCED_TRANSACTION_SELECTION__", | ||||
|     "TRANSACTION_INDEXING": "__TRANSACTION_INDEXING__" | ||||
|   }, | ||||
|   "CORE_RPC": { | ||||
|     "HOST": "__CORE_RPC_HOST__", | ||||
| @ -95,7 +99,8 @@ | ||||
|     "TOPOLOGY_FOLDER": "__LIGHTNING_TOPOLOGY_FOLDER__", | ||||
|     "STATS_REFRESH_INTERVAL": 600, | ||||
|     "GRAPH_REFRESH_INTERVAL": 600, | ||||
|     "LOGGER_UPDATE_INTERVAL": 30 | ||||
|     "LOGGER_UPDATE_INTERVAL": 30, | ||||
|     "FORENSICS_INTERVAL": 43200 | ||||
|   }, | ||||
|   "LND": { | ||||
|     "TLS_CERT_PATH": "", | ||||
|  | ||||
| @ -13,6 +13,7 @@ describe('Mempool Backend Config', () => { | ||||
|       const config = jest.requireActual('../config').default; | ||||
| 
 | ||||
|       expect(config.MEMPOOL).toStrictEqual({ | ||||
|         ENABLED: true, | ||||
|         NETWORK: 'mainnet', | ||||
|         BACKEND: 'none', | ||||
|         BLOCKS_SUMMARIES_INDEXING: false, | ||||
| @ -36,7 +37,9 @@ 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.json', | ||||
|         ADVANCED_TRANSACTION_SELECTION: false, | ||||
|         TRANSACTION_INDEXING: false, | ||||
|       }); | ||||
| 
 | ||||
|       expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true }); | ||||
|  | ||||
| @ -1,13 +1,18 @@ | ||||
| import logger from '../logger'; | ||||
| import { BlockExtended, TransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces'; | ||||
| import config from '../config'; | ||||
| import bitcoinApi from './bitcoin/bitcoin-api-factory'; | ||||
| import { Common } from './common'; | ||||
| import { TransactionExtended, MempoolBlockWithTransactions, AuditScore } from '../mempool.interfaces'; | ||||
| import blocksRepository from '../repositories/BlocksRepository'; | ||||
| import blocksAuditsRepository from '../repositories/BlocksAuditsRepository'; | ||||
| import blocks from '../api/blocks'; | ||||
| 
 | ||||
| const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners
 | ||||
| 
 | ||||
| class Audit { | ||||
|   auditBlock(transactions: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended }) | ||||
|    : { censored: string[], added: string[], score: number } { | ||||
|    : { censored: string[], added: string[], fresh: string[], score: number } { | ||||
|     if (!projectedBlocks?.[0]?.transactionIds || !mempool) { | ||||
|       return { censored: [], added: [], score: 0 }; | ||||
|       return { censored: [], added: [], fresh: [], score: 0 }; | ||||
|     } | ||||
| 
 | ||||
|     const matches: string[] = []; // present in both mined block and template
 | ||||
| @ -44,8 +49,6 @@ class Audit { | ||||
| 
 | ||||
|     displacedWeight += (4000 - transactions[0].weight); | ||||
| 
 | ||||
|     logger.warn(`${fresh.length} fresh, ${Object.keys(isCensored).length} possibly censored, ${displacedWeight} displaced weight`); | ||||
| 
 | ||||
|     // we can expect an honest miner to include 'displaced' transactions in place of recent arrivals and censored txs
 | ||||
|     // these displaced transactions should occupy the first N weight units of the next projected block
 | ||||
|     let displacedWeightRemaining = displacedWeight; | ||||
| @ -73,20 +76,33 @@ class Audit { | ||||
| 
 | ||||
|     // mark unexpected transactions in the mined block as 'added'
 | ||||
|     let overflowWeight = 0; | ||||
|     let totalWeight = 0; | ||||
|     for (const tx of transactions) { | ||||
|       if (inTemplate[tx.txid]) { | ||||
|         matches.push(tx.txid); | ||||
|       } else { | ||||
|         if (!isDisplaced[tx.txid]) { | ||||
|           added.push(tx.txid); | ||||
|         } else { | ||||
|         } | ||||
|         let blockIndex = -1; | ||||
|         let index = -1; | ||||
|         projectedBlocks.forEach((block, bi) => { | ||||
|           const i = block.transactionIds.indexOf(tx.txid); | ||||
|           if (i >= 0) { | ||||
|             blockIndex = bi; | ||||
|             index = i; | ||||
|           } | ||||
|         }); | ||||
|         overflowWeight += tx.weight; | ||||
|       } | ||||
|       totalWeight += tx.weight; | ||||
|     } | ||||
| 
 | ||||
|     // transactions missing from near the end of our template are probably not being censored
 | ||||
|     let overflowWeightRemaining = overflowWeight; | ||||
|     let lastOverflowRate = 1.00; | ||||
|     let overflowWeightRemaining = overflowWeight - (config.MEMPOOL.BLOCK_WEIGHT_UNITS - totalWeight); | ||||
|     let maxOverflowRate = 0; | ||||
|     let rateThreshold = 0; | ||||
|     index = projectedBlocks[0].transactionIds.length - 1; | ||||
|     while (index >= 0) { | ||||
|       const txid = projectedBlocks[0].transactionIds[index]; | ||||
| @ -94,8 +110,11 @@ class Audit { | ||||
|         if (isCensored[txid]) { | ||||
|           delete isCensored[txid]; | ||||
|         } | ||||
|         lastOverflowRate = mempool[txid].effectiveFeePerVsize; | ||||
|       } else if (Math.floor(mempool[txid].effectiveFeePerVsize * 100) <= Math.ceil(lastOverflowRate * 100)) { // tolerance of 0.01 sat/vb
 | ||||
|         if (mempool[txid].effectiveFeePerVsize > maxOverflowRate) { | ||||
|           maxOverflowRate = mempool[txid].effectiveFeePerVsize; | ||||
|           rateThreshold = (Math.ceil(maxOverflowRate * 100) / 100) + 0.005; | ||||
|         } | ||||
|       } else if (mempool[txid].effectiveFeePerVsize <= rateThreshold) { // tolerance of 0.01 sat/vb + rounding
 | ||||
|         if (isCensored[txid]) { | ||||
|           delete isCensored[txid]; | ||||
|         } | ||||
| @ -110,6 +129,7 @@ class Audit { | ||||
|     return { | ||||
|       censored: Object.keys(isCensored), | ||||
|       added, | ||||
|       fresh, | ||||
|       score | ||||
|     }; | ||||
|   } | ||||
|  | ||||
| @ -3,13 +3,14 @@ import { IEsploraApi } from './esplora-api.interface'; | ||||
| export interface AbstractBitcoinApi { | ||||
|   $getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]>; | ||||
|   $getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean, lazyPrevouts?: boolean): Promise<IEsploraApi.Transaction>; | ||||
|   $getTransactionHex(txId: string): Promise<string>; | ||||
|   $getBlockHeightTip(): Promise<number>; | ||||
|   $getBlockHashTip(): Promise<string>; | ||||
|   $getTxIdsForBlock(hash: string): Promise<string[]>; | ||||
|   $getBlockHash(height: number): Promise<string>; | ||||
|   $getBlockHeader(hash: string): Promise<string>; | ||||
|   $getBlock(hash: string): Promise<IEsploraApi.Block>; | ||||
|   $getRawBlock(hash: string): Promise<string>; | ||||
|   $getRawBlock(hash: string): Promise<Buffer>; | ||||
|   $getAddress(address: string): Promise<IEsploraApi.Address>; | ||||
|   $getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>; | ||||
|   $getAddressPrefix(prefix: string): string[]; | ||||
|  | ||||
| @ -57,6 +57,11 @@ class BitcoinApi implements AbstractBitcoinApi { | ||||
|       }); | ||||
|   } | ||||
| 
 | ||||
|   $getTransactionHex(txId: string): Promise<string> { | ||||
|     return this.$getRawTransaction(txId, true) | ||||
|       .then((tx) => tx.hex || ''); | ||||
|   } | ||||
| 
 | ||||
|   $getBlockHeightTip(): Promise<number> { | ||||
|     return this.bitcoindClient.getChainTips() | ||||
|       .then((result: IBitcoinApi.ChainTips[]) => { | ||||
| @ -76,7 +81,7 @@ class BitcoinApi implements AbstractBitcoinApi { | ||||
|       .then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx); | ||||
|   } | ||||
| 
 | ||||
|   $getRawBlock(hash: string): Promise<string> { | ||||
|   $getRawBlock(hash: string): Promise<Buffer> { | ||||
|     return this.bitcoindClient.getBlock(hash, 0) | ||||
|       .then((raw: string) => Buffer.from(raw, "hex")); | ||||
|   } | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| import { Application, Request, Response } from 'express'; | ||||
| import axios from 'axios'; | ||||
| import * as bitcoinjs from 'bitcoinjs-lib'; | ||||
| import config from '../../config'; | ||||
| import websocketHandler from '../websocket-handler'; | ||||
| import mempool from '../mempool'; | ||||
| @ -16,13 +17,14 @@ import logger from '../../logger'; | ||||
| import blocks from '../blocks'; | ||||
| import bitcoinClient from './bitcoin-client'; | ||||
| import difficultyAdjustment from '../difficulty-adjustment'; | ||||
| import transactionRepository from '../../repositories/TransactionRepository'; | ||||
| 
 | ||||
| class BitcoinRoutes { | ||||
|   public initRoutes(app: Application) { | ||||
|     app | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'transaction-times', this.getTransactionTimes) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'outspends', this.$getBatchedOutspends) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'cpfp/:txId', this.getCpfpInfo) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'cpfp/:txId', this.$getCpfpInfo) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'difficulty-adjustment', this.getDifficultyChange) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'fees/recommended', this.getRecommendedFees) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'fees/mempool-blocks', this.getMempoolBlocks) | ||||
| @ -87,7 +89,9 @@ class BitcoinRoutes { | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'blocks', this.getBlocks.bind(this)) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', this.getBlocks.bind(this)) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', this.getBlock) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions); | ||||
|       .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) | ||||
|       ; | ||||
| 
 | ||||
|       if (config.MEMPOOL.BACKEND !== 'esplora') { | ||||
| @ -185,22 +189,20 @@ class BitcoinRoutes { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private getCpfpInfo(req: Request, res: Response) { | ||||
|   private async $getCpfpInfo(req: Request, res: Response) { | ||||
|     if (!/^[a-fA-F0-9]{64}$/.test(req.params.txId)) { | ||||
|       res.status(501).send(`Invalid transaction ID.`); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const tx = mempool.getMempool()[req.params.txId]; | ||||
|     if (!tx) { | ||||
|       res.status(404).send(`Transaction doesn't exist in the mempool.`); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (tx.cpfpChecked) { | ||||
|     if (tx) { | ||||
|       if (tx?.cpfpChecked) { | ||||
|         res.json({ | ||||
|           ancestors: tx.ancestors, | ||||
|           bestDescendant: tx.bestDescendant || null, | ||||
|           descendants: tx.descendants || null, | ||||
|           effectiveFeePerVsize: tx.effectiveFeePerVsize || null, | ||||
|         }); | ||||
|         return; | ||||
|       } | ||||
| @ -208,6 +210,15 @@ class BitcoinRoutes { | ||||
|       const cpfpInfo = Common.setRelativesAndGetCpfpInfo(tx, mempool.getMempool()); | ||||
| 
 | ||||
|       res.json(cpfpInfo); | ||||
|       return; | ||||
|     } else { | ||||
|       const cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId); | ||||
|       if (cpfpInfo) { | ||||
|         res.json(cpfpInfo); | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|     res.status(404).send(`Transaction has no CPFP info available.`); | ||||
|   } | ||||
| 
 | ||||
|   private getBackendInfo(req: Request, res: Response) { | ||||
| @ -241,6 +252,74 @@ class BitcoinRoutes { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Takes the PSBT as text/plain body, parses it, and adds the full | ||||
|    * parent transaction to each input that doesn't already have it. | ||||
|    * This is used for BTCPayServer / Trezor users which need access to | ||||
|    * the full parent transaction even with segwit inputs. | ||||
|    * It will respond with a text/plain PSBT in the same format (hex|base64). | ||||
|    */ | ||||
|   private async postPsbtCompletion(req: Request, res: Response): Promise<void> { | ||||
|     res.setHeader('content-type', 'text/plain'); | ||||
|     const notFoundError = `Couldn't get transaction hex for parent of input`; | ||||
|     try { | ||||
|       let psbt: bitcoinjs.Psbt; | ||||
|       let format: 'hex' | 'base64'; | ||||
|       let isModified = false; | ||||
|       try { | ||||
|         psbt = bitcoinjs.Psbt.fromBase64(req.body); | ||||
|         format = 'base64'; | ||||
|       } catch (e1) { | ||||
|         try { | ||||
|           psbt = bitcoinjs.Psbt.fromHex(req.body); | ||||
|           format = 'hex'; | ||||
|         } catch (e2) { | ||||
|           throw new Error(`Unable to parse PSBT`); | ||||
|         } | ||||
|       } | ||||
|       for (const [index, input] of psbt.data.inputs.entries()) { | ||||
|         if (!input.nonWitnessUtxo) { | ||||
|           // Buffer.from ensures it won't be modified in place by reverse()
 | ||||
|           const txid = Buffer.from(psbt.txInputs[index].hash) | ||||
|             .reverse() | ||||
|             .toString('hex'); | ||||
| 
 | ||||
|           let transactionHex: string; | ||||
|           // If missing transaction, return 404 status error
 | ||||
|           try { | ||||
|             transactionHex = await bitcoinApi.$getTransactionHex(txid); | ||||
|             if (!transactionHex) { | ||||
|               throw new Error(''); | ||||
|             } | ||||
|           } catch (err) { | ||||
|             throw new Error(`${notFoundError} #${index} @ ${txid}`); | ||||
|           } | ||||
| 
 | ||||
|           psbt.updateInput(index, { | ||||
|             nonWitnessUtxo: Buffer.from(transactionHex, 'hex'), | ||||
|           }); | ||||
|           if (!isModified) { | ||||
|             isModified = true; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       if (isModified) { | ||||
|         res.send(format === 'hex' ? psbt.toHex() : psbt.toBase64()); | ||||
|       } else { | ||||
|         // Not modified
 | ||||
|         // 422 Unprocessable Entity
 | ||||
|         // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422
 | ||||
|         res.status(422).send(`Psbt had no missing nonWitnessUtxos.`); | ||||
|       } | ||||
|     } catch (e: any) { | ||||
|       if (e instanceof Error && new RegExp(notFoundError).test(e.message)) { | ||||
|         res.status(404).send(e.message); | ||||
|       } else { | ||||
|         res.status(500).send(e instanceof Error ? e.message : e); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async getTransactionStatus(req: Request, res: Response) { | ||||
|     try { | ||||
|       const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true); | ||||
| @ -254,6 +333,16 @@ class BitcoinRoutes { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async getStrippedBlockTransactions(req: Request, res: Response) { | ||||
|     try { | ||||
|       const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); | ||||
|       res.json(transactions); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async getBlock(req: Request, res: Response) { | ||||
|     try { | ||||
|       const block = await blocks.$getBlock(req.params.hash); | ||||
| @ -286,9 +375,9 @@ class BitcoinRoutes { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async getStrippedBlockTransactions(req: Request, res: Response) { | ||||
|   private async getBlockAuditSummary(req: Request, res: Response) { | ||||
|     try { | ||||
|       const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); | ||||
|       const transactions = await blocks.$getBlockAuditSummary(req.params.hash); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); | ||||
|       res.json(transactions); | ||||
|     } catch (e) { | ||||
|  | ||||
| @ -20,6 +20,11 @@ class ElectrsApi implements AbstractBitcoinApi { | ||||
|       .then((response) => response.data); | ||||
|   } | ||||
| 
 | ||||
|   $getTransactionHex(txId: string): Promise<string> { | ||||
|     return axios.get<string>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex', this.axiosConfig) | ||||
|       .then((response) => response.data); | ||||
|   } | ||||
| 
 | ||||
|   $getBlockHeightTip(): Promise<number> { | ||||
|     return axios.get<number>(config.ESPLORA.REST_API_URL + '/blocks/tip/height', this.axiosConfig) | ||||
|       .then((response) => response.data); | ||||
| @ -50,9 +55,9 @@ class ElectrsApi implements AbstractBitcoinApi { | ||||
|       .then((response) => response.data); | ||||
|   } | ||||
| 
 | ||||
|   $getRawBlock(hash: string): Promise<string> { | ||||
|     return axios.get<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", this.axiosConfig) | ||||
|       .then((response) => response.data); | ||||
|   $getRawBlock(hash: string): Promise<Buffer> { | ||||
|     return axios.get<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", { ...this.axiosConfig, responseType: 'arraybuffer' }) | ||||
|       .then((response) => { return Buffer.from(response.data); }); | ||||
|   } | ||||
| 
 | ||||
|   $getAddress(address: string): Promise<IEsploraApi.Address> { | ||||
|  | ||||
| @ -21,10 +21,13 @@ import fiatConversion from './fiat-conversion'; | ||||
| import poolsParser from './pools-parser'; | ||||
| import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository'; | ||||
| import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository'; | ||||
| import cpfpRepository from '../repositories/CpfpRepository'; | ||||
| import transactionRepository from '../repositories/TransactionRepository'; | ||||
| import mining from './mining/mining'; | ||||
| import DifficultyAdjustmentsRepository from '../repositories/DifficultyAdjustmentsRepository'; | ||||
| import PricesRepository from '../repositories/PricesRepository'; | ||||
| import priceUpdater from '../tasks/price-updater'; | ||||
| import { Block } from 'bitcoinjs-lib'; | ||||
| 
 | ||||
| class Blocks { | ||||
|   private blocks: BlockExtended[] = []; | ||||
| @ -34,6 +37,7 @@ class Blocks { | ||||
|   private lastDifficultyAdjustmentTime = 0; | ||||
|   private previousDifficultyRetarget = 0; | ||||
|   private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = []; | ||||
|   private newAsyncBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => Promise<void>)[] = []; | ||||
| 
 | ||||
|   constructor() { } | ||||
| 
 | ||||
| @ -57,6 +61,10 @@ class Blocks { | ||||
|     this.newBlockCallbacks.push(fn); | ||||
|   } | ||||
| 
 | ||||
|   public setNewAsyncBlockCallback(fn: (block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => Promise<void>) { | ||||
|     this.newAsyncBlockCallbacks.push(fn); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Return the list of transaction for a block | ||||
|    * @param blockHash | ||||
| @ -130,7 +138,7 @@ class Blocks { | ||||
|     const stripped = block.tx.map((tx) => { | ||||
|       return { | ||||
|         txid: tx.txid, | ||||
|         vsize: tx.vsize, | ||||
|         vsize: tx.weight / 4, | ||||
|         fee: tx.fee ? Math.round(tx.fee * 100000000) : 0, | ||||
|         value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0) * 100000000) | ||||
|       }; | ||||
| @ -195,9 +203,9 @@ class Blocks { | ||||
|         }; | ||||
|       } | ||||
| 
 | ||||
|       const auditSummary = await BlocksAuditsRepository.$getShortBlockAudit(block.id); | ||||
|       if (auditSummary) { | ||||
|         blockExtended.extras.matchRate = auditSummary.matchRate; | ||||
|       const auditScore = await BlocksAuditsRepository.$getBlockAuditScore(block.id); | ||||
|       if (auditScore != null) { | ||||
|         blockExtended.extras.matchRate = auditScore.matchRate; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
| @ -255,7 +263,7 @@ class Blocks { | ||||
|   /** | ||||
|    * [INDEXING] Index all blocks summaries for the block txs visualization | ||||
|    */ | ||||
|   public async $generateBlocksSummariesDatabase() { | ||||
|   public async $generateBlocksSummariesDatabase(): Promise<void> { | ||||
|     if (Common.blocksSummariesIndexingEnabled() === false) { | ||||
|       return; | ||||
|     } | ||||
| @ -311,6 +319,57 @@ class Blocks { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * [INDEXING] Index transaction CPFP data for all blocks | ||||
|    */ | ||||
|    public async $generateCPFPDatabase(): Promise<void> { | ||||
|     if (Common.cpfpIndexingEnabled() === false) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     try { | ||||
|       // Get all indexed block hash
 | ||||
|       const unindexedBlocks = await blocksRepository.$getCPFPUnindexedBlocks(); | ||||
| 
 | ||||
|       if (!unindexedBlocks?.length) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       // Logging
 | ||||
|       let count = 0; | ||||
|       let countThisRun = 0; | ||||
|       let timer = new Date().getTime() / 1000; | ||||
|       const startedAt = new Date().getTime() / 1000; | ||||
| 
 | ||||
|       for (const block of unindexedBlocks) { | ||||
|         // Logging
 | ||||
|         const elapsedSeconds = Math.max(1, new Date().getTime() / 1000 - timer); | ||||
|         if (elapsedSeconds > 5) { | ||||
|           const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); | ||||
|           const blockPerSeconds = Math.max(1, countThisRun / elapsedSeconds); | ||||
|           const progress = Math.round(count / unindexedBlocks.length * 10000) / 100; | ||||
|           logger.debug(`Indexing cpfp clusters for #${block.height} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${count}/${unindexedBlocks.length} (${progress}%) | elapsed: ${runningFor} seconds`); | ||||
|           timer = new Date().getTime() / 1000; | ||||
|           countThisRun = 0; | ||||
|         } | ||||
| 
 | ||||
|         await this.$indexCPFP(block.hash, block.height); // Calculate and save CPFP data for transactions in this block
 | ||||
| 
 | ||||
|         // Logging
 | ||||
|         count++; | ||||
|         countThisRun++; | ||||
|       } | ||||
|       if (count > 0) { | ||||
|         logger.notice(`CPFP indexing completed: indexed ${count} blocks`); | ||||
|       } else { | ||||
|         logger.debug(`CPFP indexing completed: indexed ${count} blocks`); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       logger.err(`CPFP indexing failed. Trying again in 10 seconds. Reason: ${(e instanceof Error ? e.message : e)}`); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * [INDEXING] Index all blocks metadata for the mining dashboard | ||||
|    */ | ||||
| @ -354,7 +413,7 @@ class Blocks { | ||||
|           } | ||||
|           ++indexedThisRun; | ||||
|           ++totalIndexed; | ||||
|           const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer)); | ||||
|           const elapsedSeconds = Math.max(1, new Date().getTime() / 1000 - timer); | ||||
|           if (elapsedSeconds > 5 || blockHeight === lastBlockToIndex) { | ||||
|             const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); | ||||
|             const blockPerSeconds = Math.max(1, indexedThisRun / elapsedSeconds); | ||||
| @ -444,6 +503,9 @@ class Blocks { | ||||
|       const blockExtended: BlockExtended = await this.$getBlockExtended(block, transactions); | ||||
|       const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock); | ||||
| 
 | ||||
|       // start async callbacks
 | ||||
|       const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, transactions)); | ||||
| 
 | ||||
|       if (Common.indexingEnabled()) { | ||||
|         if (!fastForwarded) { | ||||
|           const lastBlock = await blocksRepository.$getBlockByHeight(blockExtended.height - 1); | ||||
| @ -453,9 +515,13 @@ class Blocks { | ||||
|             await BlocksRepository.$deleteBlocksFrom(lastBlock['height'] - 10); | ||||
|             await HashratesRepository.$deleteLastEntries(); | ||||
|             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); | ||||
|               await this.$getStrippedBlockTransactions(newBlock.id, true, true); | ||||
|               if (config.MEMPOOL.TRANSACTION_INDEXING) { | ||||
|                 await this.$indexCPFP(newBlock.id, lastBlock['height'] - i); | ||||
|               } | ||||
|             } | ||||
|             await mining.$indexDifficultyAdjustments(); | ||||
|             await DifficultyAdjustmentsRepository.$deleteLastAdjustment(); | ||||
| @ -481,6 +547,9 @@ class Blocks { | ||||
|           if (Common.blocksSummariesIndexingEnabled() === true) { | ||||
|             await this.$getStrippedBlockTransactions(blockExtended.id, true); | ||||
|           } | ||||
|           if (config.MEMPOOL.TRANSACTION_INDEXING) { | ||||
|             this.$indexCPFP(blockExtended.id, this.currentBlockHeight); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
| @ -514,6 +583,9 @@ class Blocks { | ||||
|       if (!memPool.hasPriority()) { | ||||
|         diskCache.$saveCacheToDisk(); | ||||
|       } | ||||
| 
 | ||||
|       // wait for pending async callbacks to finish
 | ||||
|       await Promise.all(callbackPromises); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| @ -579,7 +651,7 @@ class Blocks { | ||||
|     if (skipMemoryCache === false) { | ||||
|       // Check the memory cache
 | ||||
|       const cachedSummary = this.getBlockSummaries().find((b) => b.id === hash); | ||||
|       if (cachedSummary) { | ||||
|       if (cachedSummary?.transactions?.length) { | ||||
|         return cachedSummary.transactions; | ||||
|       } | ||||
|     } | ||||
| @ -587,7 +659,7 @@ class Blocks { | ||||
|     // Check if it's indexed in db
 | ||||
|     if (skipDBLookup === false && Common.blocksSummariesIndexingEnabled() === true) { | ||||
|       const indexedSummary = await BlocksSummariesRepository.$getByBlockId(hash); | ||||
|       if (indexedSummary !== undefined) { | ||||
|       if (indexedSummary !== undefined && indexedSummary?.transactions?.length) { | ||||
|         return indexedSummary.transactions; | ||||
|       } | ||||
|     } | ||||
| @ -640,6 +712,22 @@ class Blocks { | ||||
|     return returnBlocks; | ||||
|   } | ||||
| 
 | ||||
|   public async $getBlockAuditSummary(hash: string): Promise<any> { | ||||
|     let summary; | ||||
|     if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { | ||||
|       summary = await BlocksAuditsRepository.$getBlockAudit(hash); | ||||
|     } | ||||
| 
 | ||||
|     // fallback to non-audited transaction summary
 | ||||
|     if (!summary?.transactions?.length) { | ||||
|       const strippedTransactions = await this.$getStrippedBlockTransactions(hash); | ||||
|       summary = { | ||||
|         transactions: strippedTransactions | ||||
|       }; | ||||
|     } | ||||
|     return summary; | ||||
|   } | ||||
| 
 | ||||
|   public getLastDifficultyAdjustmentTime(): number { | ||||
|     return this.lastDifficultyAdjustmentTime; | ||||
|   } | ||||
| @ -651,6 +739,62 @@ class Blocks { | ||||
|   public getCurrentBlockHeight(): number { | ||||
|     return this.currentBlockHeight; | ||||
|   } | ||||
| 
 | ||||
|   public async $indexCPFP(hash: string, height: number): Promise<void> { | ||||
|     let transactions; | ||||
|     if (false/*Common.blocksSummariesIndexingEnabled()*/) { | ||||
|       transactions = await this.$getStrippedBlockTransactions(hash); | ||||
|       const rawBlock = await bitcoinApi.$getRawBlock(hash); | ||||
|       const block = Block.fromBuffer(rawBlock); | ||||
|       const txMap = {}; | ||||
|       for (const tx of block.transactions || []) { | ||||
|         txMap[tx.getId()] = tx; | ||||
|       } | ||||
|       for (const tx of transactions) { | ||||
|         if (txMap[tx.txid]?.ins) { | ||||
|           tx.vin = txMap[tx.txid].ins.map(vin => { | ||||
|             return { | ||||
|               txid: vin.hash | ||||
|             }; | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|     } else { | ||||
|       const block = await bitcoinClient.getBlock(hash, 2); | ||||
|       transactions = block.tx.map(tx => { | ||||
|         tx.vsize = tx.weight / 4; | ||||
|         return tx; | ||||
|       }); | ||||
|     } | ||||
|   | ||||
|     let cluster: TransactionStripped[] = []; | ||||
|     let ancestors: { [txid: string]: boolean } = {}; | ||||
|     for (let i = transactions.length - 1; i >= 0; i--) { | ||||
|       const tx = transactions[i]; | ||||
|       if (!ancestors[tx.txid]) { | ||||
|         let totalFee = 0; | ||||
|         let totalVSize = 0; | ||||
|         cluster.forEach(tx => { | ||||
|           totalFee += tx?.fee || 0; | ||||
|           totalVSize += tx.vsize; | ||||
|         }); | ||||
|         const effectiveFeePerVsize = (totalFee * 100_000_000) / totalVSize; | ||||
|         if (cluster.length > 1) { | ||||
|           await cpfpRepository.$saveCluster(height, cluster.map(tx => { return { txid: tx.txid, weight: tx.vsize * 4, fee: (tx.fee || 0) * 100_000_000 }; }), effectiveFeePerVsize); | ||||
|           for (const tx of cluster) { | ||||
|             await transactionRepository.$setCluster(tx.txid, cluster[0].txid); | ||||
|           } | ||||
|         } | ||||
|         cluster = []; | ||||
|         ancestors = {}; | ||||
|       } | ||||
|       cluster.push(tx); | ||||
|       tx.vin.forEach(vin => { | ||||
|         ancestors[vin.txid] = true; | ||||
|       }); | ||||
|     } | ||||
|     await blocksRepository.$setCPFPIndexed(hash); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new Blocks(); | ||||
|  | ||||
| @ -187,6 +187,13 @@ export class Common { | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   static cpfpIndexingEnabled(): boolean { | ||||
|     return ( | ||||
|       Common.indexingEnabled() && | ||||
|       config.MEMPOOL.TRANSACTION_INDEXING === true | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   static setDateMidnight(date: Date): void { | ||||
|     date.setUTCHours(0); | ||||
|     date.setUTCMinutes(0); | ||||
|  | ||||
| @ -4,8 +4,8 @@ import logger from '../logger'; | ||||
| import { Common } from './common'; | ||||
| 
 | ||||
| class DatabaseMigration { | ||||
|   private static currentVersion = 41; | ||||
|   private queryTimeout = 120000; | ||||
|   private static currentVersion = 47; | ||||
|   private queryTimeout = 900_000; | ||||
|   private statisticsAddedIndexed = false; | ||||
|   private uniqueLogs: string[] = []; | ||||
| 
 | ||||
| @ -352,6 +352,33 @@ class DatabaseMigration { | ||||
|     if (databaseSchemaVersion < 41 && isBitcoin === true) { | ||||
|       await this.$executeQuery('UPDATE channels SET closing_reason = NULL WHERE closing_reason = 1'); | ||||
|     } | ||||
| 
 | ||||
|     if (databaseSchemaVersion < 42 && isBitcoin === true) { | ||||
|       await this.$executeQuery('ALTER TABLE `channels` ADD closing_resolved tinyint(1) DEFAULT 0'); | ||||
|     } | ||||
| 
 | ||||
|     if (databaseSchemaVersion < 43 && isBitcoin === true) { | ||||
|       await this.$executeQuery(this.getCreateLNNodeRecordsTableQuery(), await this.$checkIfTableExists('nodes_records')); | ||||
|     } | ||||
| 
 | ||||
|     if (databaseSchemaVersion < 44 && isBitcoin === true) { | ||||
|       await this.$executeQuery('TRUNCATE TABLE `blocks_audits`'); | ||||
|       await this.$executeQuery('UPDATE blocks_summaries SET template = NULL'); | ||||
|     } | ||||
| 
 | ||||
|     if (databaseSchemaVersion < 45 && isBitcoin === true) { | ||||
|       await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fresh_txs JSON DEFAULT "[]"'); | ||||
|     } | ||||
| 
 | ||||
|     if (databaseSchemaVersion < 46) { | ||||
|       await this.$executeQuery(`ALTER TABLE blocks MODIFY blockTimestamp timestamp NOT NULL DEFAULT 0`); | ||||
|     } | ||||
| 
 | ||||
|     if (databaseSchemaVersion < 47) { | ||||
|       await this.$executeQuery('ALTER TABLE `blocks` ADD cpfp_indexed tinyint(1) DEFAULT 0'); | ||||
|       await this.$executeQuery(this.getCreateCPFPTableQuery(), await this.$checkIfTableExists('cpfp_clusters')); | ||||
|       await this.$executeQuery(this.getCreateTransactionsTableQuery(), await this.$checkIfTableExists('transactions')); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|   /** | ||||
| @ -787,6 +814,38 @@ class DatabaseMigration { | ||||
|     ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
 | ||||
|   } | ||||
| 
 | ||||
|   private getCreateLNNodeRecordsTableQuery(): string { | ||||
|     return `CREATE TABLE IF NOT EXISTS nodes_records (
 | ||||
|       public_key varchar(66) NOT NULL, | ||||
|       type int(10) unsigned NOT NULL, | ||||
|       payload blob NOT NULL, | ||||
|       UNIQUE KEY public_key_type (public_key, type), | ||||
|       INDEX (public_key), | ||||
|       FOREIGN KEY (public_key) | ||||
|         REFERENCES nodes (public_key) | ||||
|         ON DELETE CASCADE | ||||
|     ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
 | ||||
|   } | ||||
| 
 | ||||
|   private getCreateCPFPTableQuery(): string { | ||||
|     return `CREATE TABLE IF NOT EXISTS cpfp_clusters (
 | ||||
|       root varchar(65) NOT NULL, | ||||
|       height int(10) NOT NULL, | ||||
|       txs JSON DEFAULT NULL, | ||||
|       fee_rate double unsigned NOT NULL, | ||||
|       PRIMARY KEY (root) | ||||
|     ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
 | ||||
|   } | ||||
| 
 | ||||
|   private getCreateTransactionsTableQuery(): string { | ||||
|     return `CREATE TABLE IF NOT EXISTS transactions (
 | ||||
|       txid varchar(65) NOT NULL, | ||||
|       cluster varchar(65) DEFAULT NULL, | ||||
|       PRIMARY KEY (txid), | ||||
|       FOREIGN KEY (cluster) REFERENCES cpfp_clusters (root) ON DELETE SET NULL | ||||
|     ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
 | ||||
|   } | ||||
| 
 | ||||
|   public async $truncateIndexedData(tables: string[]) { | ||||
|     const allowedTables = ['blocks', 'hashrates', 'prices']; | ||||
| 
 | ||||
|  | ||||
| @ -117,6 +117,17 @@ class ChannelsApi { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getUnresolvedClosedChannels(): Promise<any[]> { | ||||
|     try { | ||||
|       const query = `SELECT * FROM channels WHERE status = 2 AND closing_reason = 2 AND closing_resolved = 0 AND closing_transaction_id != ''`; | ||||
|       const [rows]: any = await DB.query(query); | ||||
|       return rows; | ||||
|     } catch (e) { | ||||
|       logger.err('$getUnresolvedClosedChannels error: ' + (e instanceof Error ? e.message : e)); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getChannelsWithoutCreatedDate(): Promise<any[]> { | ||||
|     try { | ||||
|       const query = `SELECT * FROM channels WHERE created IS NULL`; | ||||
|  | ||||
| @ -105,6 +105,18 @@ class NodesApi { | ||||
|         node.closed_channel_count = rows[0].closed_channel_count; | ||||
|       } | ||||
| 
 | ||||
|       // Custom records
 | ||||
|       query = ` | ||||
|         SELECT type, payload | ||||
|         FROM nodes_records | ||||
|         WHERE public_key = ? | ||||
|       `;
 | ||||
|       [rows] = await DB.query(query, [public_key]); | ||||
|       node.custom_records = {}; | ||||
|       for (const record of rows) { | ||||
|         node.custom_records[record.type] = Buffer.from(record.payload, 'binary').toString('hex'); | ||||
|       } | ||||
| 
 | ||||
|       return node; | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot get node information for ${public_key}. Reason: ${(e instanceof Error ? e.message : e)}`); | ||||
| @ -512,7 +524,37 @@ class NodesApi { | ||||
| 
 | ||||
|   public async $getNodesPerISP(ISPId: string) { | ||||
|     try { | ||||
|       const query = ` | ||||
|       let query = ` | ||||
|         SELECT channels.node1_public_key AS node1PublicKey, isp1.id as isp1ID, | ||||
|           channels.node2_public_key AS node2PublicKey, isp2.id as isp2ID | ||||
|         FROM channels | ||||
|         JOIN nodes node1 ON node1.public_key = channels.node1_public_key | ||||
|         JOIN nodes node2 ON node2.public_key = channels.node2_public_key | ||||
|         JOIN geo_names isp1 ON isp1.id = node1.as_number | ||||
|         JOIN geo_names isp2 ON isp2.id = node2.as_number | ||||
|         WHERE channels.status = 1 AND (node1.as_number IN (?) OR node2.as_number IN (?)) | ||||
|         ORDER BY short_id DESC | ||||
|       `;
 | ||||
| 
 | ||||
|       const IPSIds = ISPId.split(','); | ||||
|       const [rows]: any = await DB.query(query, [IPSIds, IPSIds]); | ||||
|       const nodes = {}; | ||||
| 
 | ||||
|       const intISPIds: number[] = []; | ||||
|       for (const ispId of IPSIds) { | ||||
|         intISPIds.push(parseInt(ispId, 10)); | ||||
|       } | ||||
| 
 | ||||
|       for (const channel of rows) { | ||||
|         if (intISPIds.includes(channel.isp1ID)) { | ||||
|           nodes[channel.node1PublicKey] = true; | ||||
|         } | ||||
|         if (intISPIds.includes(channel.isp2ID)) { | ||||
|           nodes[channel.node2PublicKey] = true; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       query = ` | ||||
|         SELECT nodes.public_key, CAST(COALESCE(nodes.capacity, 0) as INT) as capacity, CAST(COALESCE(nodes.channels, 0) as INT) as channels, | ||||
|           nodes.alias, UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at, | ||||
|           geo_names_city.names as city, geo_names_country.names as country, | ||||
| @ -523,17 +565,18 @@ class NodesApi { | ||||
|         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' | ||||
|         WHERE nodes.as_number IN (?) | ||||
|         WHERE nodes.public_key IN (?) | ||||
|         ORDER BY capacity DESC | ||||
|       `;
 | ||||
| 
 | ||||
|       const [rows]: any = await DB.query(query, [ISPId.split(',')]); | ||||
|       for (let i = 0; i < rows.length; ++i) { | ||||
|         rows[i].country = JSON.parse(rows[i].country); | ||||
|         rows[i].city = JSON.parse(rows[i].city); | ||||
|         rows[i].subdivision = JSON.parse(rows[i].subdivision); | ||||
|       const [rows2]: any = await DB.query(query, [Object.keys(nodes)]); | ||||
|       for (let i = 0; i < rows2.length; ++i) { | ||||
|         rows2[i].country = JSON.parse(rows2[i].country); | ||||
|         rows2[i].city = JSON.parse(rows2[i].city); | ||||
|         rows2[i].subdivision = JSON.parse(rows2[i].subdivision); | ||||
|       } | ||||
|       return rows; | ||||
|       return rows2; | ||||
| 
 | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot get nodes for ISP id ${ISPId}. Reason: ${e instanceof Error ? e.message : e}`); | ||||
|       throw e; | ||||
|  | ||||
| @ -7,6 +7,15 @@ import { Common } from '../../common'; | ||||
|  * Convert a clightning "listnode" entry to a lnd node entry | ||||
|  */ | ||||
| export function convertNode(clNode: any): ILightningApi.Node { | ||||
|   let custom_records: { [type: number]: string } | undefined = undefined; | ||||
|   if (clNode.option_will_fund) { | ||||
|     try { | ||||
|       custom_records = { '1': Buffer.from(clNode.option_will_fund.compact_lease || '', 'hex').toString('base64') }; | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot decode option_will_fund compact_lease for ${clNode.nodeid}). Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|       custom_records = undefined; | ||||
|     } | ||||
|   } | ||||
|   return { | ||||
|     alias: clNode.alias ?? '', | ||||
|     color: `#${clNode.color ?? ''}`, | ||||
| @ -23,6 +32,7 @@ export function convertNode(clNode: any): ILightningApi.Node { | ||||
|       }; | ||||
|     }) ?? [], | ||||
|     last_update: clNode?.last_timestamp ?? 0, | ||||
|     custom_records | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -49,6 +49,7 @@ export namespace ILightningApi { | ||||
|     }[]; | ||||
|     color: string; | ||||
|     features: { [key: number]: Feature }; | ||||
|     custom_records?: { [type: number]: string }; | ||||
|   } | ||||
| 
 | ||||
|   export interface Info { | ||||
|  | ||||
| @ -1,12 +1,17 @@ | ||||
| import logger from '../logger'; | ||||
| import { MempoolBlock, TransactionExtended, AuditTransaction, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor } from '../mempool.interfaces'; | ||||
| import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta } from '../mempool.interfaces'; | ||||
| import { Common } from './common'; | ||||
| import config from '../config'; | ||||
| import { PairingHeap } from '../utils/pairing-heap'; | ||||
| import { StaticPool } from 'node-worker-threads-pool'; | ||||
| import path from 'path'; | ||||
| 
 | ||||
| class MempoolBlocks { | ||||
|   private mempoolBlocks: MempoolBlockWithTransactions[] = []; | ||||
|   private mempoolBlockDeltas: MempoolBlockDelta[] = []; | ||||
|   private makeTemplatesPool = new StaticPool({ | ||||
|     size: 1, | ||||
|     task: path.resolve(__dirname, './tx-selection-worker.js'), | ||||
|   }); | ||||
| 
 | ||||
|   constructor() {} | ||||
| 
 | ||||
| @ -72,16 +77,15 @@ class MempoolBlocks { | ||||
|     const time = end - start; | ||||
|     logger.debug('Mempool blocks calculated in ' + time / 1000 + ' seconds'); | ||||
| 
 | ||||
|     const { blocks, deltas } = this.calculateMempoolBlocks(memPoolArray, this.mempoolBlocks); | ||||
|     const blocks = this.calculateMempoolBlocks(memPoolArray, this.mempoolBlocks); | ||||
|     const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, blocks); | ||||
| 
 | ||||
|     this.mempoolBlocks = blocks; | ||||
|     this.mempoolBlockDeltas = deltas; | ||||
|   } | ||||
| 
 | ||||
|   private calculateMempoolBlocks(transactionsSorted: TransactionExtended[], prevBlocks: MempoolBlockWithTransactions[]): | ||||
|     { blocks: MempoolBlockWithTransactions[], deltas: MempoolBlockDelta[] } { | ||||
|   private calculateMempoolBlocks(transactionsSorted: TransactionExtended[], prevBlocks: MempoolBlockWithTransactions[]): MempoolBlockWithTransactions[] { | ||||
|     const mempoolBlocks: MempoolBlockWithTransactions[] = []; | ||||
|     const mempoolBlockDeltas: MempoolBlockDelta[] = []; | ||||
|     let blockWeight = 0; | ||||
|     let blockSize = 0; | ||||
|     let transactions: TransactionExtended[] = []; | ||||
| @ -102,7 +106,11 @@ class MempoolBlocks { | ||||
|       mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length)); | ||||
|     } | ||||
| 
 | ||||
|     // Calculate change from previous block states
 | ||||
|     return mempoolBlocks; | ||||
|   } | ||||
| 
 | ||||
|   private calculateMempoolDeltas(prevBlocks: MempoolBlockWithTransactions[], mempoolBlocks: MempoolBlockWithTransactions[]): MempoolBlockDelta[] { | ||||
|     const mempoolBlockDeltas: MempoolBlockDelta[] = []; | ||||
|     for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) { | ||||
|       let added: TransactionStripped[] = []; | ||||
|       let removed: string[] = []; | ||||
| @ -135,284 +143,26 @@ class MempoolBlocks { | ||||
|         removed | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|       blocks: mempoolBlocks, | ||||
|       deltas: mempoolBlockDeltas | ||||
|     }; | ||||
|     return mempoolBlockDeltas; | ||||
|   } | ||||
| 
 | ||||
|   /* | ||||
|   * Build projected mempool blocks using an approximation of the transaction selection algorithm from Bitcoin Core | ||||
|   * (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp)
 | ||||
|   * | ||||
|   * blockLimit: number of blocks to build in total. | ||||
|   * weightLimit: maximum weight of transactions to consider using the selection algorithm. | ||||
|   *              if weightLimit is significantly lower than the mempool size, results may start to diverge from getBlockTemplate  | ||||
|   * condenseRest: whether to ignore excess transactions or append them to the final block. | ||||
|   */ | ||||
|   public makeBlockTemplates(mempool: { [txid: string]: TransactionExtended }, blockLimit: number, weightLimit: number | null = null, condenseRest = false): MempoolBlockWithTransactions[] { | ||||
|     const start = Date.now(); | ||||
|     const auditPool: { [txid: string]: AuditTransaction } = {}; | ||||
|     const mempoolArray: AuditTransaction[] = []; | ||||
|     const restOfArray: TransactionExtended[] = []; | ||||
|   public async makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, blockLimit: number, weightLimit: number | null = null, condenseRest = false): Promise<void> { | ||||
|     const { mempool, blocks } = await this.makeTemplatesPool.exec({ mempool: newMempool, blockLimit, weightLimit, condenseRest }); | ||||
|     const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, blocks); | ||||
| 
 | ||||
|     let weight = 0; | ||||
|     const maxWeight = weightLimit ? Math.max(4_000_000 * blockLimit, weightLimit) : Infinity; | ||||
|     // grab the top feerate txs up to maxWeight
 | ||||
|     Object.values(mempool).sort((a, b) => b.feePerVsize - a.feePerVsize).forEach(tx => { | ||||
|       weight += tx.weight; | ||||
|       if (weight >= maxWeight) { | ||||
|         restOfArray.push(tx); | ||||
|         return; | ||||
|       } | ||||
|       // initializing everything up front helps V8 optimize property access later
 | ||||
|       auditPool[tx.txid] = { | ||||
|         txid: tx.txid, | ||||
|         fee: tx.fee, | ||||
|         size: tx.size, | ||||
|         weight: tx.weight, | ||||
|         feePerVsize: tx.feePerVsize, | ||||
|         vin: tx.vin, | ||||
|         relativesSet: false, | ||||
|         ancestorMap: new Map<string, AuditTransaction>(), | ||||
|         children: new Set<AuditTransaction>(), | ||||
|         ancestorFee: 0, | ||||
|         ancestorWeight: 0, | ||||
|         score: 0, | ||||
|         used: false, | ||||
|         modified: false, | ||||
|         modifiedNode: null, | ||||
|       } | ||||
|       mempoolArray.push(auditPool[tx.txid]); | ||||
|     }) | ||||
| 
 | ||||
|     // Build relatives graph & calculate ancestor scores
 | ||||
|     for (const tx of mempoolArray) { | ||||
|       if (!tx.relativesSet) { | ||||
|         this.setRelatives(tx, auditPool); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // Sort by descending ancestor score
 | ||||
|     mempoolArray.sort((a, b) => (b.score || 0) - (a.score || 0)); | ||||
| 
 | ||||
|     // Build blocks by greedily choosing the highest feerate package
 | ||||
|     // (i.e. the package rooted in the transaction with the best ancestor score)
 | ||||
|     const blocks: MempoolBlockWithTransactions[] = []; | ||||
|     let blockWeight = 4000; | ||||
|     let blockSize = 0; | ||||
|     let transactions: AuditTransaction[] = []; | ||||
|     const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => (a.score || 0) > (b.score || 0)); | ||||
|     let overflow: AuditTransaction[] = []; | ||||
|     let failures = 0; | ||||
|     let top = 0; | ||||
|     while ((top < mempoolArray.length || !modified.isEmpty()) && (condenseRest || blocks.length < blockLimit)) { | ||||
|       // skip invalid transactions
 | ||||
|       while (top < mempoolArray.length && (mempoolArray[top].used || mempoolArray[top].modified)) { | ||||
|         top++; | ||||
|       } | ||||
| 
 | ||||
|       // Select best next package
 | ||||
|       let nextTx; | ||||
|       const nextPoolTx = mempoolArray[top]; | ||||
|       const nextModifiedTx = modified.peek(); | ||||
|       if (nextPoolTx && (!nextModifiedTx || (nextPoolTx.score || 0) > (nextModifiedTx.score || 0))) { | ||||
|         nextTx = nextPoolTx; | ||||
|         top++; | ||||
|       } else { | ||||
|         modified.pop(); | ||||
|         if (nextModifiedTx) { | ||||
|           nextTx = nextModifiedTx; | ||||
|           nextTx.modifiedNode = undefined; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       if (nextTx && !nextTx?.used) { | ||||
|         // Check if the package fits into this block
 | ||||
|         if (blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS) { | ||||
|           blockWeight += nextTx.ancestorWeight; | ||||
|           const ancestors: AuditTransaction[] = Array.from(nextTx.ancestorMap.values()); | ||||
|           // sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count)
 | ||||
|           const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx]; | ||||
|           const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4); | ||||
|           sortedTxSet.forEach((ancestor, i, arr) => { | ||||
|             const mempoolTx = mempool[ancestor.txid]; | ||||
|             if (ancestor && !ancestor?.used) { | ||||
|               ancestor.used = true; | ||||
|               // update original copy of this tx with effective fee rate & relatives data
 | ||||
|               mempoolTx.effectiveFeePerVsize = effectiveFeeRate; | ||||
|               mempoolTx.ancestors = (Array.from(ancestor.ancestorMap?.values()) as AuditTransaction[]).map((a) => { | ||||
|                 return { | ||||
|                   txid: a.txid, | ||||
|                   fee: a.fee, | ||||
|                   weight: a.weight, | ||||
|                 } | ||||
|               }) | ||||
|               if (i < arr.length - 1) { | ||||
|                 mempoolTx.bestDescendant = { | ||||
|                   txid: arr[arr.length - 1].txid, | ||||
|                   fee: arr[arr.length - 1].fee, | ||||
|                   weight: arr[arr.length - 1].weight, | ||||
|                 }; | ||||
|               } | ||||
|               transactions.push(ancestor); | ||||
|               blockSize += ancestor.size; | ||||
|     // copy CPFP info across to main thread's mempool
 | ||||
|     Object.keys(newMempool).forEach((txid) => { | ||||
|       if (newMempool[txid] && mempool[txid]) { | ||||
|         newMempool[txid].effectiveFeePerVsize = mempool[txid].effectiveFeePerVsize; | ||||
|         newMempool[txid].ancestors = mempool[txid].ancestors; | ||||
|         newMempool[txid].descendants = mempool[txid].descendants; | ||||
|         newMempool[txid].bestDescendant = mempool[txid].bestDescendant; | ||||
|         newMempool[txid].cpfpChecked = mempool[txid].cpfpChecked; | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|           // remove these as valid package ancestors for any descendants remaining in the mempool
 | ||||
|           if (sortedTxSet.length) { | ||||
|             sortedTxSet.forEach(tx => { | ||||
|               this.updateDescendants(tx, auditPool, modified); | ||||
|             }); | ||||
|           } | ||||
| 
 | ||||
|           failures = 0; | ||||
|         } else { | ||||
|           // hold this package in an overflow list while we check for smaller options
 | ||||
|           overflow.push(nextTx); | ||||
|           failures++; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       // this block is full
 | ||||
|       const exceededPackageTries = failures > 1000 && blockWeight > (config.MEMPOOL.BLOCK_WEIGHT_UNITS - 4000); | ||||
|       if (exceededPackageTries && (!condenseRest || blocks.length < blockLimit - 1)) { | ||||
|         // construct this block
 | ||||
|         if (transactions.length) { | ||||
|           blocks.push(this.dataToMempoolBlocks(transactions.map(t => mempool[t.txid]), blockSize, blockWeight, blocks.length)); | ||||
|         } | ||||
|         // reset for the next block
 | ||||
|         transactions = []; | ||||
|         blockSize = 0; | ||||
|         blockWeight = 4000; | ||||
| 
 | ||||
|         // 'overflow' packages didn't fit in this block, but are valid candidates for the next
 | ||||
|         for (const overflowTx of overflow.reverse()) { | ||||
|           if (overflowTx.modified) { | ||||
|             overflowTx.modifiedNode = modified.add(overflowTx); | ||||
|           } else { | ||||
|             top--; | ||||
|             mempoolArray[top] = overflowTx; | ||||
|           } | ||||
|         } | ||||
|         overflow = []; | ||||
|       } | ||||
|     } | ||||
|     if (condenseRest) { | ||||
|       // pack any leftover transactions into the last block
 | ||||
|       for (const tx of overflow) { | ||||
|         if (!tx || tx?.used) { | ||||
|           continue; | ||||
|         } | ||||
|         blockWeight += tx.weight; | ||||
|         blockSize += tx.size; | ||||
|         transactions.push(tx); | ||||
|         tx.used = true; | ||||
|       } | ||||
|       const blockTransactions = transactions.map(t => mempool[t.txid]) | ||||
|       restOfArray.forEach(tx => { | ||||
|         blockWeight += tx.weight; | ||||
|         blockSize += tx.size; | ||||
|         blockTransactions.push(tx); | ||||
|       }); | ||||
|       if (blockTransactions.length) { | ||||
|         blocks.push(this.dataToMempoolBlocks(blockTransactions, blockSize, blockWeight, blocks.length)); | ||||
|       } | ||||
|       transactions = []; | ||||
|     } else if (transactions.length) { | ||||
|       blocks.push(this.dataToMempoolBlocks(transactions.map(t => mempool[t.txid]), blockSize, blockWeight, blocks.length)); | ||||
|     } | ||||
| 
 | ||||
|     const end = Date.now(); | ||||
|     const time = end - start; | ||||
|     logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds'); | ||||
| 
 | ||||
|     return blocks; | ||||
|   } | ||||
| 
 | ||||
|   // traverse in-mempool ancestors
 | ||||
|   // recursion unavoidable, but should be limited to depth < 25 by mempool policy
 | ||||
|   public setRelatives( | ||||
|     tx: AuditTransaction, | ||||
|     mempool: { [txid: string]: AuditTransaction }, | ||||
|   ): void { | ||||
|     for (const parent of tx.vin) { | ||||
|       const parentTx = mempool[parent.txid]; | ||||
|       if (parentTx && !tx.ancestorMap!.has(parent.txid)) { | ||||
|         tx.ancestorMap.set(parent.txid, parentTx); | ||||
|         parentTx.children.add(tx); | ||||
|         // visit each node only once
 | ||||
|         if (!parentTx.relativesSet) { | ||||
|           this.setRelatives(parentTx, mempool); | ||||
|         } | ||||
|         parentTx.ancestorMap.forEach((ancestor) => { | ||||
|           tx.ancestorMap.set(ancestor.txid, ancestor); | ||||
|         }); | ||||
|       } | ||||
|     }; | ||||
|     tx.ancestorFee = tx.fee || 0; | ||||
|     tx.ancestorWeight = tx.weight || 0; | ||||
|     tx.ancestorMap.forEach((ancestor) => { | ||||
|       tx.ancestorFee += ancestor.fee; | ||||
|       tx.ancestorWeight += ancestor.weight; | ||||
|     }); | ||||
|     tx.score = tx.ancestorFee / (tx.ancestorWeight || 1); | ||||
|     tx.relativesSet = true; | ||||
|   } | ||||
| 
 | ||||
|   // iterate over remaining descendants, removing the root as a valid ancestor & updating the ancestor score
 | ||||
|   // avoids recursion to limit call stack depth
 | ||||
|   private updateDescendants( | ||||
|     rootTx: AuditTransaction, | ||||
|     mempool: { [txid: string]: AuditTransaction }, | ||||
|     modified: PairingHeap<AuditTransaction>, | ||||
|   ): void { | ||||
|     const descendantSet: Set<AuditTransaction> = new Set(); | ||||
|     // stack of nodes left to visit
 | ||||
|     const descendants: AuditTransaction[] = []; | ||||
|     let descendantTx; | ||||
|     let ancestorIndex; | ||||
|     let tmpScore; | ||||
|     rootTx.children.forEach(childTx => { | ||||
|       if (!descendantSet.has(childTx)) { | ||||
|         descendants.push(childTx); | ||||
|         descendantSet.add(childTx); | ||||
|       } | ||||
|     }); | ||||
|     while (descendants.length) { | ||||
|       descendantTx = descendants.pop(); | ||||
|       if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) { | ||||
|         // remove tx as ancestor
 | ||||
|         descendantTx.ancestorMap.delete(rootTx.txid); | ||||
|         descendantTx.ancestorFee -= rootTx.fee; | ||||
|         descendantTx.ancestorWeight -= rootTx.weight; | ||||
|         tmpScore = descendantTx.score; | ||||
|         descendantTx.score = descendantTx.ancestorFee / descendantTx.ancestorWeight; | ||||
| 
 | ||||
|         if (!descendantTx.modifiedNode) { | ||||
|           descendantTx.modified = true; | ||||
|           descendantTx.modifiedNode = modified.add(descendantTx); | ||||
|         } else { | ||||
|           // rebalance modified heap if score has changed
 | ||||
|           if (descendantTx.score < tmpScore) { | ||||
|             modified.decreasePriority(descendantTx.modifiedNode); | ||||
|           } else if (descendantTx.score > tmpScore) { | ||||
|             modified.increasePriority(descendantTx.modifiedNode); | ||||
|           } | ||||
|         } | ||||
| 
 | ||||
|         // add this node's children to the stack
 | ||||
|         descendantTx.children.forEach(childTx => { | ||||
|           // visit each node only once
 | ||||
|           if (!descendantSet.has(childTx)) { | ||||
|             descendants.push(childTx); | ||||
|             descendantSet.add(childTx); | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|     this.mempoolBlocks = blocks; | ||||
|     this.mempoolBlockDeltas = deltas; | ||||
|   } | ||||
| 
 | ||||
|   private dataToMempoolBlocks(transactions: TransactionExtended[], | ||||
|  | ||||
| @ -20,6 +20,8 @@ class Mempool { | ||||
|                                                     maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 }; | ||||
|   private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[], | ||||
|     deletedTransactions: TransactionExtended[]) => void) | undefined; | ||||
|   private asyncMempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[], | ||||
|     deletedTransactions: TransactionExtended[]) => void) | undefined; | ||||
| 
 | ||||
|   private txPerSecondArray: number[] = []; | ||||
|   private txPerSecond: number = 0; | ||||
| @ -63,6 +65,11 @@ class Mempool { | ||||
|     this.mempoolChangedCallback = fn; | ||||
|   } | ||||
| 
 | ||||
|   public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: TransactionExtended; }, | ||||
|     newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => Promise<void>) { | ||||
|     this.asyncMempoolChangedCallback = fn; | ||||
|   } | ||||
| 
 | ||||
|   public getMempool(): { [txid: string]: TransactionExtended } { | ||||
|     return this.mempoolCache; | ||||
|   } | ||||
| @ -72,6 +79,9 @@ class Mempool { | ||||
|     if (this.mempoolChangedCallback) { | ||||
|       this.mempoolChangedCallback(this.mempoolCache, [], []); | ||||
|     } | ||||
|     if (this.asyncMempoolChangedCallback) { | ||||
|       this.asyncMempoolChangedCallback(this.mempoolCache, [], []); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $updateMemPoolInfo() { | ||||
| @ -103,12 +113,11 @@ class Mempool { | ||||
|     return txTimes; | ||||
|   } | ||||
| 
 | ||||
|   public async $updateMempool() { | ||||
|     logger.debug('Updating mempool'); | ||||
|   public async $updateMempool(): Promise<void> { | ||||
|     logger.debug(`Updating mempool...`); | ||||
|     const start = new Date().getTime(); | ||||
|     let hasChange: boolean = false; | ||||
|     const currentMempoolSize = Object.keys(this.mempoolCache).length; | ||||
|     let txCount = 0; | ||||
|     const transactions = await bitcoinApi.$getRawMempool(); | ||||
|     const diff = transactions.length - currentMempoolSize; | ||||
|     const newTransactions: TransactionExtended[] = []; | ||||
| @ -124,7 +133,6 @@ class Mempool { | ||||
|         try { | ||||
|           const transaction = await transactionUtils.$getTransactionExtended(txid); | ||||
|           this.mempoolCache[txid] = transaction; | ||||
|           txCount++; | ||||
|           if (this.inSync) { | ||||
|             this.txPerSecondArray.push(new Date().getTime()); | ||||
|             this.vBytesPerSecondArray.push({ | ||||
| @ -133,14 +141,9 @@ class Mempool { | ||||
|             }); | ||||
|           } | ||||
|           hasChange = true; | ||||
|           if (diff > 0) { | ||||
|             logger.debug('Fetched transaction ' + txCount + ' / ' + diff); | ||||
|           } else { | ||||
|             logger.debug('Fetched transaction ' + txCount); | ||||
|           } | ||||
|           newTransactions.push(transaction); | ||||
|         } catch (e) { | ||||
|           logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e)); | ||||
|           logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e)); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
| @ -194,11 +197,13 @@ class Mempool { | ||||
|     if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) { | ||||
|       this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions); | ||||
|     } | ||||
|     if (this.asyncMempoolChangedCallback && (hasChange || deletedTransactions.length)) { | ||||
|       await this.asyncMempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions); | ||||
|     } | ||||
| 
 | ||||
|     const end = new Date().getTime(); | ||||
|     const time = end - start; | ||||
|     logger.debug(`New mempool size: ${Object.keys(this.mempoolCache).length} Change: ${diff}`); | ||||
|     logger.debug('Mempool updated in ' + time / 1000 + ' seconds'); | ||||
|     logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`); | ||||
|   } | ||||
| 
 | ||||
|   public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) { | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| 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'; | ||||
| @ -26,7 +27,11 @@ class MiningRoutes { | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments/:interval', this.$getDifficultyAdjustments) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/predictions/:interval', this.$getHistoricalBlockPrediction) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/scores', this.$getBlockAuditScores) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/scores/:height', this.$getBlockAuditScores) | ||||
|       .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) | ||||
|     ; | ||||
|   } | ||||
| 
 | ||||
| @ -252,6 +257,55 @@ class MiningRoutes { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getHeightFromTimestamp(req: Request, res: Response) { | ||||
|     try { | ||||
|       const timestamp = parseInt(req.params.timestamp, 10); | ||||
|       // This will prevent people from entering milliseconds etc.
 | ||||
|       // Block timestamps are allowed to be up to 2 hours off, so 24 hours
 | ||||
|       // will never put the maximum value before the most recent block
 | ||||
|       const nowPlus1day = Math.floor(Date.now() / 1000) + 60 * 60 * 24; | ||||
|       // Prevent non-integers that are not seconds
 | ||||
|       if (!/^[1-9][0-9]*$/.test(req.params.timestamp) || timestamp > nowPlus1day) { | ||||
|         throw new Error(`Invalid timestamp, value must be Unix seconds`); | ||||
|       } | ||||
|       const result = await BlocksRepository.$getBlockHeightFromTimestamp( | ||||
|         timestamp, | ||||
|       ); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); | ||||
|       res.json(result); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getBlockAuditScores(req: Request, res: Response) { | ||||
|     try { | ||||
|       let height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10); | ||||
|       if (height == null) { | ||||
|         height = await BlocksRepository.$mostRecentBlockHeight(); | ||||
|       } | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); | ||||
|       res.json(await BlocksAuditsRepository.$getBlockAuditScores(height, height - 15)); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getBlockAuditScore(req: Request, res: Response) { | ||||
|     try { | ||||
|       const audit = await BlocksAuditsRepository.$getBlockAuditScore(req.params.hash); | ||||
| 
 | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString()); | ||||
|       res.json(audit || 'null'); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new MiningRoutes(); | ||||
|  | ||||
| @ -14,10 +14,10 @@ interface Pool { | ||||
| class PoolsParser { | ||||
|   miningPools: any[] = []; | ||||
|   unknownPool: any = { | ||||
|     'name': "Unknown", | ||||
|     'link': "https://learnmeabitcoin.com/technical/coinbase-transaction", | ||||
|     'regexes': "[]", | ||||
|     'addresses': "[]", | ||||
|     'name': 'Unknown', | ||||
|     'link': 'https://learnmeabitcoin.com/technical/coinbase-transaction', | ||||
|     'regexes': '[]', | ||||
|     'addresses': '[]', | ||||
|     'slug': 'unknown' | ||||
|   }; | ||||
|   slugWarnFlag = false; | ||||
| @ -25,7 +25,7 @@ class PoolsParser { | ||||
|   /** | ||||
|    * Parse the pools.json file, consolidate the data and dump it into the database | ||||
|    */ | ||||
|   public async migratePoolsJson(poolsJson: object) { | ||||
|   public async migratePoolsJson(poolsJson: object): Promise<void> { | ||||
|     if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) { | ||||
|       return; | ||||
|     } | ||||
| @ -81,6 +81,7 @@ class PoolsParser { | ||||
|     // 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[] = []; | ||||
| @ -126,10 +127,28 @@ class PoolsParser { | ||||
|         if (!equals(JSON.parse(existingPool.addresses), poolObj.addresses) || !equals(JSON.parse(existingPool.regexes), poolObj.regexes)) { | ||||
|           finalPoolDataUpdate.push(poolObj); | ||||
|         } | ||||
|       } else { | ||||
|         // 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}`); | ||||
|         } else { | ||||
|           logger.debug(`Add '${finalPoolName}' mining pool`); | ||||
|           finalPoolDataAdd.push(poolObj); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       this.miningPools.push({ | ||||
|         'name': finalPoolName, | ||||
| @ -145,7 +164,9 @@ class PoolsParser { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (finalPoolDataAdd.length > 0 || finalPoolDataUpdate.length > 0) {     | ||||
|     if (finalPoolDataAdd.length > 0 || finalPoolDataUpdate.length > 0 || | ||||
|       finalPoolDataRename.length > 0 | ||||
|     ) {     | ||||
|       logger.debug(`Update pools table now`); | ||||
| 
 | ||||
|       // Add new mining pools into the database
 | ||||
| @ -169,8 +190,22 @@ class PoolsParser { | ||||
|         ;`);
 | ||||
|       } | ||||
| 
 | ||||
|       // 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 }); | ||||
| @ -178,6 +213,9 @@ class PoolsParser { | ||||
|         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'); | ||||
|       } catch (e) { | ||||
|  | ||||
							
								
								
									
										338
									
								
								backend/src/api/tx-selection-worker.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										338
									
								
								backend/src/api/tx-selection-worker.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,338 @@ | ||||
| import config from '../config'; | ||||
| import logger from '../logger'; | ||||
| import { TransactionExtended, MempoolBlockWithTransactions, AuditTransaction } from '../mempool.interfaces'; | ||||
| import { PairingHeap } from '../utils/pairing-heap'; | ||||
| import { Common } from './common'; | ||||
| import { parentPort } from 'worker_threads'; | ||||
| 
 | ||||
| if (parentPort) { | ||||
|   parentPort.on('message', (params: { mempool: { [txid: string]: TransactionExtended }, blockLimit: number, weightLimit: number | null, condenseRest: boolean}) => { | ||||
|     const { mempool, blocks } = makeBlockTemplates(params); | ||||
| 
 | ||||
|     // return the result to main thread.
 | ||||
|     if (parentPort) { | ||||
|      parentPort.postMessage({ mempool, blocks }); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| /* | ||||
| * Build projected mempool blocks using an approximation of the transaction selection algorithm from Bitcoin Core | ||||
| * (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp)
 | ||||
| * | ||||
| * blockLimit: number of blocks to build in total. | ||||
| * weightLimit: maximum weight of transactions to consider using the selection algorithm. | ||||
| *              if weightLimit is significantly lower than the mempool size, results may start to diverge from getBlockTemplate  | ||||
| * condenseRest: whether to ignore excess transactions or append them to the final block. | ||||
| */ | ||||
| function makeBlockTemplates({ mempool, blockLimit, weightLimit, condenseRest }: { mempool: { [txid: string]: TransactionExtended }, blockLimit: number, weightLimit?: number | null, condenseRest?: boolean | null }) | ||||
|   : { mempool: { [txid: string]: TransactionExtended }, blocks: MempoolBlockWithTransactions[] } { | ||||
|   const start = Date.now(); | ||||
|   const auditPool: { [txid: string]: AuditTransaction } = {}; | ||||
|   const mempoolArray: AuditTransaction[] = []; | ||||
|   const restOfArray: TransactionExtended[] = []; | ||||
|    | ||||
|   let weight = 0; | ||||
|   const maxWeight = weightLimit ? Math.max(4_000_000 * blockLimit, weightLimit) : Infinity; | ||||
|   // grab the top feerate txs up to maxWeight
 | ||||
|   Object.values(mempool).sort((a, b) => b.feePerVsize - a.feePerVsize).forEach(tx => { | ||||
|     weight += tx.weight; | ||||
|     if (weight >= maxWeight) { | ||||
|       restOfArray.push(tx); | ||||
|       return; | ||||
|     } | ||||
|     // initializing everything up front helps V8 optimize property access later
 | ||||
|     auditPool[tx.txid] = { | ||||
|       txid: tx.txid, | ||||
|       fee: tx.fee, | ||||
|       size: tx.size, | ||||
|       weight: tx.weight, | ||||
|       feePerVsize: tx.feePerVsize, | ||||
|       vin: tx.vin, | ||||
|       relativesSet: false, | ||||
|       ancestorMap: new Map<string, AuditTransaction>(), | ||||
|       children: new Set<AuditTransaction>(), | ||||
|       ancestorFee: 0, | ||||
|       ancestorWeight: 0, | ||||
|       score: 0, | ||||
|       used: false, | ||||
|       modified: false, | ||||
|       modifiedNode: null, | ||||
|     }; | ||||
|     mempoolArray.push(auditPool[tx.txid]); | ||||
|   }); | ||||
| 
 | ||||
|   // Build relatives graph & calculate ancestor scores
 | ||||
|   for (const tx of mempoolArray) { | ||||
|     if (!tx.relativesSet) { | ||||
|       setRelatives(tx, auditPool); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // Sort by descending ancestor score
 | ||||
|   mempoolArray.sort((a, b) => (b.score || 0) - (a.score || 0)); | ||||
| 
 | ||||
|   // Build blocks by greedily choosing the highest feerate package
 | ||||
|   // (i.e. the package rooted in the transaction with the best ancestor score)
 | ||||
|   const blocks: MempoolBlockWithTransactions[] = []; | ||||
|   let blockWeight = 4000; | ||||
|   let blockSize = 0; | ||||
|   let transactions: AuditTransaction[] = []; | ||||
|   const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => (a.score || 0) > (b.score || 0)); | ||||
|   let overflow: AuditTransaction[] = []; | ||||
|   let failures = 0; | ||||
|   let top = 0; | ||||
|   while ((top < mempoolArray.length || !modified.isEmpty()) && (condenseRest || blocks.length < blockLimit)) { | ||||
|     // skip invalid transactions
 | ||||
|     while (top < mempoolArray.length && (mempoolArray[top].used || mempoolArray[top].modified)) { | ||||
|       top++; | ||||
|     } | ||||
| 
 | ||||
|     // Select best next package
 | ||||
|     let nextTx; | ||||
|     const nextPoolTx = mempoolArray[top]; | ||||
|     const nextModifiedTx = modified.peek(); | ||||
|     if (nextPoolTx && (!nextModifiedTx || (nextPoolTx.score || 0) > (nextModifiedTx.score || 0))) { | ||||
|       nextTx = nextPoolTx; | ||||
|       top++; | ||||
|     } else { | ||||
|       modified.pop(); | ||||
|       if (nextModifiedTx) { | ||||
|         nextTx = nextModifiedTx; | ||||
|         nextTx.modifiedNode = undefined; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (nextTx && !nextTx?.used) { | ||||
|       // Check if the package fits into this block
 | ||||
|       if (blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS) { | ||||
|         blockWeight += nextTx.ancestorWeight; | ||||
|         const ancestors: AuditTransaction[] = Array.from(nextTx.ancestorMap.values()); | ||||
|         const descendants: AuditTransaction[] = []; | ||||
|         // sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count)
 | ||||
|         const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx]; | ||||
|         const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4); | ||||
| 
 | ||||
|         while (sortedTxSet.length) { | ||||
|           const ancestor = sortedTxSet.pop(); | ||||
|           const mempoolTx = mempool[ancestor.txid]; | ||||
|           if (ancestor && !ancestor?.used) { | ||||
|             ancestor.used = true; | ||||
|             // update original copy of this tx with effective fee rate & relatives data
 | ||||
|             mempoolTx.effectiveFeePerVsize = effectiveFeeRate; | ||||
|             mempoolTx.ancestors = sortedTxSet.map((a) => { | ||||
|               return { | ||||
|                 txid: a.txid, | ||||
|                 fee: a.fee, | ||||
|                 weight: a.weight, | ||||
|               }; | ||||
|             }).reverse(); | ||||
|             mempoolTx.descendants = descendants.map((a) => { | ||||
|               return { | ||||
|                 txid: a.txid, | ||||
|                 fee: a.fee, | ||||
|                 weight: a.weight, | ||||
|               }; | ||||
|             }); | ||||
|             descendants.push(ancestor); | ||||
|             mempoolTx.cpfpChecked = true; | ||||
|             transactions.push(ancestor); | ||||
|             blockSize += ancestor.size; | ||||
|           } | ||||
|         } | ||||
| 
 | ||||
|         // remove these as valid package ancestors for any descendants remaining in the mempool
 | ||||
|         if (sortedTxSet.length) { | ||||
|           sortedTxSet.forEach(tx => { | ||||
|             updateDescendants(tx, auditPool, modified); | ||||
|           }); | ||||
|         } | ||||
| 
 | ||||
|         failures = 0; | ||||
|       } else { | ||||
|         // hold this package in an overflow list while we check for smaller options
 | ||||
|         overflow.push(nextTx); | ||||
|         failures++; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // this block is full
 | ||||
|     const exceededPackageTries = failures > 1000 && blockWeight > (config.MEMPOOL.BLOCK_WEIGHT_UNITS - 4000); | ||||
|     const queueEmpty = top >= mempoolArray.length && modified.isEmpty(); | ||||
|     if ((exceededPackageTries || queueEmpty) && (!condenseRest || blocks.length < blockLimit - 1)) { | ||||
|       // construct this block
 | ||||
|       if (transactions.length) { | ||||
|         blocks.push(dataToMempoolBlocks(transactions.map(t => mempool[t.txid]), blockSize, blockWeight, blocks.length)); | ||||
|       } | ||||
|       // reset for the next block
 | ||||
|       transactions = []; | ||||
|       blockSize = 0; | ||||
|       blockWeight = 4000; | ||||
| 
 | ||||
|       // 'overflow' packages didn't fit in this block, but are valid candidates for the next
 | ||||
|       for (const overflowTx of overflow.reverse()) { | ||||
|         if (overflowTx.modified) { | ||||
|           overflowTx.modifiedNode = modified.add(overflowTx); | ||||
|         } else { | ||||
|           top--; | ||||
|           mempoolArray[top] = overflowTx; | ||||
|         } | ||||
|       } | ||||
|       overflow = []; | ||||
|     } | ||||
|   } | ||||
|   if (condenseRest) { | ||||
|     // pack any leftover transactions into the last block
 | ||||
|     for (const tx of overflow) { | ||||
|       if (!tx || tx?.used) { | ||||
|         continue; | ||||
|       } | ||||
|       blockWeight += tx.weight; | ||||
|       blockSize += tx.size; | ||||
|       const mempoolTx = mempool[tx.txid]; | ||||
|       // update original copy of this tx with effective fee rate & relatives data
 | ||||
|       mempoolTx.effectiveFeePerVsize = tx.score; | ||||
|       mempoolTx.ancestors = (Array.from(tx.ancestorMap?.values()) as AuditTransaction[]).map((a) => { | ||||
|         return { | ||||
|           txid: a.txid, | ||||
|           fee: a.fee, | ||||
|           weight: a.weight, | ||||
|         }; | ||||
|       }); | ||||
|       mempoolTx.bestDescendant = null; | ||||
|       mempoolTx.cpfpChecked = true; | ||||
|       transactions.push(tx); | ||||
|       tx.used = true; | ||||
|     } | ||||
|     const blockTransactions = transactions.map(t => mempool[t.txid]); | ||||
|     restOfArray.forEach(tx => { | ||||
|       blockWeight += tx.weight; | ||||
|       blockSize += tx.size; | ||||
|       tx.effectiveFeePerVsize = tx.feePerVsize; | ||||
|       tx.cpfpChecked = false; | ||||
|       tx.ancestors = []; | ||||
|       tx.bestDescendant = null; | ||||
|       blockTransactions.push(tx); | ||||
|     }); | ||||
|     if (blockTransactions.length) { | ||||
|       blocks.push(dataToMempoolBlocks(blockTransactions, blockSize, blockWeight, blocks.length)); | ||||
|     } | ||||
|     transactions = []; | ||||
|   } else if (transactions.length) { | ||||
|     blocks.push(dataToMempoolBlocks(transactions.map(t => mempool[t.txid]), blockSize, blockWeight, blocks.length)); | ||||
|   } | ||||
| 
 | ||||
|   const end = Date.now(); | ||||
|   const time = end - start; | ||||
|   logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds'); | ||||
| 
 | ||||
|   return { | ||||
|     mempool, | ||||
|     blocks | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| // traverse in-mempool ancestors
 | ||||
| // recursion unavoidable, but should be limited to depth < 25 by mempool policy
 | ||||
| function setRelatives( | ||||
|   tx: AuditTransaction, | ||||
|   mempool: { [txid: string]: AuditTransaction }, | ||||
| ): void { | ||||
|   for (const parent of tx.vin) { | ||||
|     const parentTx = mempool[parent.txid]; | ||||
|     if (parentTx && !tx.ancestorMap?.has(parent.txid)) { | ||||
|       tx.ancestorMap.set(parent.txid, parentTx); | ||||
|       parentTx.children.add(tx); | ||||
|       // visit each node only once
 | ||||
|       if (!parentTx.relativesSet) { | ||||
|         setRelatives(parentTx, mempool); | ||||
|       } | ||||
|       parentTx.ancestorMap.forEach((ancestor) => { | ||||
|         tx.ancestorMap.set(ancestor.txid, ancestor); | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
|   tx.ancestorFee = tx.fee || 0; | ||||
|   tx.ancestorWeight = tx.weight || 0; | ||||
|   tx.ancestorMap.forEach((ancestor) => { | ||||
|     tx.ancestorFee += ancestor.fee; | ||||
|     tx.ancestorWeight += ancestor.weight; | ||||
|   }); | ||||
|   tx.score = tx.ancestorFee / ((tx.ancestorWeight / 4) || 1); | ||||
|   tx.relativesSet = true; | ||||
| } | ||||
| 
 | ||||
| // iterate over remaining descendants, removing the root as a valid ancestor & updating the ancestor score
 | ||||
| // avoids recursion to limit call stack depth
 | ||||
| function updateDescendants( | ||||
|   rootTx: AuditTransaction, | ||||
|   mempool: { [txid: string]: AuditTransaction }, | ||||
|   modified: PairingHeap<AuditTransaction>, | ||||
| ): void { | ||||
|   const descendantSet: Set<AuditTransaction> = new Set(); | ||||
|   // stack of nodes left to visit
 | ||||
|   const descendants: AuditTransaction[] = []; | ||||
|   let descendantTx; | ||||
|   let tmpScore; | ||||
|   rootTx.children.forEach(childTx => { | ||||
|     if (!descendantSet.has(childTx)) { | ||||
|       descendants.push(childTx); | ||||
|       descendantSet.add(childTx); | ||||
|     } | ||||
|   }); | ||||
|   while (descendants.length) { | ||||
|     descendantTx = descendants.pop(); | ||||
|     if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) { | ||||
|       // remove tx as ancestor
 | ||||
|       descendantTx.ancestorMap.delete(rootTx.txid); | ||||
|       descendantTx.ancestorFee -= rootTx.fee; | ||||
|       descendantTx.ancestorWeight -= rootTx.weight; | ||||
|       tmpScore = descendantTx.score; | ||||
|       descendantTx.score = descendantTx.ancestorFee / (descendantTx.ancestorWeight / 4); | ||||
| 
 | ||||
|       if (!descendantTx.modifiedNode) { | ||||
|         descendantTx.modified = true; | ||||
|         descendantTx.modifiedNode = modified.add(descendantTx); | ||||
|       } else { | ||||
|         // rebalance modified heap if score has changed
 | ||||
|         if (descendantTx.score < tmpScore) { | ||||
|           modified.decreasePriority(descendantTx.modifiedNode); | ||||
|         } else if (descendantTx.score > tmpScore) { | ||||
|           modified.increasePriority(descendantTx.modifiedNode); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       // add this node's children to the stack
 | ||||
|       descendantTx.children.forEach(childTx => { | ||||
|         // visit each node only once
 | ||||
|         if (!descendantSet.has(childTx)) { | ||||
|           descendants.push(childTx); | ||||
|           descendantSet.add(childTx); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function dataToMempoolBlocks(transactions: TransactionExtended[], | ||||
|   blockSize: number, blockWeight: number, blocksIndex: number): MempoolBlockWithTransactions { | ||||
|   let rangeLength = 4; | ||||
|   if (blocksIndex === 0) { | ||||
|     rangeLength = 8; | ||||
|   } | ||||
|   if (transactions.length > 4000) { | ||||
|     rangeLength = 6; | ||||
|   } else if (transactions.length > 10000) { | ||||
|     rangeLength = 8; | ||||
|   } | ||||
|   return { | ||||
|     blockSize: blockSize, | ||||
|     blockVSize: blockWeight / 4, | ||||
|     nTx: transactions.length, | ||||
|     totalFees: transactions.reduce((acc, cur) => acc + cur.fee, 0), | ||||
|     medianFee: Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE), | ||||
|     feeRange: Common.getFeesInRange(transactions, rangeLength), | ||||
|     transactionIds: transactions.map((tx) => tx.txid), | ||||
|     transactions: transactions.map((tx) => Common.stripTransaction(tx)), | ||||
|   }; | ||||
| } | ||||
| @ -244,13 +244,18 @@ class WebsocketHandler { | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   handleMempoolChange(newMempool: { [txid: string]: TransactionExtended }, | ||||
|     newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) { | ||||
|   async handleMempoolChange(newMempool: { [txid: string]: TransactionExtended }, | ||||
|     newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]): Promise<void> { | ||||
|     if (!this.wss) { | ||||
|       throw new Error('WebSocket.Server is not set'); | ||||
|     } | ||||
| 
 | ||||
|     if (config.MEMPOOL.ADVANCED_TRANSACTION_SELECTION) { | ||||
|       await mempoolBlocks.makeBlockTemplates(newMempool, 8, null, true); | ||||
|     } | ||||
|     else { | ||||
|       mempoolBlocks.updateMempoolBlocks(newMempool); | ||||
|     } | ||||
|     const mBlocks = mempoolBlocks.getMempoolBlocks(); | ||||
|     const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); | ||||
|     const mempoolInfo = memPool.getMempoolInfo(); | ||||
| @ -406,21 +411,24 @@ class WebsocketHandler { | ||||
|     }); | ||||
|   } | ||||
|   | ||||
|   handleNewBlock(block: BlockExtended, txIds: string[], transactions: TransactionExtended[]): void { | ||||
|   async handleNewBlock(block: BlockExtended, txIds: string[], transactions: TransactionExtended[]): Promise<void> { | ||||
|     if (!this.wss) { | ||||
|       throw new Error('WebSocket.Server is not set'); | ||||
|     } | ||||
| 
 | ||||
|     let mBlocks: undefined | MempoolBlock[]; | ||||
|     let mBlockDeltas: undefined | MempoolBlockDelta[]; | ||||
|     let matchRate; | ||||
|     const _memPool = memPool.getMempool(); | ||||
|     let matchRate; | ||||
| 
 | ||||
|     if (Common.indexingEnabled()) { | ||||
|       const mempoolCopy = cloneMempool(_memPool); | ||||
|       const projectedBlocks = mempoolBlocks.makeBlockTemplates(mempoolCopy, 2); | ||||
|     if (config.MEMPOOL.ADVANCED_TRANSACTION_SELECTION) { | ||||
|       await mempoolBlocks.makeBlockTemplates(_memPool, 2); | ||||
|     } else { | ||||
|       mempoolBlocks.updateMempoolBlocks(_memPool); | ||||
|     } | ||||
| 
 | ||||
|       const { censored, added, score } = Audit.auditBlock(transactions, projectedBlocks, mempoolCopy); | ||||
|     if (Common.indexingEnabled() && memPool.isInSync()) { | ||||
|       const projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions(); | ||||
| 
 | ||||
|       const { censored, added, fresh, score } = Audit.auditBlock(transactions, projectedBlocks, _memPool); | ||||
|       matchRate = Math.round(score * 100 * 100) / 100; | ||||
| 
 | ||||
|       const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => { | ||||
| @ -446,6 +454,7 @@ class WebsocketHandler { | ||||
|         hash: block.id, | ||||
|         addedTxs: added, | ||||
|         missingTxs: censored, | ||||
|         freshTxs: fresh, | ||||
|         matchRate: matchRate, | ||||
|       }); | ||||
| 
 | ||||
| @ -459,9 +468,13 @@ class WebsocketHandler { | ||||
|       delete _memPool[txId]; | ||||
|     } | ||||
| 
 | ||||
|     if (config.MEMPOOL.ADVANCED_TRANSACTION_SELECTION) { | ||||
|       await mempoolBlocks.makeBlockTemplates(_memPool, 2); | ||||
|     } else { | ||||
|       mempoolBlocks.updateMempoolBlocks(_memPool); | ||||
|     mBlocks = mempoolBlocks.getMempoolBlocks(); | ||||
|     mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); | ||||
|     } | ||||
|     const mBlocks = mempoolBlocks.getMempoolBlocks(); | ||||
|     const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); | ||||
| 
 | ||||
|     const da = difficultyAdjustment.getDifficultyAdjustment(); | ||||
|     const fees = feeApi.getRecommendedFee(); | ||||
| @ -569,14 +582,4 @@ class WebsocketHandler { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function cloneMempool(mempool: { [txid: string]: TransactionExtended }): { [txid: string]: TransactionExtended } { | ||||
|   const cloned = {}; | ||||
|   Object.keys(mempool).forEach(id => { | ||||
|     cloned[id] = { | ||||
|       ...mempool[id] | ||||
|     }; | ||||
|   }); | ||||
|   return cloned; | ||||
| } | ||||
| 
 | ||||
| export default new WebsocketHandler(); | ||||
|  | ||||
| @ -4,6 +4,7 @@ const configFromFile = require( | ||||
| 
 | ||||
| interface IConfig { | ||||
|   MEMPOOL: { | ||||
|     ENABLED: boolean; | ||||
|     NETWORK: 'mainnet' | 'testnet' | 'signet' | 'liquid' | 'liquidtestnet'; | ||||
|     BACKEND: 'esplora' | 'electrum' | 'none'; | ||||
|     HTTP_PORT: number; | ||||
| @ -28,6 +29,8 @@ interface IConfig { | ||||
|     AUTOMATIC_BLOCK_REINDEXING: boolean; | ||||
|     POOLS_JSON_URL: string, | ||||
|     POOLS_JSON_TREE_URL: string, | ||||
|     ADVANCED_TRANSACTION_SELECTION: boolean; | ||||
|     TRANSACTION_INDEXING: boolean; | ||||
|   }; | ||||
|   ESPLORA: { | ||||
|     REST_API_URL: string; | ||||
| @ -39,6 +42,7 @@ interface IConfig { | ||||
|     STATS_REFRESH_INTERVAL: number; | ||||
|     GRAPH_REFRESH_INTERVAL: number; | ||||
|     LOGGER_UPDATE_INTERVAL: number; | ||||
|     FORENSICS_INTERVAL: number; | ||||
|   }; | ||||
|   LND: { | ||||
|     TLS_CERT_PATH: string; | ||||
| @ -119,6 +123,7 @@ interface IConfig { | ||||
| 
 | ||||
| const defaults: IConfig = { | ||||
|   'MEMPOOL': { | ||||
|     'ENABLED': true, | ||||
|     'NETWORK': 'mainnet', | ||||
|     'BACKEND': 'none', | ||||
|     'HTTP_PORT': 8999, | ||||
| @ -143,6 +148,8 @@ const defaults: IConfig = { | ||||
|     'AUTOMATIC_BLOCK_REINDEXING': false, | ||||
|     'POOLS_JSON_URL': 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json', | ||||
|     'POOLS_JSON_TREE_URL': 'https://api.github.com/repos/mempool/mining-pools/git/trees/master', | ||||
|     'ADVANCED_TRANSACTION_SELECTION': false, | ||||
|     'TRANSACTION_INDEXING': false, | ||||
|   }, | ||||
|   'ESPLORA': { | ||||
|     'REST_API_URL': 'http://127.0.0.1:3000', | ||||
| @ -195,6 +202,7 @@ const defaults: IConfig = { | ||||
|     'STATS_REFRESH_INTERVAL': 600, | ||||
|     'GRAPH_REFRESH_INTERVAL': 600, | ||||
|     'LOGGER_UPDATE_INTERVAL': 30, | ||||
|     'FORENSICS_INTERVAL': 43200, | ||||
|   }, | ||||
|   'LND': { | ||||
|     'TLS_CERT_PATH': '', | ||||
| @ -224,11 +232,11 @@ const defaults: IConfig = { | ||||
|     'BISQ_URL': 'https://bisq.markets/api', | ||||
|     'BISQ_ONION': 'http://bisqmktse2cabavbr2xjq7xw3h6g5ottemo5rolfcwt6aly6tp5fdryd.onion/api' | ||||
|   }, | ||||
|   "MAXMIND": { | ||||
|   'MAXMIND': { | ||||
|     'ENABLED': false, | ||||
|     "GEOLITE2_CITY": "/usr/local/share/GeoIP/GeoLite2-City.mmdb", | ||||
|     "GEOLITE2_ASN": "/usr/local/share/GeoIP/GeoLite2-ASN.mmdb", | ||||
|     "GEOIP2_ISP": "/usr/local/share/GeoIP/GeoIP2-ISP.mmdb" | ||||
|     'GEOLITE2_CITY': '/usr/local/share/GeoIP/GeoLite2-City.mmdb', | ||||
|     'GEOLITE2_ASN': '/usr/local/share/GeoIP/GeoLite2-ASN.mmdb', | ||||
|     'GEOIP2_ISP': '/usr/local/share/GeoIP/GeoIP2-ISP.mmdb' | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| import express from "express"; | ||||
| import express from 'express'; | ||||
| import { Application, Request, Response, NextFunction } from 'express'; | ||||
| import * as http from 'http'; | ||||
| import * as WebSocket from 'ws'; | ||||
| @ -34,7 +34,8 @@ import miningRoutes from './api/mining/mining-routes'; | ||||
| import bisqRoutes from './api/bisq/bisq.routes'; | ||||
| import liquidRoutes from './api/liquid/liquid.routes'; | ||||
| import bitcoinRoutes from './api/bitcoin/bitcoin.routes'; | ||||
| import fundingTxFetcher from "./tasks/lightning/sync-tasks/funding-tx-fetcher"; | ||||
| import fundingTxFetcher from './tasks/lightning/sync-tasks/funding-tx-fetcher'; | ||||
| import forensicsService from './tasks/lightning/forensics.service'; | ||||
| 
 | ||||
| class Server { | ||||
|   private wss: WebSocket.Server | undefined; | ||||
| @ -74,7 +75,7 @@ class Server { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async startServer(worker = false) { | ||||
|   async startServer(worker = false): Promise<void> { | ||||
|     logger.notice(`Starting Mempool Server${worker ? ' (worker)' : ''}... (${backendInfo.getShortCommitHash()})`); | ||||
| 
 | ||||
|     this.app | ||||
| @ -83,7 +84,7 @@ class Server { | ||||
|         next(); | ||||
|       }) | ||||
|       .use(express.urlencoded({ extended: true })) | ||||
|       .use(express.text()) | ||||
|       .use(express.text({ type: ['text/plain', 'application/base64'] })) | ||||
|       ; | ||||
| 
 | ||||
|     this.server = http.createServer(this.app); | ||||
| @ -92,7 +93,9 @@ class Server { | ||||
|     this.setUpWebsocketHandling(); | ||||
| 
 | ||||
|     await syncAssets.syncAssets$(); | ||||
|     if (config.MEMPOOL.ENABLED) { | ||||
|       diskCache.loadMempoolCache(); | ||||
|     } | ||||
| 
 | ||||
|     if (config.DATABASE.ENABLED) { | ||||
|       await DB.checkDbConnection(); | ||||
| @ -127,7 +130,10 @@ class Server { | ||||
|     fiatConversion.startService(); | ||||
| 
 | ||||
|     this.setUpHttpApiRoutes(); | ||||
| 
 | ||||
|     if (config.MEMPOOL.ENABLED) { | ||||
|       this.runMainUpdateLoop(); | ||||
|     } | ||||
| 
 | ||||
|     if (config.BISQ.ENABLED) { | ||||
|       bisq.startBisqService(); | ||||
| @ -149,7 +155,7 @@ class Server { | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   async runMainUpdateLoop() { | ||||
|   async runMainUpdateLoop(): Promise<void> { | ||||
|     try { | ||||
|       try { | ||||
|         await memPool.$updateMemPoolInfo(); | ||||
| @ -183,10 +189,11 @@ class Server { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async $runLightningBackend() { | ||||
|   async $runLightningBackend(): Promise<void> { | ||||
|     try { | ||||
|       await fundingTxFetcher.$init(); | ||||
|       await networkSyncService.$startService(); | ||||
|       await forensicsService.$startService(); | ||||
|       await lightningStatsUpdater.$startService(); | ||||
|     } catch(e) { | ||||
|       logger.err(`Nodejs lightning backend crashed. Restarting in 1 minute. Reason: ${(e instanceof Error ? e.message : e)}`); | ||||
| @ -195,7 +202,7 @@ class Server { | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
|   setUpWebsocketHandling() { | ||||
|   setUpWebsocketHandling(): void { | ||||
|     if (this.wss) { | ||||
|       websocketHandler.setWebsocketServer(this.wss); | ||||
|     } | ||||
| @ -209,19 +216,21 @@ class Server { | ||||
|       }); | ||||
|     } | ||||
|     websocketHandler.setupConnectionHandling(); | ||||
|     if (config.MEMPOOL.ENABLED) { | ||||
|       statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler)); | ||||
|     blocks.setNewBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler)); | ||||
|     memPool.setMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler)); | ||||
|       memPool.setAsyncMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler)); | ||||
|       blocks.setNewAsyncBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler)); | ||||
|     } | ||||
|     fiatConversion.setProgressChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler)); | ||||
|     loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler)); | ||||
|   } | ||||
|    | ||||
|   setUpHttpApiRoutes() { | ||||
|   setUpHttpApiRoutes(): void { | ||||
|     bitcoinRoutes.initRoutes(this.app); | ||||
|     if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED) { | ||||
|     if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && config.MEMPOOL.ENABLED) { | ||||
|       statisticsRoutes.initRoutes(this.app); | ||||
|     } | ||||
|     if (Common.indexingEnabled()) { | ||||
|     if (Common.indexingEnabled() && config.MEMPOOL.ENABLED) { | ||||
|       miningRoutes.initRoutes(this.app); | ||||
|     } | ||||
|     if (config.BISQ.ENABLED) { | ||||
| @ -238,4 +247,4 @@ class Server { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const server = new Server(); | ||||
| ((): Server => new Server())(); | ||||
|  | ||||
| @ -77,6 +77,7 @@ class Indexer { | ||||
|       await mining.$generateNetworkHashrateHistory(); | ||||
|       await mining.$generatePoolHashrateHistory(); | ||||
|       await blocks.$generateBlocksSummariesDatabase(); | ||||
|       await blocks.$generateCPFPDatabase(); | ||||
|     } catch (e) { | ||||
|       this.indexerRunning = false; | ||||
|       logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|  | ||||
| @ -28,10 +28,16 @@ export interface BlockAudit { | ||||
|   height: number, | ||||
|   hash: string, | ||||
|   missingTxs: string[], | ||||
|   freshTxs: string[], | ||||
|   addedTxs: string[], | ||||
|   matchRate: number, | ||||
| } | ||||
| 
 | ||||
| export interface AuditScore { | ||||
|   hash: string, | ||||
|   matchRate?: number, | ||||
| } | ||||
| 
 | ||||
| export interface MempoolBlock { | ||||
|   blockSize: number; | ||||
|   blockVSize: number; | ||||
| @ -66,6 +72,7 @@ export interface TransactionExtended extends IEsploraApi.Transaction { | ||||
|   firstSeen?: number; | ||||
|   effectiveFeePerVsize: number; | ||||
|   ancestors?: Ancestor[]; | ||||
|   descendants?: Ancestor[]; | ||||
|   bestDescendant?: BestDescendant | null; | ||||
|   cpfpChecked?: boolean; | ||||
|   deleteAfter?: number; | ||||
| @ -113,7 +120,9 @@ interface BestDescendant { | ||||
| 
 | ||||
| export interface CpfpInfo { | ||||
|   ancestors: Ancestor[]; | ||||
|   bestDescendant: BestDescendant | null; | ||||
|   bestDescendant?: BestDescendant | null; | ||||
|   descendants?: Ancestor[]; | ||||
|   effectiveFeePerVsize?: number; | ||||
| } | ||||
| 
 | ||||
| export interface TransactionStripped { | ||||
|  | ||||
| @ -1,13 +1,14 @@ | ||||
| import blocks from '../api/blocks'; | ||||
| import DB from '../database'; | ||||
| import logger from '../logger'; | ||||
| import { BlockAudit } from '../mempool.interfaces'; | ||||
| import { BlockAudit, AuditScore } from '../mempool.interfaces'; | ||||
| 
 | ||||
| class BlocksAuditRepositories { | ||||
|   public async $saveAudit(audit: BlockAudit): Promise<void> { | ||||
|     try { | ||||
|       await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, match_rate)
 | ||||
|         VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs),
 | ||||
|           JSON.stringify(audit.addedTxs), audit.matchRate]); | ||||
|       await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, fresh_txs, match_rate)
 | ||||
|         VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs),
 | ||||
|           JSON.stringify(audit.addedTxs), JSON.stringify(audit.freshTxs), audit.matchRate]); | ||||
|     } catch (e: any) { | ||||
|       if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
 | ||||
|         logger.debug(`Cannot save block audit for block ${audit.hash} because it has already been indexed, ignoring`); | ||||
| @ -51,7 +52,7 @@ class BlocksAuditRepositories { | ||||
|       const [rows]: any[] = await DB.query( | ||||
|         `SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size,
 | ||||
|         blocks.weight, blocks.tx_count, | ||||
|         transactions, template, missing_txs as missingTxs, added_txs as addedTxs, match_rate as matchRate | ||||
|         transactions, template, missing_txs as missingTxs, added_txs as addedTxs, fresh_txs as freshTxs, match_rate as matchRate | ||||
|         FROM blocks_audits | ||||
|         JOIN blocks ON blocks.hash = blocks_audits.hash | ||||
|         JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash | ||||
| @ -61,21 +62,25 @@ class BlocksAuditRepositories { | ||||
|       if (rows.length) { | ||||
|         rows[0].missingTxs = JSON.parse(rows[0].missingTxs); | ||||
|         rows[0].addedTxs = JSON.parse(rows[0].addedTxs); | ||||
|         rows[0].freshTxs = JSON.parse(rows[0].freshTxs); | ||||
|         rows[0].transactions = JSON.parse(rows[0].transactions); | ||||
|         rows[0].template = JSON.parse(rows[0].template); | ||||
|       } | ||||
| 
 | ||||
|         if (rows[0].transactions.length) { | ||||
|           return rows[0]; | ||||
|         } | ||||
|       } | ||||
|       return null; | ||||
|     } catch (e: any) { | ||||
|       logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getShortBlockAudit(hash: string): Promise<any> { | ||||
|   public async $getBlockAuditScore(hash: string): Promise<AuditScore> { | ||||
|     try { | ||||
|       const [rows]: any[] = await DB.query( | ||||
|         `SELECT hash as id, match_rate as matchRate
 | ||||
|         `SELECT hash, match_rate as matchRate
 | ||||
|         FROM blocks_audits | ||||
|         WHERE blocks_audits.hash = "${hash}" | ||||
|       `);
 | ||||
| @ -85,6 +90,20 @@ class BlocksAuditRepositories { | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getBlockAuditScores(maxHeight: number, minHeight: number): Promise<AuditScore[]> { | ||||
|     try { | ||||
|       const [rows]: any[] = await DB.query( | ||||
|         `SELECT hash, match_rate as matchRate
 | ||||
|         FROM blocks_audits | ||||
|         WHERE blocks_audits.height BETWEEN ? AND ? | ||||
|       `, [minHeight, maxHeight]);
 | ||||
|       return rows; | ||||
|     } catch (e: any) { | ||||
|       logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new BlocksAuditRepositories(); | ||||
|  | ||||
| @ -392,6 +392,36 @@ class BlocksRepository { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get the first block at or directly after a given timestamp | ||||
|    * @param timestamp number unix time in seconds | ||||
|    * @returns The height and timestamp of a block (timestamp might vary from given timestamp) | ||||
|    */ | ||||
|   public async $getBlockHeightFromTimestamp( | ||||
|     timestamp: number, | ||||
|   ): Promise<{ height: number; hash: string; timestamp: number }> { | ||||
|     try { | ||||
|       // Get first block at or after the given timestamp
 | ||||
|       const query = `SELECT height, hash, blockTimestamp as timestamp FROM blocks
 | ||||
|         WHERE blockTimestamp <= FROM_UNIXTIME(?) | ||||
|         ORDER BY blockTimestamp DESC | ||||
|         LIMIT 1`;
 | ||||
|       const params = [timestamp]; | ||||
|       const [rows]: any[][] = await DB.query(query, params); | ||||
|       if (rows.length === 0) { | ||||
|         throw new Error(`No block was found before timestamp ${timestamp}`); | ||||
|       } | ||||
| 
 | ||||
|       return rows[0]; | ||||
|     } catch (e) { | ||||
|       logger.err( | ||||
|         'Cannot get block height from timestamp from the db. Reason: ' + | ||||
|           (e instanceof Error ? e.message : e), | ||||
|       ); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Return blocks height | ||||
|    */ | ||||
| @ -632,6 +662,23 @@ class BlocksRepository { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get a list of blocks that have not had CPFP data indexed | ||||
|    */ | ||||
|    public async $getCPFPUnindexedBlocks(): Promise<any[]> { | ||||
|     try { | ||||
|       const [rows]: any = await DB.query(`SELECT height, hash FROM blocks WHERE cpfp_indexed = 0 ORDER BY height DESC`); | ||||
|       return rows; | ||||
|     } catch (e) { | ||||
|       logger.err('Cannot fetch CPFP unindexed blocks. Reason: ' + (e instanceof Error ? e.message : e)); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $setCPFPIndexed(hash: string): Promise<void> { | ||||
|     await DB.query(`UPDATE blocks SET cpfp_indexed = 1 WHERE hash = ?`, [hash]); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Return the oldest block  from a consecutive chain of block from the most recent one | ||||
|    */ | ||||
|  | ||||
							
								
								
									
										43
									
								
								backend/src/repositories/CpfpRepository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								backend/src/repositories/CpfpRepository.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,43 @@ | ||||
| import DB from '../database'; | ||||
| import logger from '../logger'; | ||||
| import { Ancestor } from '../mempool.interfaces'; | ||||
| 
 | ||||
| class CpfpRepository { | ||||
|   public async $saveCluster(height: number, txs: Ancestor[], effectiveFeePerVsize: number): Promise<void> { | ||||
|     try { | ||||
|       const txsJson = JSON.stringify(txs); | ||||
|       await DB.query( | ||||
|         ` | ||||
|           INSERT INTO cpfp_clusters(root, height, txs, fee_rate) | ||||
|           VALUE (?, ?, ?, ?) | ||||
|           ON DUPLICATE KEY UPDATE | ||||
|             height = ?, | ||||
|             txs = ?, | ||||
|             fee_rate = ? | ||||
|         `,
 | ||||
|         [txs[0].txid, height, txsJson, effectiveFeePerVsize, height, txsJson, effectiveFeePerVsize, height] | ||||
|       ); | ||||
|     } catch (e: any) { | ||||
|       logger.err(`Cannot save cpfp cluster into db. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $deleteClustersFrom(height: number): Promise<void> { | ||||
|     logger.info(`Delete newer cpfp clusters from height ${height} from the database`); | ||||
|     try { | ||||
|       await DB.query( | ||||
|         ` | ||||
|           DELETE from cpfp_clusters | ||||
|           WHERE height >= ? | ||||
|         `,
 | ||||
|         [height] | ||||
|       ); | ||||
|     } catch (e: any) { | ||||
|       logger.err(`Cannot delete cpfp clusters from db. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new CpfpRepository(); | ||||
							
								
								
									
										67
									
								
								backend/src/repositories/NodeRecordsRepository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								backend/src/repositories/NodeRecordsRepository.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,67 @@ | ||||
| import { ResultSetHeader, RowDataPacket } from 'mysql2'; | ||||
| import DB from '../database'; | ||||
| import logger from '../logger'; | ||||
| 
 | ||||
| export interface NodeRecord { | ||||
|   publicKey: string; // node public key
 | ||||
|   type: number; // TLV extension record type
 | ||||
|   payload: string; // base64 record payload
 | ||||
| } | ||||
| 
 | ||||
| class NodesRecordsRepository { | ||||
|   public async $saveRecord(record: NodeRecord): Promise<void> { | ||||
|     try { | ||||
|       const payloadBytes = Buffer.from(record.payload, 'base64'); | ||||
|       await DB.query(` | ||||
|         INSERT INTO nodes_records(public_key, type, payload) | ||||
|         VALUE (?, ?, ?) | ||||
|         ON DUPLICATE KEY UPDATE | ||||
|           payload = ? | ||||
|       `, [record.publicKey, record.type, payloadBytes, payloadBytes]);
 | ||||
|     } catch (e: any) { | ||||
|       if (e.errno !== 1062) { // ER_DUP_ENTRY - Not an issue, just ignore this
 | ||||
|         logger.err(`Cannot save node record (${[record.publicKey, record.type, record.payload]}) into db. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|         // We don't throw, not a critical issue if we miss some nodes records
 | ||||
|       } | ||||
|     } | ||||
|    } | ||||
| 
 | ||||
|   public async $getRecordTypes(publicKey: string): Promise<any> { | ||||
|     try { | ||||
|       const query = ` | ||||
|         SELECT type FROM nodes_records | ||||
|         WHERE public_key = ? | ||||
|       `;
 | ||||
|       const [rows] = await DB.query<RowDataPacket[][]>(query, [publicKey]); | ||||
|       return rows.map(row => row['type']); | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot retrieve custom records for ${publicKey} from db. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|       return []; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $deleteUnusedRecords(publicKey: string, recordTypes: number[]): Promise<number> { | ||||
|     try { | ||||
|       let query; | ||||
|       if (recordTypes.length) { | ||||
|         query = ` | ||||
|           DELETE FROM nodes_records | ||||
|           WHERE public_key = ? | ||||
|           AND type NOT IN (${recordTypes.map(type => `${type}`).join(',')}) | ||||
|         `;
 | ||||
|       } else { | ||||
|         query = ` | ||||
|           DELETE FROM nodes_records | ||||
|           WHERE public_key = ? | ||||
|         `;
 | ||||
|       } | ||||
|       const [result] = await DB.query<ResultSetHeader>(query, [publicKey]); | ||||
|       return result.affectedRows; | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot delete unused custom records for ${publicKey} from db. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|       return 0; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new NodesRecordsRepository(); | ||||
							
								
								
									
										77
									
								
								backend/src/repositories/TransactionRepository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								backend/src/repositories/TransactionRepository.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,77 @@ | ||||
| import DB from '../database'; | ||||
| import logger from '../logger'; | ||||
| import { Ancestor, CpfpInfo } from '../mempool.interfaces'; | ||||
| 
 | ||||
| interface CpfpSummary { | ||||
|   txid: string; | ||||
|   cluster: string; | ||||
|   root: string; | ||||
|   txs: Ancestor[]; | ||||
|   height: number; | ||||
|   fee_rate: number; | ||||
| } | ||||
| 
 | ||||
| class TransactionRepository { | ||||
|   public async $setCluster(txid: string, cluster: string): Promise<void> { | ||||
|     try { | ||||
|       await DB.query( | ||||
|         ` | ||||
|           INSERT INTO transactions | ||||
|           ( | ||||
|             txid, | ||||
|             cluster | ||||
|           ) | ||||
|           VALUE (?, ?) | ||||
|           ON DUPLICATE KEY UPDATE | ||||
|             cluster = ? | ||||
|         ;`,
 | ||||
|         [txid, cluster, cluster] | ||||
|       ); | ||||
|     } catch (e: any) { | ||||
|       logger.err(`Cannot save transaction cpfp cluster into db. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getCpfpInfo(txid: string): Promise<CpfpInfo | void> { | ||||
|     try { | ||||
|       let query = ` | ||||
|         SELECT * | ||||
|         FROM transactions | ||||
|         LEFT JOIN cpfp_clusters AS cluster ON cluster.root = transactions.cluster | ||||
|         WHERE transactions.txid = ? | ||||
|       `;
 | ||||
|       const [rows]: any = await DB.query(query, [txid]); | ||||
|       if (rows.length) { | ||||
|         rows[0].txs = JSON.parse(rows[0].txs) as Ancestor[]; | ||||
|         return this.convertCpfp(rows[0]); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       logger.err('Cannot get transaction cpfp info from db. Reason: ' + (e instanceof Error ? e.message : e)); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private convertCpfp(cpfp: CpfpSummary): CpfpInfo { | ||||
|     const descendants: Ancestor[] = []; | ||||
|     const ancestors: Ancestor[] = []; | ||||
|     let matched = false; | ||||
|     for (const tx of cpfp.txs) { | ||||
|       if (tx.txid === cpfp.txid) { | ||||
|         matched = true; | ||||
|       } else if (!matched) { | ||||
|         descendants.push(tx); | ||||
|       } else { | ||||
|         ancestors.push(tx); | ||||
|       } | ||||
|     } | ||||
|     return { | ||||
|       descendants, | ||||
|       ancestors, | ||||
|       effectiveFeePerVsize: cpfp.fee_rate | ||||
|     }; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new TransactionRepository(); | ||||
| 
 | ||||
							
								
								
									
										225
									
								
								backend/src/tasks/lightning/forensics.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								backend/src/tasks/lightning/forensics.service.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,225 @@ | ||||
| import DB from '../../database'; | ||||
| import logger from '../../logger'; | ||||
| import channelsApi from '../../api/explorer/channels.api'; | ||||
| import bitcoinApi from '../../api/bitcoin/bitcoin-api-factory'; | ||||
| import config from '../../config'; | ||||
| import { IEsploraApi } from '../../api/bitcoin/esplora-api.interface'; | ||||
| import { Common } from '../../api/common'; | ||||
| 
 | ||||
| const throttleDelay = 20; //ms
 | ||||
| 
 | ||||
| class ForensicsService { | ||||
|   loggerTimer = 0; | ||||
|   closedChannelsScanBlock = 0; | ||||
|   txCache: { [txid: string]: IEsploraApi.Transaction } = {}; | ||||
| 
 | ||||
|   constructor() {} | ||||
| 
 | ||||
|   public async $startService(): Promise<void> { | ||||
|     logger.info('Starting lightning network forensics service'); | ||||
| 
 | ||||
|     this.loggerTimer = new Date().getTime() / 1000; | ||||
| 
 | ||||
|     await this.$runTasks(); | ||||
|   } | ||||
| 
 | ||||
|   private async $runTasks(): Promise<void> { | ||||
|     try { | ||||
|       logger.info(`Running forensics scans`); | ||||
| 
 | ||||
|       if (config.MEMPOOL.BACKEND === 'esplora') { | ||||
|         await this.$runClosedChannelsForensics(false); | ||||
|       } | ||||
| 
 | ||||
|     } catch (e) { | ||||
|       logger.err('ForensicsService.$runTasks() error: ' + (e instanceof Error ? e.message : e)); | ||||
|     } | ||||
| 
 | ||||
|     setTimeout(() => { this.$runTasks(); }, 1000 * config.LIGHTNING.FORENSICS_INTERVAL); | ||||
|   } | ||||
| 
 | ||||
|   /* | ||||
|     1. Mutually closed | ||||
|     2. Forced closed | ||||
|     3. Forced closed with penalty | ||||
| 
 | ||||
|     ┌────────────────────────────────────┐       ┌────────────────────────────┐ | ||||
|     │ outputs contain revocation script? ├──yes──► force close w/ penalty = 3 │ | ||||
|     └──────────────┬─────────────────────┘       └────────────────────────────┘ | ||||
|                    no | ||||
|     ┌──────────────▼──────────────────────────┐ | ||||
|     │ outputs contain other lightning script? ├──┐ | ||||
|     └──────────────┬──────────────────────────┘  │ | ||||
|                    no                           yes | ||||
|     ┌──────────────▼─────────────┐               │ | ||||
|     │ sequence starts with 0x80  │      ┌────────▼────────┐ | ||||
|     │           and              ├──────► force close = 2 │ | ||||
|     │ locktime starts with 0x20? │      └─────────────────┘ | ||||
|     └──────────────┬─────────────┘ | ||||
|                    no | ||||
|          ┌─────────▼────────┐ | ||||
|          │ mutual close = 1 │ | ||||
|          └──────────────────┘ | ||||
|   */ | ||||
| 
 | ||||
|   public async $runClosedChannelsForensics(onlyNewChannels: boolean = false): Promise<void> { | ||||
|     if (config.MEMPOOL.BACKEND !== 'esplora') { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     let progress = 0; | ||||
| 
 | ||||
|     try { | ||||
|       logger.info(`Started running closed channel forensics...`); | ||||
|       let channels; | ||||
|       if (onlyNewChannels) { | ||||
|         channels = await channelsApi.$getClosedChannelsWithoutReason(); | ||||
|       } else { | ||||
|         channels = await channelsApi.$getUnresolvedClosedChannels(); | ||||
|       } | ||||
| 
 | ||||
|       for (const channel of channels) { | ||||
|         let reason = 0; | ||||
|         let resolvedForceClose = false; | ||||
|         // Only Esplora backend can retrieve spent transaction outputs
 | ||||
|         const cached: string[] = []; | ||||
|         try { | ||||
|           let outspends: IEsploraApi.Outspend[] | undefined; | ||||
|           try { | ||||
|             outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id); | ||||
|             await Common.sleep$(throttleDelay); | ||||
|           } catch (e) { | ||||
|             logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`); | ||||
|             continue; | ||||
|           } | ||||
|           const lightningScriptReasons: number[] = []; | ||||
|           for (const outspend of outspends) { | ||||
|             if (outspend.spent && outspend.txid) { | ||||
|               let spendingTx: IEsploraApi.Transaction | undefined = this.txCache[outspend.txid]; | ||||
|               if (!spendingTx) { | ||||
|                 try { | ||||
|                   spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid); | ||||
|                   await Common.sleep$(throttleDelay); | ||||
|                   this.txCache[outspend.txid] = spendingTx; | ||||
|                 } catch (e) { | ||||
|                   logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + outspend.txid}. Reason ${e instanceof Error ? e.message : e}`); | ||||
|                   continue; | ||||
|                 } | ||||
|               } | ||||
|               cached.push(spendingTx.txid); | ||||
|               const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]); | ||||
|               lightningScriptReasons.push(lightningScript); | ||||
|             } | ||||
|           } | ||||
|           const filteredReasons = lightningScriptReasons.filter((r) => r !== 1); | ||||
|           if (filteredReasons.length) { | ||||
|             if (filteredReasons.some((r) => r === 2 || r === 4)) { | ||||
|               reason = 3; | ||||
|             } else { | ||||
|               reason = 2; | ||||
|               resolvedForceClose = true; | ||||
|             } | ||||
|           } else { | ||||
|             /* | ||||
|               We can detect a commitment transaction (force close) by reading Sequence and Locktime | ||||
|               https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction
 | ||||
|             */ | ||||
|             let closingTx: IEsploraApi.Transaction | undefined = this.txCache[channel.closing_transaction_id]; | ||||
|             if (!closingTx) { | ||||
|               try { | ||||
|                 closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id); | ||||
|                 await Common.sleep$(throttleDelay); | ||||
|                 this.txCache[channel.closing_transaction_id] = closingTx; | ||||
|               } catch (e) { | ||||
|                 logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id}. Reason ${e instanceof Error ? e.message : e}`); | ||||
|                 continue; | ||||
|               } | ||||
|             } | ||||
|             cached.push(closingTx.txid); | ||||
|             const sequenceHex: string = closingTx.vin[0].sequence.toString(16); | ||||
|             const locktimeHex: string = closingTx.locktime.toString(16); | ||||
|             if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') { | ||||
|               reason = 2; // Here we can't be sure if it's a penalty or not
 | ||||
|             } else { | ||||
|               reason = 1; | ||||
|             } | ||||
|           } | ||||
|           if (reason) { | ||||
|             logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.'); | ||||
|             await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]); | ||||
|             if (reason === 2 && resolvedForceClose) { | ||||
|               await DB.query(`UPDATE channels SET closing_resolved = ? WHERE id = ?`, [true, channel.id]); | ||||
|             } | ||||
|             if (reason !== 2 || resolvedForceClose) { | ||||
|               cached.forEach(txid => { | ||||
|                 delete this.txCache[txid]; | ||||
|               }); | ||||
|             } | ||||
|           } | ||||
|         } catch (e) { | ||||
|           logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`); | ||||
|         } | ||||
| 
 | ||||
|         ++progress; | ||||
|         const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); | ||||
|         if (elapsedSeconds > 10) { | ||||
|           logger.info(`Updating channel closed channel forensics ${progress}/${channels.length}`); | ||||
|           this.loggerTimer = new Date().getTime() / 1000; | ||||
|         } | ||||
|       } | ||||
|       logger.info(`Closed channels forensics scan complete.`); | ||||
|     } catch (e) { | ||||
|       logger.err('$runClosedChannelsForensics() error: ' + (e instanceof Error ? e.message : e)); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private findLightningScript(vin: IEsploraApi.Vin): number { | ||||
|     const topElement = vin.witness[vin.witness.length - 2]; | ||||
|       if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(vin.inner_witnessscript_asm)) { | ||||
|         // https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs
 | ||||
|         if (topElement === '01') { | ||||
|           // top element is '01' to get in the revocation path
 | ||||
|           // 'Revoked Lightning Force Close';
 | ||||
|           // Penalty force closed
 | ||||
|           return 2; | ||||
|         } else { | ||||
|           // top element is '', this is a delayed to_local output
 | ||||
|           // 'Lightning Force Close';
 | ||||
|           return 3; | ||||
|         } | ||||
|       } else if ( | ||||
|         /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) || | ||||
|         /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) | ||||
|       ) { | ||||
|         // https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs
 | ||||
|         // https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs
 | ||||
|         if (topElement.length === 66) { | ||||
|           // top element is a public key
 | ||||
|           // 'Revoked Lightning HTLC'; Penalty force closed
 | ||||
|           return 4; | ||||
|         } else if (topElement) { | ||||
|           // top element is a preimage
 | ||||
|           // 'Lightning HTLC';
 | ||||
|           return 5; | ||||
|         } else { | ||||
|           // top element is '' to get in the expiry of the script
 | ||||
|           // 'Expired Lightning HTLC';
 | ||||
|           return 6; | ||||
|         } | ||||
|       } else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(vin.inner_witnessscript_asm)) { | ||||
|         // https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors
 | ||||
|         if (topElement) { | ||||
|           // top element is a signature
 | ||||
|           // 'Lightning Anchor';
 | ||||
|           return 7; | ||||
|         } else { | ||||
|           // top element is '', it has been swept after 16 blocks
 | ||||
|           // 'Swept Lightning Anchor';
 | ||||
|           return 8; | ||||
|         } | ||||
|       } | ||||
|       return 1; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new ForensicsService(); | ||||
| @ -13,6 +13,8 @@ import fundingTxFetcher from './sync-tasks/funding-tx-fetcher'; | ||||
| import NodesSocketsRepository from '../../repositories/NodesSocketsRepository'; | ||||
| import { Common } from '../../api/common'; | ||||
| import blocks from '../../api/blocks'; | ||||
| import NodeRecordsRepository from '../../repositories/NodeRecordsRepository'; | ||||
| import forensicsService from './forensics.service'; | ||||
| 
 | ||||
| class NetworkSyncService { | ||||
|   loggerTimer = 0; | ||||
| @ -45,8 +47,10 @@ class NetworkSyncService { | ||||
|       await this.$lookUpCreationDateFromChain(); | ||||
|       await this.$updateNodeFirstSeen(); | ||||
|       await this.$scanForClosedChannels(); | ||||
|        | ||||
|       if (config.MEMPOOL.BACKEND === 'esplora') { | ||||
|         await this.$runClosedChannelsForensics(); | ||||
|         // run forensics on new channels only
 | ||||
|         await forensicsService.$runClosedChannelsForensics(true); | ||||
|       } | ||||
| 
 | ||||
|     } catch (e) { | ||||
| @ -63,6 +67,7 @@ class NetworkSyncService { | ||||
|     let progress = 0; | ||||
| 
 | ||||
|     let deletedSockets = 0; | ||||
|     let deletedRecords = 0; | ||||
|     const graphNodesPubkeys: string[] = []; | ||||
|     for (const node of nodes) { | ||||
|       const latestUpdated = await channelsApi.$getLatestChannelUpdateForNode(node.pub_key); | ||||
| @ -84,8 +89,23 @@ class NetworkSyncService { | ||||
|         addresses.push(socket.addr); | ||||
|       } | ||||
|       deletedSockets += await NodesSocketsRepository.$deleteUnusedSockets(node.pub_key, addresses); | ||||
| 
 | ||||
|       const oldRecordTypes = await NodeRecordsRepository.$getRecordTypes(node.pub_key); | ||||
|       const customRecordTypes: number[] = []; | ||||
|       for (const [type, payload] of Object.entries(node.custom_records || {})) { | ||||
|         const numericalType = parseInt(type); | ||||
|         await NodeRecordsRepository.$saveRecord({ | ||||
|           publicKey: node.pub_key, | ||||
|           type: numericalType, | ||||
|           payload, | ||||
|         }); | ||||
|         customRecordTypes.push(numericalType); | ||||
|       } | ||||
|     logger.info(`${progress} nodes updated. ${deletedSockets} sockets deleted`); | ||||
|       if (oldRecordTypes.reduce((changed, type) => changed || customRecordTypes.indexOf(type) === -1, false)) { | ||||
|         deletedRecords += await NodeRecordsRepository.$deleteUnusedRecords(node.pub_key, customRecordTypes); | ||||
|       } | ||||
|     } | ||||
|     logger.info(`${progress} nodes updated. ${deletedSockets} sockets deleted. ${deletedRecords} custom records deleted.`); | ||||
| 
 | ||||
|     // If a channel if not present in the graph, mark it as inactive
 | ||||
|     await nodesApi.$setNodesInactive(graphNodesPubkeys); | ||||
| @ -284,161 +304,6 @@ class NetworkSyncService { | ||||
|       logger.err('$scanForClosedChannels() error: ' + (e instanceof Error ? e.message : e)); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /* | ||||
|     1. Mutually closed | ||||
|     2. Forced closed | ||||
|     3. Forced closed with penalty | ||||
| 
 | ||||
|     ┌────────────────────────────────────┐       ┌────────────────────────────┐ | ||||
|     │ outputs contain revocation script? ├──yes──► force close w/ penalty = 3 │ | ||||
|     └──────────────┬─────────────────────┘       └────────────────────────────┘ | ||||
|                    no | ||||
|     ┌──────────────▼──────────────────────────┐ | ||||
|     │ outputs contain other lightning script? ├──┐ | ||||
|     └──────────────┬──────────────────────────┘  │ | ||||
|                    no                           yes | ||||
|     ┌──────────────▼─────────────┐               │ | ||||
|     │ sequence starts with 0x80  │      ┌────────▼────────┐ | ||||
|     │           and              ├──────► force close = 2 │ | ||||
|     │ locktime starts with 0x20? │      └─────────────────┘ | ||||
|     └──────────────┬─────────────┘ | ||||
|                    no | ||||
|          ┌─────────▼────────┐ | ||||
|          │ mutual close = 1 │ | ||||
|          └──────────────────┘ | ||||
|   */ | ||||
| 
 | ||||
|   private async $runClosedChannelsForensics(): Promise<void> { | ||||
|     if (!config.ESPLORA.REST_API_URL) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     let progress = 0; | ||||
| 
 | ||||
|     try { | ||||
|       logger.info(`Started running closed channel forensics...`); | ||||
|       const channels = await channelsApi.$getClosedChannelsWithoutReason(); | ||||
|       for (const channel of channels) { | ||||
|         let reason = 0; | ||||
|         // Only Esplora backend can retrieve spent transaction outputs
 | ||||
|         try { | ||||
|           let outspends: IEsploraApi.Outspend[] | undefined; | ||||
|           try { | ||||
|             outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id); | ||||
|           } catch (e) { | ||||
|             logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`); | ||||
|             continue; | ||||
|           } | ||||
|           const lightningScriptReasons: number[] = []; | ||||
|           for (const outspend of outspends) { | ||||
|             if (outspend.spent && outspend.txid) { | ||||
|               let spendingTx: IEsploraApi.Transaction | undefined; | ||||
|               try { | ||||
|                 spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid); | ||||
|               } catch (e) { | ||||
|                 logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + outspend.txid}. Reason ${e instanceof Error ? e.message : e}`); | ||||
|                 continue; | ||||
|               } | ||||
|               const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]); | ||||
|               lightningScriptReasons.push(lightningScript); | ||||
|             } | ||||
|           } | ||||
|           const filteredReasons = lightningScriptReasons.filter((r) => r !== 1); | ||||
|           if (filteredReasons.length) { | ||||
|             if (filteredReasons.some((r) => r === 2 || r === 4)) { | ||||
|               reason = 3; | ||||
|             } else { | ||||
|               reason = 2; | ||||
|             } | ||||
|           } else { | ||||
|             /* | ||||
|               We can detect a commitment transaction (force close) by reading Sequence and Locktime | ||||
|               https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction
 | ||||
|             */ | ||||
|             let closingTx: IEsploraApi.Transaction | undefined; | ||||
|             try { | ||||
|               closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id); | ||||
|             } catch (e) { | ||||
|               logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id}. Reason ${e instanceof Error ? e.message : e}`); | ||||
|               continue; | ||||
|             } | ||||
|             const sequenceHex: string = closingTx.vin[0].sequence.toString(16); | ||||
|             const locktimeHex: string = closingTx.locktime.toString(16); | ||||
|             if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') { | ||||
|               reason = 2; // Here we can't be sure if it's a penalty or not
 | ||||
|             } else { | ||||
|               reason = 1; | ||||
|             } | ||||
|           } | ||||
|           if (reason) { | ||||
|             logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.'); | ||||
|             await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]); | ||||
|           } | ||||
|         } catch (e) { | ||||
|           logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`); | ||||
|         } | ||||
| 
 | ||||
|         ++progress; | ||||
|         const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); | ||||
|         if (elapsedSeconds > 10) { | ||||
|           logger.info(`Updating channel closed channel forensics ${progress}/${channels.length}`); | ||||
|           this.loggerTimer = new Date().getTime() / 1000; | ||||
|         } | ||||
|       } | ||||
|       logger.info(`Closed channels forensics scan complete.`); | ||||
|     } catch (e) { | ||||
|       logger.err('$runClosedChannelsForensics() error: ' + (e instanceof Error ? e.message : e)); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private findLightningScript(vin: IEsploraApi.Vin): number { | ||||
|     const topElement = vin.witness[vin.witness.length - 2]; | ||||
|       if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(vin.inner_witnessscript_asm)) { | ||||
|         // https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs
 | ||||
|         if (topElement === '01') { | ||||
|           // top element is '01' to get in the revocation path
 | ||||
|           // 'Revoked Lightning Force Close';
 | ||||
|           // Penalty force closed
 | ||||
|           return 2; | ||||
|         } else { | ||||
|           // top element is '', this is a delayed to_local output
 | ||||
|           // 'Lightning Force Close';
 | ||||
|           return 3; | ||||
|         } | ||||
|       } else if ( | ||||
|         /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) || | ||||
|         /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) | ||||
|       ) { | ||||
|         // https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs
 | ||||
|         // https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs
 | ||||
|         if (topElement.length === 66) { | ||||
|           // top element is a public key
 | ||||
|           // 'Revoked Lightning HTLC'; Penalty force closed
 | ||||
|           return 4; | ||||
|         } else if (topElement) { | ||||
|           // top element is a preimage
 | ||||
|           // 'Lightning HTLC';
 | ||||
|           return 5; | ||||
|         } else { | ||||
|           // top element is '' to get in the expiry of the script
 | ||||
|           // 'Expired Lightning HTLC';
 | ||||
|           return 6; | ||||
|         } | ||||
|       } else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(vin.inner_witnessscript_asm)) { | ||||
|         // https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors
 | ||||
|         if (topElement) { | ||||
|           // top element is a signature
 | ||||
|           // 'Lightning Anchor';
 | ||||
|           return 7; | ||||
|         } else { | ||||
|           // top element is '', it has been swept after 16 blocks
 | ||||
|           // 'Swept Lightning Anchor';
 | ||||
|           return 8; | ||||
|         } | ||||
|       } | ||||
|       return 1; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new NetworkSyncService(); | ||||
|  | ||||
| @ -6,6 +6,7 @@ import DB from '../../../database'; | ||||
| import logger from '../../../logger'; | ||||
| import { ResultSetHeader } from 'mysql2'; | ||||
| import * as IPCheck from '../../../utils/ipcheck.js'; | ||||
| import { Reader } from 'mmdb-lib'; | ||||
| 
 | ||||
| export async function $lookupNodeLocation(): Promise<void> { | ||||
|   let loggerTimer = new Date().getTime() / 1000; | ||||
| @ -18,7 +19,10 @@ export async function $lookupNodeLocation(): Promise<void> { | ||||
|     const nodes = await nodesApi.$getAllNodes(); | ||||
|     const lookupCity = await maxmind.open<CityResponse>(config.MAXMIND.GEOLITE2_CITY); | ||||
|     const lookupAsn = await maxmind.open<AsnResponse>(config.MAXMIND.GEOLITE2_ASN); | ||||
|     const lookupIsp = await maxmind.open<IspResponse>(config.MAXMIND.GEOIP2_ISP); | ||||
|     let lookupIsp: Reader<IspResponse> | null = null; | ||||
|     try { | ||||
|       lookupIsp = await maxmind.open<IspResponse>(config.MAXMIND.GEOIP2_ISP); | ||||
|     } catch (e) { } | ||||
| 
 | ||||
|     for (const node of nodes) { | ||||
|       const sockets: string[] = node.sockets.split(','); | ||||
| @ -29,7 +33,10 @@ export async function $lookupNodeLocation(): Promise<void> { | ||||
|         if (hasClearnet && ip !== '127.0.1.1' && ip !== '127.0.0.1') { | ||||
|           const city = lookupCity.get(ip); | ||||
|           const asn = lookupAsn.get(ip); | ||||
|           const isp = lookupIsp.get(ip); | ||||
|           let isp: IspResponse | null = null; | ||||
|           if (lookupIsp) { | ||||
|             isp = lookupIsp.get(ip); | ||||
|           } | ||||
| 
 | ||||
|           let asOverwrite: any | undefined; | ||||
|           if (asn && (IPCheck.match(ip, '170.75.160.0/20') || IPCheck.match(ip, '172.81.176.0/21'))) { | ||||
|  | ||||
| @ -1,43 +0,0 @@ | ||||
| import { query } from '../../utils/axios-query'; | ||||
| import priceUpdater, { PriceFeed, PriceHistory } from '../price-updater'; | ||||
| 
 | ||||
| class FtxApi implements PriceFeed { | ||||
|   public name: string = 'FTX'; | ||||
|   public currencies: string[] = ['USD', 'BRZ', 'EUR', 'JPY', 'AUD']; | ||||
| 
 | ||||
|   public url: string = 'https://ftx.com/api/markets/BTC/'; | ||||
|   public urlHist: string = 'https://ftx.com/api/markets/BTC/{CURRENCY}/candles?resolution={GRANULARITY}'; | ||||
| 
 | ||||
|   constructor() { | ||||
|   } | ||||
| 
 | ||||
|   public async $fetchPrice(currency): Promise<number> { | ||||
|     const response = await query(this.url + currency); | ||||
|     return response ? parseInt(response['result']['last'], 10) : -1; | ||||
|   } | ||||
| 
 | ||||
|   public async $fetchRecentPrice(currencies: string[], type: 'hour' | 'day'): Promise<PriceHistory> { | ||||
|     const priceHistory: PriceHistory = {}; | ||||
| 
 | ||||
|     for (const currency of currencies) { | ||||
|       if (this.currencies.includes(currency) === false) { | ||||
|         continue; | ||||
|       } | ||||
| 
 | ||||
|       const response = await query(this.urlHist.replace('{GRANULARITY}', type === 'hour' ? '3600' : '86400').replace('{CURRENCY}', currency)); | ||||
|       const pricesRaw = response ? response['result'] : []; | ||||
| 
 | ||||
|       for (const price of pricesRaw as any[]) { | ||||
|         const time = Math.round(price['time'] / 1000); | ||||
|         if (priceHistory[time] === undefined) { | ||||
|           priceHistory[time] = priceUpdater.getEmptyPricesObj(); | ||||
|         } | ||||
|         priceHistory[time][currency] = price['close']; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return priceHistory; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default FtxApi; | ||||
| @ -1,13 +1,11 @@ | ||||
| import * as fs from 'fs'; | ||||
| import path from "path"; | ||||
| import { Common } from '../api/common'; | ||||
| import config from '../config'; | ||||
| import logger from '../logger'; | ||||
| import PricesRepository from '../repositories/PricesRepository'; | ||||
| import BitfinexApi from './price-feeds/bitfinex-api'; | ||||
| import BitflyerApi from './price-feeds/bitflyer-api'; | ||||
| import CoinbaseApi from './price-feeds/coinbase-api'; | ||||
| import FtxApi from './price-feeds/ftx-api'; | ||||
| import GeminiApi from './price-feeds/gemini-api'; | ||||
| import KrakenApi from './price-feeds/kraken-api'; | ||||
| 
 | ||||
| @ -48,7 +46,6 @@ class PriceUpdater { | ||||
|     this.latestPrices = this.getEmptyPricesObj(); | ||||
| 
 | ||||
|     this.feeds.push(new BitflyerApi()); // Does not have historical endpoint
 | ||||
|     this.feeds.push(new FtxApi()); | ||||
|     this.feeds.push(new KrakenApi()); | ||||
|     this.feeds.push(new CoinbaseApi()); | ||||
|     this.feeds.push(new BitfinexApi()); | ||||
|  | ||||
| @ -89,6 +89,7 @@ Below we list all settings from `mempool-config.json` and the corresponding over | ||||
|   "MEMPOOL": { | ||||
|     "NETWORK": "mainnet", | ||||
|     "BACKEND": "electrum", | ||||
|     "ENABLED": true, | ||||
|     "HTTP_PORT": 8999, | ||||
|     "SPAWN_CLUSTER_PROCS": 0, | ||||
|     "API_URL_PREFIX": "/api/v1/", | ||||
|  | ||||
| @ -2,6 +2,7 @@ | ||||
|   "MEMPOOL": { | ||||
|     "NETWORK": "__MEMPOOL_NETWORK__", | ||||
|     "BACKEND": "__MEMPOOL_BACKEND__", | ||||
|     "ENABLED": __MEMPOOL_ENABLED__, | ||||
|     "HTTP_PORT": __MEMPOOL_HTTP_PORT__, | ||||
|     "SPAWN_CLUSTER_PROCS": __MEMPOOL_SPAWN_CLUSTER_PROCS__, | ||||
|     "API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__", | ||||
|  | ||||
| @ -3,6 +3,7 @@ | ||||
| # MEMPOOL | ||||
| __MEMPOOL_NETWORK__=${MEMPOOL_NETWORK:=mainnet} | ||||
| __MEMPOOL_BACKEND__=${MEMPOOL_BACKEND:=electrum} | ||||
| __MEMPOOL_ENABLED__=${MEMPOOL_ENABLED:=true} | ||||
| __MEMPOOL_HTTP_PORT__=${BACKEND_HTTP_PORT:=8999} | ||||
| __MEMPOOL_SPAWN_CLUSTER_PROCS__=${MEMPOOL_SPAWN_CLUSTER_PROCS:=0} | ||||
| __MEMPOOL_API_URL_PREFIX__=${MEMPOOL_API_URL_PREFIX:=/api/v1/} | ||||
| @ -111,6 +112,7 @@ mkdir -p "${__MEMPOOL_CACHE_DIR__}" | ||||
| 
 | ||||
| sed -i "s/__MEMPOOL_NETWORK__/${__MEMPOOL_NETWORK__}/g" mempool-config.json | ||||
| sed -i "s/__MEMPOOL_BACKEND__/${__MEMPOOL_BACKEND__}/g" mempool-config.json | ||||
| sed -i "s/__MEMPOOL_ENABLED__/${__MEMPOOL_ENABLED__}/g" mempool-config.json | ||||
| sed -i "s/__MEMPOOL_HTTP_PORT__/${__MEMPOOL_HTTP_PORT__}/g" mempool-config.json | ||||
| sed -i "s/__MEMPOOL_SPAWN_CLUSTER_PROCS__/${__MEMPOOL_SPAWN_CLUSTER_PROCS__}/g" mempool-config.json | ||||
| sed -i "s!__MEMPOOL_API_URL_PREFIX__!${__MEMPOOL_API_URL_PREFIX__}!g" mempool-config.json | ||||
|  | ||||
| @ -8,7 +8,9 @@ WORKDIR /build | ||||
| COPY . . | ||||
| RUN apt-get update | ||||
| RUN apt-get install -y build-essential rsync | ||||
| RUN cp mempool-frontend-config.sample.json mempool-frontend-config.json | ||||
| RUN npm install --omit=dev --omit=optional | ||||
| 
 | ||||
| RUN npm run build | ||||
| 
 | ||||
| FROM nginx:1.17.8-alpine | ||||
| @ -28,7 +30,9 @@ RUN chown -R 1000:1000 /patch && chmod -R 755 /patch && \ | ||||
|         chown -R 1000:1000 /var/cache/nginx && \ | ||||
|         chown -R 1000:1000 /var/log/nginx && \ | ||||
|         chown -R 1000:1000 /etc/nginx/nginx.conf && \ | ||||
|         chown -R 1000:1000 /etc/nginx/conf.d | ||||
|         chown -R 1000:1000 /etc/nginx/conf.d && \ | ||||
|         chown -R 1000:1000 /var/www/mempool | ||||
| 
 | ||||
| RUN touch /var/run/nginx.pid && \ | ||||
|         chown -R 1000:1000 /var/run/nginx.pid | ||||
| 
 | ||||
|  | ||||
| @ -10,4 +10,51 @@ cp /etc/nginx/nginx.conf /patch/nginx.conf | ||||
| sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf | ||||
| cat /patch/nginx.conf > /etc/nginx/nginx.conf | ||||
| 
 | ||||
| # Runtime overrides - read env vars defined in docker compose | ||||
| 
 | ||||
| __TESTNET_ENABLED__=${TESTNET_ENABLED:=false} | ||||
| __SIGNET_ENABLED__=${SIGNET_ENABLED:=false} | ||||
| __LIQUID_ENABLED__=${LIQUID_EANBLED:=false} | ||||
| __LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false} | ||||
| __BISQ_ENABLED__=${BISQ_ENABLED:=false} | ||||
| __BISQ_SEPARATE_BACKEND__=${BISQ_SEPARATE_BACKEND:=false} | ||||
| __ITEMS_PER_PAGE__=${ITEMS_PER_PAGE:=10} | ||||
| __KEEP_BLOCKS_AMOUNT__=${KEEP_BLOCKS_AMOUNT:=8} | ||||
| __NGINX_PROTOCOL__=${NGINX_PROTOCOL:=http} | ||||
| __NGINX_HOSTNAME__=${NGINX_HOSTNAME:=localhost} | ||||
| __NGINX_PORT__=${NGINX_PORT:=8999} | ||||
| __BLOCK_WEIGHT_UNITS__=${BLOCK_WEIGHT_UNITS:=4000000} | ||||
| __MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_BLOCKS_AMOUNT:=8} | ||||
| __BASE_MODULE__=${BASE_MODULE:=mempool} | ||||
| __MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://mempool.space} | ||||
| __LIQUID_WEBSITE_URL__=${LIQUID_WEBSITE_URL:=https://liquid.network} | ||||
| __BISQ_WEBSITE_URL__=${BISQ_WEBSITE_URL:=https://bisq.markets} | ||||
| __MINING_DASHBOARD__=${MINING_DASHBOARD:=true} | ||||
| __LIGHTNING__=${LIGHTNING:=false} | ||||
| 
 | ||||
| # Export as environment variables to be used by envsubst | ||||
| export __TESTNET_ENABLED__ | ||||
| export __SIGNET_ENABLED__ | ||||
| export __LIQUID_ENABLED__ | ||||
| export __LIQUID_TESTNET_ENABLED__ | ||||
| export __BISQ_ENABLED__ | ||||
| export __BISQ_SEPARATE_BACKEND__ | ||||
| export __ITEMS_PER_PAGE__ | ||||
| export __KEEP_BLOCKS_AMOUNT__ | ||||
| export __NGINX_PROTOCOL__ | ||||
| export __NGINX_HOSTNAME__ | ||||
| export __NGINX_PORT__ | ||||
| export __BLOCK_WEIGHT_UNITS__ | ||||
| export __MEMPOOL_BLOCKS_AMOUNT__ | ||||
| export __BASE_MODULE__ | ||||
| export __MEMPOOL_WEBSITE_URL__ | ||||
| export __LIQUID_WEBSITE_URL__ | ||||
| export __BISQ_WEBSITE_URL__ | ||||
| export __MINING_DASHBOARD__ | ||||
| export __LIGHTNING__ | ||||
| 
 | ||||
| folder=$(find /var/www/mempool -name "config.js" | xargs dirname) | ||||
| echo ${folder} | ||||
| envsubst < ${folder}/config.template.js > ${folder}/config.js | ||||
| 
 | ||||
| exec "$@" | ||||
|  | ||||
| @ -152,15 +152,14 @@ | ||||
|             "assets": [ | ||||
|               "src/favicon.ico", | ||||
|               "src/resources", | ||||
|               "src/robots.txt" | ||||
|               "src/robots.txt", | ||||
|               "src/config.js", | ||||
|               "src/config.template.js" | ||||
|             ], | ||||
|             "styles": [ | ||||
|               "src/styles.scss", | ||||
|               "node_modules/@fortawesome/fontawesome-svg-core/styles.css" | ||||
|             ], | ||||
|             "scripts": [ | ||||
|               "generated-config.js" | ||||
|             ], | ||||
|             "vendorChunk": true, | ||||
|             "extractLicenses": false, | ||||
|             "buildOptimizer": false, | ||||
| @ -222,6 +221,10 @@ | ||||
|               "proxyConfig": "proxy.conf.local.js", | ||||
|               "verbose": true | ||||
|             }, | ||||
|             "local-esplora": { | ||||
|               "proxyConfig": "proxy.conf.local-esplora.js", | ||||
|               "verbose": true | ||||
|             }, | ||||
|             "mixed": { | ||||
|               "proxyConfig": "proxy.conf.mixed.js", | ||||
|               "verbose": true | ||||
| @ -265,57 +268,6 @@ | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "server": { | ||||
|           "builder": "@angular-devkit/build-angular:server", | ||||
|           "options": { | ||||
|             "outputPath": "dist/mempool/server", | ||||
|             "main": "server.ts", | ||||
|             "tsConfig": "tsconfig.server.json", | ||||
|             "sourceMap": true, | ||||
|             "optimization": false | ||||
|           }, | ||||
|           "configurations": { | ||||
|             "production": { | ||||
|               "outputHashing": "media", | ||||
|               "fileReplacements": [ | ||||
|                 { | ||||
|                   "replace": "src/environments/environment.ts", | ||||
|                   "with": "src/environments/environment.prod.ts" | ||||
|                 } | ||||
|               ], | ||||
|               "sourceMap": false, | ||||
|               "localize": true, | ||||
|               "optimization": true | ||||
|             } | ||||
|           }, | ||||
|           "defaultConfiguration": "" | ||||
|         }, | ||||
|         "serve-ssr": { | ||||
|           "builder": "@nguniversal/builders:ssr-dev-server", | ||||
|           "options": { | ||||
|             "browserTarget": "mempool:build", | ||||
|             "serverTarget": "mempool:server" | ||||
|           }, | ||||
|           "configurations": { | ||||
|             "production": { | ||||
|               "browserTarget": "mempool:build:production", | ||||
|               "serverTarget": "mempool:server:production" | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "prerender": { | ||||
|           "builder": "@nguniversal/builders:prerender", | ||||
|           "options": { | ||||
|             "browserTarget": "mempool:build:production", | ||||
|             "serverTarget": "mempool:server:production", | ||||
|             "routes": [ | ||||
|               "/" | ||||
|             ] | ||||
|           }, | ||||
|           "configurations": { | ||||
|             "production": {} | ||||
|           } | ||||
|         }, | ||||
|         "cypress-run": { | ||||
|           "builder": "@cypress/schematic:cypress", | ||||
|           "options": { | ||||
| @ -336,6 +288,5 @@ | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "defaultProject": "mempool" | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -2,7 +2,8 @@ var fs = require('fs'); | ||||
| const { spawnSync } = require('child_process'); | ||||
| 
 | ||||
| const CONFIG_FILE_NAME = 'mempool-frontend-config.json'; | ||||
| const GENERATED_CONFIG_FILE_NAME = 'generated-config.js'; | ||||
| const GENERATED_CONFIG_FILE_NAME = 'src/resources/config.js'; | ||||
| const GENERATED_TEMPLATE_CONFIG_FILE_NAME = 'src/resources/config.template.js'; | ||||
| 
 | ||||
| let settings = []; | ||||
| let configContent = {}; | ||||
| @ -70,7 +71,14 @@ const newConfig = `(function (window) { | ||||
|     window.__env.${obj.key} = ${typeof obj.value === 'string' ? `'${obj.value}'` : obj.value};`, '')}
 | ||||
|     window.__env.GIT_COMMIT_HASH = '${gitCommitHash}'; | ||||
|     window.__env.PACKAGE_JSON_VERSION = '${packetJsonVersion}'; | ||||
|   }(global || this));`;
 | ||||
|   }(this));`;
 | ||||
| 
 | ||||
| const newConfigTemplate = `(function (window) {
 | ||||
|   window.__env = window.__env || {};${settings.reduce((str, obj) => `${str} | ||||
|     window.__env.${obj.key} = ${typeof obj.value === 'string' ? `'\${__${obj.key}__}'` : `\${__${obj.key}__}`};`, '')}
 | ||||
|     window.__env.GIT_COMMIT_HASH = '${gitCommitHash}'; | ||||
|     window.__env.PACKAGE_JSON_VERSION = '${packetJsonVersion}'; | ||||
|   }(this));`;
 | ||||
| 
 | ||||
| function readConfig(path) { | ||||
|   try { | ||||
| @ -89,6 +97,16 @@ function writeConfig(path, config) { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function writeConfigTemplate(path, config) { | ||||
|   try { | ||||
|     fs.writeFileSync(path, config, 'utf8'); | ||||
|   } catch (e) { | ||||
|     throw new Error(e); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| writeConfigTemplate(GENERATED_TEMPLATE_CONFIG_FILE_NAME, newConfigTemplate); | ||||
| 
 | ||||
| const currentConfig = readConfig(GENERATED_CONFIG_FILE_NAME); | ||||
| 
 | ||||
| if (currentConfig && currentConfig === newConfig) { | ||||
| @ -106,4 +124,4 @@ if (currentConfig && currentConfig === newConfig) { | ||||
|   console.log('NEW CONFIG: ', newConfig); | ||||
|   writeConfig(GENERATED_CONFIG_FILE_NAME, newConfig); | ||||
|   console.log(`${GENERATED_CONFIG_FILE_NAME} file updated`); | ||||
| }; | ||||
| } | ||||
|  | ||||
							
								
								
									
										13897
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										13897
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -29,6 +29,7 @@ | ||||
|     "serve:local-prod": "npm run generate-config && npm run ng -- serve -c local-prod", | ||||
|     "serve:local-staging": "npm run generate-config && npm run ng -- serve -c local-staging", | ||||
|     "start": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local", | ||||
|     "start:local-esplora": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-esplora", | ||||
|     "start:stg": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c staging", | ||||
|     "start:local-prod": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-prod", | ||||
|     "start:local-staging": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-staging", | ||||
| @ -50,9 +51,6 @@ | ||||
|     "config:defaults:mempool": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=mempool BLOCK_WEIGHT_UNITS=4000000 && npm run generate-config", | ||||
|     "config:defaults:liquid": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=liquid BLOCK_WEIGHT_UNITS=300000 && npm run generate-config", | ||||
|     "config:defaults:bisq": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=bisq BLOCK_WEIGHT_UNITS=4000000 && npm run generate-config", | ||||
|     "dev:ssr": "npm run generate-config && npm run ng -- run mempool:serve-ssr", | ||||
|     "serve:ssr": "node server.run.js", | ||||
|     "build:ssr": "npm run build && npm run ng -- run mempool:server:production && npm run tsc -- server.run.ts", | ||||
|     "prerender": "npm run ng -- run mempool:prerender", | ||||
|     "cypress:open": "cypress open", | ||||
|     "cypress:run": "cypress run", | ||||
| @ -63,48 +61,44 @@ | ||||
|     "cypress:run:ci:staging": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:run:record" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@angular-devkit/build-angular": "~13.3.7", | ||||
|     "@angular/animations": "~13.3.10", | ||||
|     "@angular/cli": "~13.3.7", | ||||
|     "@angular/common": "~13.3.10", | ||||
|     "@angular/compiler": "~13.3.10", | ||||
|     "@angular/core": "~13.3.10", | ||||
|     "@angular/forms": "~13.3.10", | ||||
|     "@angular/localize": "~13.3.10", | ||||
|     "@angular/platform-browser": "~13.3.10", | ||||
|     "@angular/platform-browser-dynamic": "~13.3.10", | ||||
|     "@angular/platform-server": "~13.3.10", | ||||
|     "@angular/router": "~13.3.10", | ||||
|     "@fortawesome/angular-fontawesome": "~0.10.2", | ||||
|     "@fortawesome/fontawesome-common-types": "~6.1.1", | ||||
|     "@fortawesome/fontawesome-svg-core": "~6.1.1", | ||||
|     "@fortawesome/free-solid-svg-icons": "~6.1.1", | ||||
|     "@angular-devkit/build-angular": "^14.2.10", | ||||
|     "@angular/animations": "^14.2.12", | ||||
|     "@angular/cli": "^14.2.10", | ||||
|     "@angular/common": "^14.2.12", | ||||
|     "@angular/compiler": "^14.2.12", | ||||
|     "@angular/core": "^14.2.12", | ||||
|     "@angular/forms": "^14.2.12", | ||||
|     "@angular/localize": "^14.2.12", | ||||
|     "@angular/platform-browser": "^14.2.12", | ||||
|     "@angular/platform-browser-dynamic": "^14.2.12", | ||||
|     "@angular/platform-server": "^14.2.12", | ||||
|     "@angular/router": "^14.2.12", | ||||
|     "@fortawesome/angular-fontawesome": "~0.11.1", | ||||
|     "@fortawesome/fontawesome-common-types": "~6.2.1", | ||||
|     "@fortawesome/fontawesome-svg-core": "~6.2.1", | ||||
|     "@fortawesome/free-solid-svg-icons": "~6.2.1", | ||||
|     "@mempool/mempool.js": "2.3.0", | ||||
|     "@ng-bootstrap/ng-bootstrap": "^11.0.0", | ||||
|     "@nguniversal/express-engine": "~13.1.1", | ||||
|     "@types/qrcode": "~1.4.2", | ||||
|     "bootstrap": "~4.5.0", | ||||
|     "@ng-bootstrap/ng-bootstrap": "^13.1.1", | ||||
|     "@types/qrcode": "~1.5.0", | ||||
|     "bootstrap": "~4.6.1", | ||||
|     "browserify": "^17.0.0", | ||||
|     "clipboard": "^2.0.10", | ||||
|     "clipboard": "^2.0.11", | ||||
|     "domino": "^2.1.6", | ||||
|     "echarts": "~5.3.2", | ||||
|     "echarts-gl": "^2.0.9", | ||||
|     "express": "^4.17.1", | ||||
|     "lightweight-charts": "~3.8.0", | ||||
|     "ngx-echarts": "8.0.1", | ||||
|     "ngx-infinite-scroll": "^10.0.1", | ||||
|     "ngx-infinite-scroll": "^14.0.1", | ||||
|     "qrcode": "1.5.0", | ||||
|     "rxjs": "~7.5.5", | ||||
|     "tinyify": "^3.0.0", | ||||
|     "rxjs": "~7.5.7", | ||||
|     "tinyify": "^3.1.0", | ||||
|     "tlite": "^0.1.9", | ||||
|     "tslib": "~2.4.0", | ||||
|     "tslib": "~2.4.1", | ||||
|     "zone.js": "~0.11.5" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@angular/compiler-cli": "~13.3.10", | ||||
|     "@angular/language-service": "~13.3.10", | ||||
|     "@nguniversal/builders": "~13.1.1", | ||||
|     "@types/express": "^4.17.0", | ||||
|     "@angular/compiler-cli": "^14.2.12", | ||||
|     "@angular/language-service": "^14.2.12", | ||||
|     "@types/node": "^12.11.1", | ||||
|     "@typescript-eslint/eslint-plugin": "^5.30.5", | ||||
|     "@typescript-eslint/parser": "^5.30.5", | ||||
| @ -115,11 +109,11 @@ | ||||
|     "typescript": "~4.6.4" | ||||
|   }, | ||||
|   "optionalDependencies": { | ||||
|     "@cypress/schematic": "~2.0.0", | ||||
|     "cypress": "^10.3.0", | ||||
|     "cypress-fail-on-console-error": "~3.0.0", | ||||
|     "@cypress/schematic": "~2.3.0", | ||||
|     "cypress": "^11.2.0", | ||||
|     "cypress-fail-on-console-error": "~4.0.2", | ||||
|     "cypress-wait-until": "^1.7.2", | ||||
|     "mock-socket": "~9.1.4", | ||||
|     "mock-socket": "~9.1.5", | ||||
|     "start-server-and-test": "~1.14.0" | ||||
|   }, | ||||
|   "scarfSettings": { | ||||
|  | ||||
							
								
								
									
										137
									
								
								frontend/proxy.conf.local-esplora.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								frontend/proxy.conf.local-esplora.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,137 @@ | ||||
| const fs = require('fs'); | ||||
| 
 | ||||
| const FRONTEND_CONFIG_FILE_NAME = 'mempool-frontend-config.json'; | ||||
| 
 | ||||
| let configContent; | ||||
| 
 | ||||
| // Read frontend config 
 | ||||
| try { | ||||
|     const rawConfig = fs.readFileSync(FRONTEND_CONFIG_FILE_NAME); | ||||
|     configContent = JSON.parse(rawConfig); | ||||
|     console.log(`${FRONTEND_CONFIG_FILE_NAME} file found, using provided config`); | ||||
| } catch (e) { | ||||
|     console.log(e); | ||||
|     if (e.code !== 'ENOENT') { | ||||
|       throw new Error(e); | ||||
|   } else { | ||||
|       console.log(`${FRONTEND_CONFIG_FILE_NAME} file not found, using default config`); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| let PROXY_CONFIG = []; | ||||
| 
 | ||||
| if (configContent && configContent.BASE_MODULE === 'liquid') { | ||||
|   PROXY_CONFIG.push(...[ | ||||
|     { | ||||
|       context: ['/liquid/api/v1/**'], | ||||
|       target: `http://127.0.0.1:8999`, | ||||
|       secure: false, | ||||
|       ws: true, | ||||
|       changeOrigin: true, | ||||
|       proxyTimeout: 30000, | ||||
|       pathRewrite: { | ||||
|           "^/liquid": "" | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       context: ['/liquid/api/**'], | ||||
|       target: `http://127.0.0.1:3000`, | ||||
|       secure: false, | ||||
|       changeOrigin: true, | ||||
|       proxyTimeout: 30000, | ||||
|       pathRewrite: { | ||||
|           "^/liquid/api/": "" | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       context: ['/liquidtestnet/api/v1/**'], | ||||
|       target: `http://127.0.0.1:8999`, | ||||
|       secure: false, | ||||
|       ws: true, | ||||
|       changeOrigin: true, | ||||
|       proxyTimeout: 30000, | ||||
|       pathRewrite: { | ||||
|           "^/liquidtestnet": "" | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       context: ['/liquidtestnet/api/**'], | ||||
|       target: `http://127.0.0.1:3000`, | ||||
|       secure: false, | ||||
|       changeOrigin: true, | ||||
|       proxyTimeout: 30000, | ||||
|       pathRewrite: { | ||||
|           "^/liquidtestnet/api/": "/" | ||||
|       }, | ||||
|     }, | ||||
|   ]); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| if (configContent && configContent.BASE_MODULE === 'bisq') { | ||||
|   PROXY_CONFIG.push(...[ | ||||
|     { | ||||
|       context: ['/bisq/api/v1/ws'], | ||||
|       target: `http://127.0.0.1:8999`, | ||||
|       secure: false, | ||||
|       ws: true, | ||||
|       changeOrigin: true, | ||||
|       proxyTimeout: 30000, | ||||
|       pathRewrite: { | ||||
|           "^/bisq": "" | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       context: ['/bisq/api/v1/**'], | ||||
|       target: `http://127.0.0.1:8999`, | ||||
|       secure: false, | ||||
|       changeOrigin: true, | ||||
|       proxyTimeout: 30000, | ||||
|     }, | ||||
|     { | ||||
|       context: ['/bisq/api/**'], | ||||
|       target: `http://127.0.0.1:8999`, | ||||
|       secure: false, | ||||
|       changeOrigin: true, | ||||
|       proxyTimeout: 30000, | ||||
|       pathRewrite: { | ||||
|           "^/bisq/api/": "/api/v1/bisq/" | ||||
|       }, | ||||
|     } | ||||
|   ]); | ||||
| } | ||||
| 
 | ||||
| PROXY_CONFIG.push(...[ | ||||
|   { | ||||
|     context: ['/testnet/api/v1/lightning/**'], | ||||
|     target: `http://127.0.0.1:8999`, | ||||
|     secure: false, | ||||
|     changeOrigin: true, | ||||
|     proxyTimeout: 30000, | ||||
|     pathRewrite: { | ||||
|         "^/testnet": "" | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     context: ['/api/v1/**'], | ||||
|     target: `http://127.0.0.1:8999`, | ||||
|     secure: false, | ||||
|     ws: true, | ||||
|     changeOrigin: true, | ||||
|     proxyTimeout: 30000, | ||||
|   }, | ||||
|   { | ||||
|     context: ['/api/**'], | ||||
|     target: `http://127.0.0.1:3000`, | ||||
|     secure: false, | ||||
|     changeOrigin: true, | ||||
|     proxyTimeout: 30000, | ||||
|     pathRewrite: { | ||||
|         "^/api": "" | ||||
|     }, | ||||
|   } | ||||
| ]); | ||||
| 
 | ||||
| console.log(PROXY_CONFIG); | ||||
| 
 | ||||
| module.exports = PROXY_CONFIG; | ||||
| @ -3,9 +3,9 @@ const fs = require('fs'); | ||||
| let PROXY_CONFIG = require('./proxy.conf'); | ||||
| 
 | ||||
| PROXY_CONFIG.forEach(entry => { | ||||
|   entry.target = entry.target.replace("mempool.space", "mempool-staging.fra.mempool.space"); | ||||
|   entry.target = entry.target.replace("liquid.network", "liquid-staging.fra.mempool.space"); | ||||
|   entry.target = entry.target.replace("bisq.markets", "bisq-staging.fra.mempool.space"); | ||||
|   entry.target = entry.target.replace("mempool.space", "mempool-staging.tk7.mempool.space"); | ||||
|   entry.target = entry.target.replace("liquid.network", "liquid-staging.tk7.mempool.space"); | ||||
|   entry.target = entry.target.replace("bisq.markets", "bisq-staging.tk7.mempool.space"); | ||||
| }); | ||||
| 
 | ||||
| module.exports = PROXY_CONFIG; | ||||
|  | ||||
| @ -1,96 +0,0 @@ | ||||
| import 'zone.js/node'; | ||||
| import './generated-config'; | ||||
| 
 | ||||
| import * as domino from 'domino'; | ||||
| import * as express from 'express'; | ||||
| import * as fs from 'fs'; | ||||
| import * as path from 'path'; | ||||
| 
 | ||||
| const {readFileSync, existsSync} = require('fs'); | ||||
| const {createProxyMiddleware} = require('http-proxy-middleware'); | ||||
| 
 | ||||
| const template = fs.readFileSync(path.join(process.cwd(), 'dist/mempool/browser/en-US/', 'index.html')).toString(); | ||||
| const win = domino.createWindow(template); | ||||
| 
 | ||||
| // @ts-ignore
 | ||||
| win.__env = global.__env; | ||||
| 
 | ||||
| // @ts-ignore
 | ||||
| win.matchMedia = () => { | ||||
|   return { | ||||
|     matches: true | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| // @ts-ignore
 | ||||
| win.setTimeout = (fn) => { fn(); }; | ||||
| win.document.body.scrollTo = (() => {}); | ||||
| // @ts-ignore
 | ||||
| global['window'] = win; | ||||
| global['document'] = win.document; | ||||
| // @ts-ignore
 | ||||
| global['history'] = { state: { } }; | ||||
| 
 | ||||
| global['localStorage'] = { | ||||
|   getItem: () => '', | ||||
|   setItem: () => {}, | ||||
|   removeItem: () => {}, | ||||
|   clear: () => {}, | ||||
|   length: 0, | ||||
|   key: () => '', | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Return the list of supported and actually active locales | ||||
|  */ | ||||
| function getActiveLocales() { | ||||
|   const angularConfig = JSON.parse(readFileSync('angular.json', 'utf8')); | ||||
| 
 | ||||
|   const supportedLocales = [ | ||||
|     angularConfig.projects.mempool.i18n.sourceLocale, | ||||
|     ...Object.keys(angularConfig.projects.mempool.i18n.locales), | ||||
|   ]; | ||||
| 
 | ||||
|   return supportedLocales.filter(locale => existsSync(`./dist/mempool/server/${locale}`)); | ||||
| } | ||||
| 
 | ||||
| function app() { | ||||
|   const server = express(); | ||||
| 
 | ||||
|   // proxy API to nginx
 | ||||
|   server.get('/api/**', createProxyMiddleware({ | ||||
|     // @ts-ignore
 | ||||
|     target: win.__env.NGINX_PROTOCOL + '://' + win.__env.NGINX_HOSTNAME + ':' + win.__env.NGINX_PORT, | ||||
|     changeOrigin: true, | ||||
|   })); | ||||
| 
 | ||||
|   // map / and /en to en-US
 | ||||
|   const defaultLocale = 'en-US'; | ||||
|   console.log(`serving default locale: ${defaultLocale}`); | ||||
|   const appServerModule = require(`./dist/mempool/server/${defaultLocale}/main.js`); | ||||
|   server.use('/', appServerModule.app(defaultLocale)); | ||||
|   server.use('/en', appServerModule.app(defaultLocale)); | ||||
| 
 | ||||
|   // map each locale to its localized main.js
 | ||||
|   getActiveLocales().forEach(locale => { | ||||
|     console.log('serving locale:', locale); | ||||
|     const appServerModule = require(`./dist/mempool/server/${locale}/main.js`); | ||||
| 
 | ||||
|     // map everything to itself
 | ||||
|     server.use(`/${locale}`, appServerModule.app(locale)); | ||||
| 
 | ||||
|   }); | ||||
| 
 | ||||
|   return server; | ||||
| } | ||||
| 
 | ||||
| function run() { | ||||
|   const port = process.env.PORT || 4000; | ||||
| 
 | ||||
|   // Start up the Node server
 | ||||
|   app().listen(port, () => { | ||||
|     console.log(`Node Express server listening on port ${port}`); | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| run(); | ||||
| @ -1,160 +0,0 @@ | ||||
| import 'zone.js/node'; | ||||
| import './generated-config'; | ||||
| 
 | ||||
| import { ngExpressEngine } from '@nguniversal/express-engine'; | ||||
| import * as express from 'express'; | ||||
| import * as fs from 'fs'; | ||||
| import * as path from 'path'; | ||||
| import * as domino from 'domino'; | ||||
| 
 | ||||
| import { join } from 'path'; | ||||
| import { AppServerModule } from './src/main.server'; | ||||
| import { APP_BASE_HREF } from '@angular/common'; | ||||
| import { existsSync } from 'fs'; | ||||
| 
 | ||||
| const template = fs.readFileSync(path.join(process.cwd(), 'dist/mempool/browser/en-US/', 'index.html')).toString(); | ||||
| const win = domino.createWindow(template); | ||||
| 
 | ||||
| // @ts-ignore
 | ||||
| win.__env = global.__env; | ||||
| 
 | ||||
| // @ts-ignore
 | ||||
| win.matchMedia = () => { | ||||
|   return { | ||||
|     matches: true | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| // @ts-ignore
 | ||||
| win.setTimeout = (fn) => { fn(); }; | ||||
| win.document.body.scrollTo = (() => {}); | ||||
| // @ts-ignore
 | ||||
| global['window'] = win; | ||||
| global['document'] = win.document; | ||||
| // @ts-ignore
 | ||||
| global['history'] = { state: { } }; | ||||
| 
 | ||||
| global['localStorage'] = { | ||||
|   getItem: () => '', | ||||
|   setItem: () => {}, | ||||
|   removeItem: () => {}, | ||||
|   clear: () => {}, | ||||
|   length: 0, | ||||
|   key: () => '', | ||||
| }; | ||||
| 
 | ||||
| // The Express app is exported so that it can be used by serverless Functions.
 | ||||
| export function app(locale: string): express.Express { | ||||
|   const server = express(); | ||||
|   const distFolder = join(process.cwd(), `dist/mempool/browser/${locale}`); | ||||
|   const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index'; | ||||
| 
 | ||||
|   // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
 | ||||
|   server.engine('html', ngExpressEngine({ | ||||
|     bootstrap: AppServerModule, | ||||
|   })); | ||||
| 
 | ||||
|   server.set('view engine', 'html'); | ||||
|   server.set('views', distFolder); | ||||
| 
 | ||||
|   // only handle URLs that actually exist
 | ||||
|   //server.get(locale, getLocalizedSSR(indexHtml));
 | ||||
|   server.get('/', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/tx/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/block/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/mempool-block/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/address/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/blocks', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/mining/pools', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/mining/pool/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/graphs', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid/tx/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid/block/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid/mempool-block/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid/address/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid/asset/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid/blocks', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid/graphs', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid/assets', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid/api', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid/tv', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid/status', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/liquid/about', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/testnet', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/testnet/tx/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/testnet/block/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/testnet/mempool-block/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/testnet/address/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/testnet/blocks', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/testnet/mining/pools', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/testnet/graphs', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/testnet/api', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/testnet/tv', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/testnet/status', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/testnet/about', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/signet', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/signet/tx/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/signet/block/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/signet/mempool-block/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/signet/address/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/signet/blocks', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/signet/mining/pools', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/signet/graphs', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/signet/api', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/signet/tv', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/signet/status', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/signet/about', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/bisq', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/bisq/tx/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/bisq/blocks', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/bisq/block/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/bisq/address/*', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/bisq/stats', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/bisq/about', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/bisq/api', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/about', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/api', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/tv', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/status', getLocalizedSSR(indexHtml)); | ||||
|   server.get('/terms-of-service', getLocalizedSSR(indexHtml)); | ||||
| 
 | ||||
|   // fallback to static file handler so we send HTTP 404 to nginx
 | ||||
|   server.get('/**', express.static(distFolder, { maxAge: '1y' })); | ||||
| 
 | ||||
|   return server; | ||||
| } | ||||
| 
 | ||||
| function getLocalizedSSR(indexHtml) { | ||||
|   return (req, res) => { | ||||
|     res.render(indexHtml, { | ||||
|       req, | ||||
|       providers: [ | ||||
|         { provide: APP_BASE_HREF, useValue: req.baseUrl } | ||||
|       ] | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // only used for development mode
 | ||||
| function run(): void { | ||||
|   const port = process.env.PORT || 4000; | ||||
| 
 | ||||
|   // Start up the Node server
 | ||||
|   const server = app('en-US'); | ||||
|   server.listen(port, () => { | ||||
|     console.log(`Node Express server listening on port ${port}`); | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| // Webpack will replace 'require' with '__webpack_require__'
 | ||||
| // '__non_webpack_require__' is a proxy to Node 'require'
 | ||||
| // The below code is to ensure that the server is run only when not requiring the bundle.
 | ||||
| declare const __non_webpack_require__: NodeRequire; | ||||
| const mainModule = __non_webpack_require__.main; | ||||
| const moduleFilename = mainModule && mainModule.filename || ''; | ||||
| if (moduleFilename === __filename || moduleFilename.includes('iisnode')) { | ||||
|   run(); | ||||
| } | ||||
| 
 | ||||
| export * from './src/main.server'; | ||||
| @ -4,7 +4,6 @@ import { AppPreloadingStrategy } from './app.preloading-strategy' | ||||
| import { StartComponent } from './components/start/start.component'; | ||||
| import { TransactionComponent } from './components/transaction/transaction.component'; | ||||
| import { BlockComponent } from './components/block/block.component'; | ||||
| import { BlockAuditComponent } from './components/block-audit/block-audit.component'; | ||||
| import { AddressComponent } from './components/address/address.component'; | ||||
| import { MasterPageComponent } from './components/master-page/master-page.component'; | ||||
| import { AboutComponent } from './components/about/about.component'; | ||||
| @ -103,16 +102,6 @@ let routes: Routes = [ | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
|           { | ||||
|             path: 'block-audit', | ||||
|             data: { networkSpecific: true }, | ||||
|             children: [ | ||||
|               { | ||||
|                 path: ':id', | ||||
|                 component: BlockAuditComponent, | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
|           { | ||||
|             path: 'docs', | ||||
|             loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule), | ||||
| @ -219,16 +208,6 @@ let routes: Routes = [ | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
|           { | ||||
|             path: 'block-audit', | ||||
|             data: { networkSpecific: true }, | ||||
|             children: [ | ||||
|               { | ||||
|                 path: ':id', | ||||
|                 component: BlockAuditComponent, | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
|           { | ||||
|             path: 'docs', | ||||
|             loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) | ||||
| @ -331,16 +310,6 @@ let routes: Routes = [ | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|       { | ||||
|         path: 'block-audit', | ||||
|         data: { networkSpecific: true }, | ||||
|         children: [ | ||||
|           { | ||||
|             path: ':id', | ||||
|             component: BlockAuditComponent | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|       { | ||||
|         path: 'docs', | ||||
|         loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) | ||||
| @ -658,7 +627,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { | ||||
| 
 | ||||
| @NgModule({ | ||||
|   imports: [RouterModule.forRoot(routes, { | ||||
|     initialNavigation: 'enabled', | ||||
|     initialNavigation: 'enabledBlocking', | ||||
|     scrollPositionRestoration: 'enabled', | ||||
|     anchorScrolling: 'enabled', | ||||
|     preloadingStrategy: AppPreloadingStrategy | ||||
|  | ||||
| @ -79,7 +79,7 @@ export const poolsColor = { | ||||
|    'binancepool': '#1E88E5', | ||||
|    'viabtc': '#039BE5', | ||||
|    'btccom': '#00897B', | ||||
|    'slushpool': '#00ACC1', | ||||
|    'braiinspool': '#00ACC1', | ||||
|    'sbicrypto': '#43A047', | ||||
|    'marapool': '#7CB342', | ||||
|    'luxor': '#C0CA33', | ||||
|  | ||||
| @ -10,27 +10,27 @@ | ||||
|       </div> | ||||
|    | ||||
|       <form [formGroup]="radioGroupForm" class="mb-3 radio-form"> | ||||
|         <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="interval"> | ||||
|           <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|             <input ngbButton type="radio" [value]="'half_hour'" (click)="setFragment('half_hour')"> 30M | ||||
|         <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|           <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('interval').value === 'half_hour'"> | ||||
|             <input type="radio" [value]="'half_hour'" (click)="setFragment('half_hour')" formControlName="interval"> 30M | ||||
|           </label> | ||||
|           <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|             <input ngbButton type="radio" [value]="'hour'" (click)="setFragment('hour')"> 1H | ||||
|           <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('interval').value === 'hour'"> | ||||
|             <input type="radio" [value]="'hour'" (click)="setFragment('hour')" formControlName="interval"> 1H | ||||
|           </label> | ||||
|           <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|             <input ngbButton type="radio" [value]="'half_day'" (click)="setFragment('half_day')"> 12H | ||||
|           <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('interval').value === 'half_day'"> | ||||
|             <input type="radio" [value]="'half_day'" (click)="setFragment('half_day')" formControlName="interval"> 12H | ||||
|           </label> | ||||
|           <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|             <input ngbButton type="radio" [value]="'day'" (click)="setFragment('day')"> 1D | ||||
|           <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('interval').value === 'day'"> | ||||
|             <input type="radio" [value]="'day'" (click)="setFragment('day')" formControlName="interval"> 1D | ||||
|           </label> | ||||
|           <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|             <input ngbButton type="radio" [value]="'week'" (click)="setFragment('week')"> 1W | ||||
|           <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('interval').value === 'week'"> | ||||
|             <input type="radio" [value]="'week'" (click)="setFragment('week')" formControlName="interval"> 1W | ||||
|           </label> | ||||
|           <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|             <input ngbButton type="radio" [value]="'month'" (click)="setFragment('month')"> 1M | ||||
|           <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('interval').value === 'month'"> | ||||
|             <input type="radio" [value]="'month'" (click)="setFragment('month')" formControlName="interval"> 1M | ||||
|           </label> | ||||
|           <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|             <input ngbButton type="radio" [value]="'year'" (click)="setFragment('year')"> 1Y | ||||
|           <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('interval').value === 'year'"> | ||||
|             <input type="radio" [value]="'year'" (click)="setFragment('year')" formControlName="interval"> 1Y | ||||
|           </label> | ||||
|         </div> | ||||
|       </form> | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; | ||||
| import { FormBuilder, FormGroup } from '@angular/forms'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; | ||||
| import { ActivatedRoute, Router } from '@angular/router'; | ||||
| import { combineLatest, merge, Observable, of } from 'rxjs'; | ||||
| import { map, switchMap } from 'rxjs/operators'; | ||||
| @ -19,7 +19,7 @@ export class BisqMarketComponent implements OnInit, OnDestroy { | ||||
|   currency$: Observable<any>; | ||||
|   offers$: Observable<OffersMarket>; | ||||
|   trades$: Observable<Trade[]>; | ||||
|   radioGroupForm: FormGroup; | ||||
|   radioGroupForm: UntypedFormGroup; | ||||
|   defaultInterval = 'day'; | ||||
| 
 | ||||
|   isLoadingGraph = false; | ||||
| @ -28,7 +28,7 @@ export class BisqMarketComponent implements OnInit, OnDestroy { | ||||
|     private websocketService: WebsocketService, | ||||
|     private route: ActivatedRoute, | ||||
|     private bisqApiService: BisqApiService, | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private seoService: SeoService, | ||||
|     private router: Router, | ||||
|   ) { } | ||||
|  | ||||
| @ -5,7 +5,7 @@ import { Observable, Subscription } from 'rxjs'; | ||||
| import { switchMap, map, tap } from 'rxjs/operators'; | ||||
| import { BisqApiService } from '../bisq-api.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { FormGroup, FormBuilder } from '@angular/forms'; | ||||
| import { UntypedFormGroup, UntypedFormBuilder } from '@angular/forms'; | ||||
| import { Router, ActivatedRoute } from '@angular/router'; | ||||
| import { IMultiSelectOption, IMultiSelectSettings, IMultiSelectTexts } from '../../components/ngx-bootstrap-multiselect/types' | ||||
| import { WebsocketService } from '../../services/websocket.service'; | ||||
| @ -23,7 +23,7 @@ export class BisqTransactionsComponent implements OnInit, OnDestroy { | ||||
|   fiveItemsPxSize = 250; | ||||
|   isLoading = true; | ||||
|   loadingItems: number[]; | ||||
|   radioGroupForm: FormGroup; | ||||
|   radioGroupForm: UntypedFormGroup; | ||||
|   types: string[] = []; | ||||
|   radioGroupSubscription: Subscription; | ||||
| 
 | ||||
| @ -70,7 +70,7 @@ export class BisqTransactionsComponent implements OnInit, OnDestroy { | ||||
|     private websocketService: WebsocketService, | ||||
|     private bisqApiService: BisqApiService, | ||||
|     private seoService: SeoService, | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private route: ActivatedRoute, | ||||
|     private router: Router, | ||||
|     private cd: ChangeDetectorRef, | ||||
|  | ||||
| @ -129,7 +129,7 @@ | ||||
|         <span>Gemini</span> | ||||
|       </a> | ||||
|       <a href="https://exodus.com/" target="_blank" title="Exodus"> | ||||
|         <svg width="80" height="80" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||||
|         <svg width="81" height="81" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||||
|           <circle cx="250" cy="250" r="250" fill="#1F2033"/> | ||||
|           <g clip-path="url(#clip0_2_14)"> | ||||
|             <path d="M411.042 178.303L271.79 87V138.048L361.121 196.097L350.612 229.351H271.79V271.648H350.612L361.121 304.903L271.79 362.952V414L411.042 322.989L388.271 250.646L411.042 178.303Z" fill="url(#paint0_linear_2_14)"/> | ||||
| @ -274,6 +274,10 @@ | ||||
|         <img class="image" src="/resources/profile/schildbach.svg" /> | ||||
|         <span>Schildbach</span> | ||||
|       </a> | ||||
|       <a href="https://github.com/nunchuk-io" target="_blank" title="Nunchuck"> | ||||
|         <img class="image" src="/resources/profile/nunchuk.svg" /> | ||||
|         <span>Nunchuk</span> | ||||
|       </a> | ||||
|     </div> | ||||
|   </div> | ||||
| 
 | ||||
|  | ||||
| @ -3,8 +3,8 @@ | ||||
|   text-align: center; | ||||
| 
 | ||||
|   .image { | ||||
|     width: 80px; | ||||
|     height: 80px; | ||||
|     width: 81px; | ||||
|     height: 81px; | ||||
|     background-size: 100%, 100%; | ||||
|     border-radius: 50%; | ||||
|     margin: 25px; | ||||
|  | ||||
| @ -4,7 +4,7 @@ import { map } from 'rxjs/operators'; | ||||
| import { moveDec } from '../../bitcoin.utils'; | ||||
| import { AssetsService } from '../../services/assets.service'; | ||||
| import { ElectrsApiService } from '../../services/electrs-api.service'; | ||||
| import { environment } from 'src/environments/environment'; | ||||
| import { environment } from '../../../environments/environment'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-asset-circulation', | ||||
|  | ||||
| @ -9,7 +9,7 @@ import { AudioService } from '../../services/audio.service'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { of, merge, Subscription, combineLatest } from 'rxjs'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { environment } from 'src/environments/environment'; | ||||
| import { environment } from '../../../environments/environment'; | ||||
| import { AssetsService } from '../../services/assets.service'; | ||||
| import { moveDec } from '../../bitcoin.utils'; | ||||
| 
 | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import { Component, OnInit, ViewChild } from '@angular/core'; | ||||
| import { FormBuilder, FormGroup, Validators } from '@angular/forms'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; | ||||
| import { Router } from '@angular/router'; | ||||
| import { NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { merge, Observable, of, Subject } from 'rxjs'; | ||||
| @ -9,7 +9,7 @@ import { AssetsService } from '../../../services/assets.service'; | ||||
| import { SeoService } from '../../../services/seo.service'; | ||||
| import { StateService } from '../../../services/state.service'; | ||||
| import { RelativeUrlPipe } from '../../../shared/pipes/relative-url/relative-url.pipe'; | ||||
| import { environment } from 'src/environments/environment'; | ||||
| import { environment } from '../../../../environments/environment'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-assets-nav', | ||||
| @ -19,7 +19,7 @@ import { environment } from 'src/environments/environment'; | ||||
| export class AssetsNavComponent implements OnInit { | ||||
|   @ViewChild('instance', {static: true}) instance: NgbTypeahead; | ||||
|   nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId; | ||||
|   searchForm: FormGroup; | ||||
|   searchForm: UntypedFormGroup; | ||||
|   assetsCache: AssetExtended[]; | ||||
| 
 | ||||
|   typeaheadSearchFn: ((text: Observable<string>) => Observable<readonly any[]>); | ||||
| @ -30,7 +30,7 @@ export class AssetsNavComponent implements OnInit { | ||||
|   itemsPerPage = 15; | ||||
| 
 | ||||
|   constructor( | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private seoService: SeoService, | ||||
|     private router: Router, | ||||
|     private assetsService: AssetsService, | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; | ||||
| import { AssetsService } from '../../services/assets.service'; | ||||
| import { environment } from 'src/environments/environment'; | ||||
| import { FormGroup } from '@angular/forms'; | ||||
| import { environment } from '../../../environments/environment'; | ||||
| import { UntypedFormGroup } from '@angular/forms'; | ||||
| import { filter, map, switchMap, take } from 'rxjs/operators'; | ||||
| import { ActivatedRoute, Router } from '@angular/router'; | ||||
| import { combineLatest, Observable } from 'rxjs'; | ||||
| @ -22,7 +22,7 @@ export class AssetsComponent implements OnInit { | ||||
| 
 | ||||
|   assets: AssetExtended[]; | ||||
|   assetsCache: AssetExtended[]; | ||||
|   searchForm: FormGroup; | ||||
|   searchForm: UntypedFormGroup; | ||||
|   assets$: Observable<AssetExtended[]>; | ||||
| 
 | ||||
|   page = 1; | ||||
|  | ||||
| @ -1,197 +0,0 @@ | ||||
| <div class="container-xl" (window:resize)="onResize($event)"> | ||||
| 
 | ||||
|   <div class="title-block" id="block"> | ||||
|     <h1> | ||||
|       <span class="next-previous-blocks"> | ||||
|         <span i18n="shared.block-audit-title">Block Audit</span> | ||||
|           | ||||
|         <a *ngIf="blockAudit" [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockAudit.height }}</a> | ||||
|           | ||||
|       </span> | ||||
|     </h1> | ||||
| 
 | ||||
|     <div class="grow"></div> | ||||
| 
 | ||||
|     <button [routerLink]="['/block/' | relativeUrl, blockHash]" class="btn btn-sm">✕</button> | ||||
|   </div> | ||||
| 
 | ||||
|   <div *ngIf="!error && !isLoading"> | ||||
|      | ||||
| 
 | ||||
|     <!-- OVERVIEW --> | ||||
|     <div class="box mb-3"> | ||||
|       <div class="row"> | ||||
|         <!-- LEFT COLUMN --> | ||||
|         <div class="col-sm"> | ||||
|           <table class="table table-borderless table-striped"> | ||||
|             <tbody> | ||||
|               <tr> | ||||
|                 <td class="td-width" i18n="block.hash">Hash</td> | ||||
|                 <td><a [routerLink]="['/block/' | relativeUrl, blockHash]" title="{{ blockHash }}">{{ blockHash | shortenString : 13 }}</a> | ||||
|                   <app-clipboard class="d-none d-sm-inline-block" [text]="blockHash"></app-clipboard> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="blockAudit.timestamp">Timestamp</td> | ||||
|                 <td> | ||||
|                   ‎{{ blockAudit.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} | ||||
|                   <div class="lg-inline"> | ||||
|                     <i class="symbol">(<app-time-since [time]="blockAudit.timestamp" [fastRender]="true"> | ||||
|                       </app-time-since>)</i> | ||||
|                   </div> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td class="td-width" i18n="shared.transaction-count">Transactions</td> | ||||
|                 <td>{{ blockAudit.tx_count }}</td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="blockAudit.size">Size</td> | ||||
|                 <td [innerHTML]="'‎' + (blockAudit.size | bytes: 2)"></td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="block.weight">Weight</td> | ||||
|                 <td [innerHTML]="'‎' + (blockAudit.weight | wuBytes: 2)"></td> | ||||
|               </tr> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
| 
 | ||||
|         <!-- RIGHT COLUMN --> | ||||
|         <div class="col-sm" *ngIf="blockAudit"> | ||||
|           <table class="table table-borderless table-striped"> | ||||
|             <tbody> | ||||
|               <tr> | ||||
|                 <td i18n="block.health">Block health</td> | ||||
|                 <td>{{ blockAudit.matchRate }}%</td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="block.missing-txs">Removed txs</td> | ||||
|                 <td>{{ blockAudit.missingTxs.length }}</td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="block.missing-txs">Omitted txs</td> | ||||
|                 <td>{{ numMissing }}</td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="block.added-txs">Added txs</td> | ||||
|                 <td>{{ blockAudit.addedTxs.length }}</td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="block.missing-txs">Included txs</td> | ||||
|                 <td>{{ numUnexpected }}</td> | ||||
|               </tr> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|       </div> <!-- row --> | ||||
|     </div> <!-- box --> | ||||
| 
 | ||||
|     <!-- ADDED vs MISSING button --> | ||||
|     <div class="d-flex justify-content-center menu mt-3 mb-3" *ngIf="isMobile"> | ||||
|       <a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'projected'" i18n="block.projected" | ||||
|         fragment="projected" (click)="changeMode('projected')">Projected</a> | ||||
|       <a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'actual'" i18n="block.actual" | ||||
|         fragment="actual" (click)="changeMode('actual')">Actual</a> | ||||
|     </div> | ||||
|   </div> | ||||
| 
 | ||||
|   <ng-template [ngIf]="!error && isLoading"> | ||||
|     <div class="title-block" id="block"> | ||||
|       <h1> | ||||
|         <span class="next-previous-blocks"> | ||||
|           <span i18n="shared.block-audit-title">Block Audit</span> | ||||
|             | ||||
|           <a *ngIf="blockAudit" [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockAudit.height }}</a> | ||||
|             | ||||
|         </span> | ||||
|       </h1> | ||||
| 
 | ||||
|       <div class="grow"></div> | ||||
| 
 | ||||
|       <button [routerLink]="['/' | relativeUrl]" class="btn btn-sm">✕</button> | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- OVERVIEW --> | ||||
|     <div class="box mb-3"> | ||||
|       <div class="row"> | ||||
|         <!-- LEFT COLUMN --> | ||||
|         <div class="col-sm"> | ||||
|           <table class="table table-borderless table-striped"> | ||||
|             <tbody> | ||||
|               <tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr> | ||||
|               <tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr> | ||||
|               <tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr> | ||||
|               <tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr> | ||||
|               <tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
| 
 | ||||
|         <!-- RIGHT COLUMN --> | ||||
|         <div class="col-sm"> | ||||
|           <table class="table table-borderless table-striped"> | ||||
|             <tbody> | ||||
|               <tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr> | ||||
|               <tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr> | ||||
|               <tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr> | ||||
|               <tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr> | ||||
|               <tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|       </div> <!-- row --> | ||||
|     </div> <!-- box --> | ||||
| 
 | ||||
|     <!-- ADDED vs MISSING button --> | ||||
|     <div class="d-flex justify-content-center menu mt-3 mb-3" *ngIf="isMobile"> | ||||
|       <a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'projected'" i18n="block.projected" | ||||
|         fragment="projected" (click)="changeMode('projected')">Projected</a> | ||||
|       <a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'actual'" i18n="block.actual" | ||||
|         fragment="actual" (click)="changeMode('actual')">Actual</a> | ||||
|     </div> | ||||
|   </ng-template> | ||||
| 
 | ||||
|   <ng-template [ngIf]="error"> | ||||
|     <div *ngIf="error && error.status === 404; else generalError" class="text-center"> | ||||
|       <br> | ||||
|       <b i18n="error.audit-unavailable">audit unavailable</b> | ||||
|       <br><br> | ||||
|       <i>{{ error.error }}</i> | ||||
|       <br> | ||||
|       <br> | ||||
|     </div> | ||||
|     <ng-template #generalError> | ||||
|       <div class="text-center"> | ||||
|         <br> | ||||
|         <span i18n="error.general-loading-data">Error loading data.</span> | ||||
|         <br><br> | ||||
|         <i>{{ error }}</i> | ||||
|         <br> | ||||
|         <br> | ||||
|       </div> | ||||
|     </ng-template> | ||||
|   </ng-template> | ||||
| 
 | ||||
|   <!-- VISUALIZATIONS --> | ||||
|   <div class="box" *ngIf="!error"> | ||||
|     <div class="row"> | ||||
|       <!-- MISSING TX RENDERING --> | ||||
|       <div class="col-sm" *ngIf="webGlEnabled"> | ||||
|         <h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.projected-block">Projected Block</h3> | ||||
|         <app-block-overview-graph #blockGraphProjected [isLoading]="isLoading" [resolution]="75" | ||||
|           [blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" | ||||
|           (txClickEvent)="onTxClick($event)"></app-block-overview-graph> | ||||
|       </div> | ||||
| 
 | ||||
|       <!-- ADDED TX RENDERING --> | ||||
|       <div class="col-sm" *ngIf="webGlEnabled && !isMobile"> | ||||
|         <h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.actual-block">Actual Block</h3> | ||||
|         <app-block-overview-graph #blockGraphActual [isLoading]="isLoading" [resolution]="75" | ||||
|           [blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" | ||||
|           (txClickEvent)="onTxClick($event)"></app-block-overview-graph> | ||||
|       </div> | ||||
|     </div> <!-- row --> | ||||
|   </div> <!-- box --> | ||||
| 
 | ||||
| </div> | ||||
| @ -1,44 +0,0 @@ | ||||
| .title-block { | ||||
|   border-top: none; | ||||
| } | ||||
| 
 | ||||
| .table { | ||||
|   tr td { | ||||
|     &:last-child { | ||||
|       text-align: right; | ||||
|       @media (min-width: 768px) { | ||||
|         text-align: left; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .block-tx-title { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   flex-direction: column; | ||||
|   position: relative; | ||||
|   @media (min-width: 550px) { | ||||
|     flex-direction: row; | ||||
|   } | ||||
|   h2 { | ||||
|     line-height: 1; | ||||
|     margin: 0; | ||||
|     position: relative; | ||||
|     padding-bottom: 10px; | ||||
|     @media (min-width: 550px) { | ||||
|       padding-bottom: 0px; | ||||
|       align-self: end; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .menu-button { | ||||
|   @media (min-width: 768px) { | ||||
|     max-width: 150px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .block-subtitle { | ||||
|   text-align: center; | ||||
| } | ||||
| @ -1,192 +0,0 @@ | ||||
| import { Component, OnDestroy, OnInit, AfterViewInit, ViewChildren, QueryList } from '@angular/core'; | ||||
| import { ActivatedRoute, ParamMap, Router } from '@angular/router'; | ||||
| import { Subscription, combineLatest } from 'rxjs'; | ||||
| import { map, switchMap, startWith, catchError } from 'rxjs/operators'; | ||||
| import { BlockAudit, TransactionStripped } from '../../interfaces/node-api.interface'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { detectWebGL } from '../../shared/graphs.utils'; | ||||
| import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; | ||||
| import { BlockOverviewGraphComponent } from '../block-overview-graph/block-overview-graph.component'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-block-audit', | ||||
|   templateUrl: './block-audit.component.html', | ||||
|   styleUrls: ['./block-audit.component.scss'], | ||||
|   styles: [` | ||||
|     .loadingGraphs { | ||||
|       position: absolute; | ||||
|       top: 50%; | ||||
|       left: calc(50% - 15px); | ||||
|       z-index: 100; | ||||
|     } | ||||
|   `],
 | ||||
| }) | ||||
| export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|   blockAudit: BlockAudit = undefined; | ||||
|   transactions: string[]; | ||||
|   auditSubscription: Subscription; | ||||
|   urlFragmentSubscription: Subscription; | ||||
| 
 | ||||
|   paginationMaxSize: number; | ||||
|   page = 1; | ||||
|   itemsPerPage: number; | ||||
| 
 | ||||
|   mode: 'projected' | 'actual' = 'projected'; | ||||
|   error: any; | ||||
|   isLoading = true; | ||||
|   webGlEnabled = true; | ||||
|   isMobile = window.innerWidth <= 767.98; | ||||
| 
 | ||||
|   childChangeSubscription: Subscription; | ||||
| 
 | ||||
|   blockHash: string; | ||||
|   numMissing: number = 0; | ||||
|   numUnexpected: number = 0; | ||||
| 
 | ||||
|   @ViewChildren('blockGraphProjected') blockGraphProjected: QueryList<BlockOverviewGraphComponent>; | ||||
|   @ViewChildren('blockGraphActual') blockGraphActual: QueryList<BlockOverviewGraphComponent>; | ||||
| 
 | ||||
|   constructor( | ||||
|     private route: ActivatedRoute, | ||||
|     public stateService: StateService, | ||||
|     private router: Router, | ||||
|     private apiService: ApiService | ||||
|   ) { | ||||
|     this.webGlEnabled = detectWebGL(); | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy() { | ||||
|     this.childChangeSubscription.unsubscribe(); | ||||
|     this.urlFragmentSubscription.unsubscribe(); | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; | ||||
|     this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE; | ||||
| 
 | ||||
|     this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => { | ||||
|       if (fragment === 'actual') { | ||||
|         this.mode = 'actual'; | ||||
|       } else { | ||||
|         this.mode = 'projected' | ||||
|       } | ||||
|       this.setupBlockGraphs(); | ||||
|     }); | ||||
| 
 | ||||
|     this.auditSubscription = this.route.paramMap.pipe( | ||||
|       switchMap((params: ParamMap) => { | ||||
|         this.blockHash = params.get('id') || null; | ||||
|         if (!this.blockHash) { | ||||
|           return null; | ||||
|         } | ||||
|         return this.apiService.getBlockAudit$(this.blockHash) | ||||
|           .pipe( | ||||
|             map((response) => { | ||||
|               const blockAudit = response.body; | ||||
|               const inTemplate = {}; | ||||
|               const inBlock = {}; | ||||
|               const isAdded = {}; | ||||
|               const isCensored = {}; | ||||
|               const isMissing = {}; | ||||
|               const isSelected = {}; | ||||
|               this.numMissing = 0; | ||||
|               this.numUnexpected = 0; | ||||
|               for (const tx of blockAudit.template) { | ||||
|                 inTemplate[tx.txid] = true; | ||||
|               } | ||||
|               for (const tx of blockAudit.transactions) { | ||||
|                 inBlock[tx.txid] = true; | ||||
|               } | ||||
|               for (const txid of blockAudit.addedTxs) { | ||||
|                 isAdded[txid] = true; | ||||
|               } | ||||
|               for (const txid of blockAudit.missingTxs) { | ||||
|                 isCensored[txid] = true; | ||||
|               } | ||||
|               // set transaction statuses
 | ||||
|               for (const tx of blockAudit.template) { | ||||
|                 if (isCensored[tx.txid]) { | ||||
|                   tx.status = 'censored'; | ||||
|                 } else if (inBlock[tx.txid]) { | ||||
|                   tx.status = 'found'; | ||||
|                 } else { | ||||
|                   tx.status = 'missing'; | ||||
|                   isMissing[tx.txid] = true; | ||||
|                   this.numMissing++; | ||||
|                 } | ||||
|               } | ||||
|               for (const [index, tx] of blockAudit.transactions.entries()) { | ||||
|                 if (isAdded[tx.txid]) { | ||||
|                   tx.status = 'added'; | ||||
|                 } else if (index === 0 || inTemplate[tx.txid]) { | ||||
|                   tx.status = 'found'; | ||||
|                 } else { | ||||
|                   tx.status = 'selected'; | ||||
|                   isSelected[tx.txid] = true; | ||||
|                   this.numUnexpected++; | ||||
|                 } | ||||
|               } | ||||
|               for (const tx of blockAudit.transactions) { | ||||
|                 inBlock[tx.txid] = true; | ||||
|               } | ||||
|               return blockAudit; | ||||
|             }) | ||||
|           ); | ||||
|       }), | ||||
|       catchError((err) => { | ||||
|         console.log(err); | ||||
|         this.error = err; | ||||
|         this.isLoading = false; | ||||
|         return null; | ||||
|       }), | ||||
|     ).subscribe((blockAudit) => { | ||||
|       this.blockAudit = blockAudit; | ||||
|       this.setupBlockGraphs(); | ||||
|       this.isLoading = false; | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   ngAfterViewInit() { | ||||
|     this.childChangeSubscription = combineLatest([this.blockGraphProjected.changes.pipe(startWith(null)), this.blockGraphActual.changes.pipe(startWith(null))]).subscribe(() => { | ||||
|       this.setupBlockGraphs(); | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   setupBlockGraphs() { | ||||
|     if (this.blockAudit) { | ||||
|       this.blockGraphProjected.forEach(graph => { | ||||
|         graph.destroy(); | ||||
|         if (this.isMobile && this.mode === 'actual') { | ||||
|           graph.setup(this.blockAudit.transactions); | ||||
|         } else { | ||||
|           graph.setup(this.blockAudit.template); | ||||
|         } | ||||
|       }) | ||||
|       this.blockGraphActual.forEach(graph => { | ||||
|         graph.destroy(); | ||||
|         graph.setup(this.blockAudit.transactions); | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onResize(event: any) { | ||||
|     const isMobile = event.target.innerWidth <= 767.98; | ||||
|     const changed = isMobile !== this.isMobile; | ||||
|     this.isMobile = isMobile; | ||||
|     this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5; | ||||
| 
 | ||||
|     if (changed) { | ||||
|       this.changeMode(this.mode); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   changeMode(mode: 'projected' | 'actual') { | ||||
|     this.router.navigate([], { fragment: mode }); | ||||
|   } | ||||
| 
 | ||||
|   onTxClick(event: TransactionStripped): void { | ||||
|     const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); | ||||
|     this.router.navigate([url]); | ||||
|   } | ||||
| } | ||||
| @ -10,36 +10,36 @@ | ||||
|     </div>   | ||||
| 
 | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 144"> | ||||
|           <input ngbButton type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 24h | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|         <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> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 432"> | ||||
|           <input ngbButton type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 3D | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 432" [class.active]="radioGroupForm.get('dateSpan').value === '3d'"> | ||||
|           <input type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 3D | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 1008"> | ||||
|           <input ngbButton type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 1W | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 1008" [class.active]="radioGroupForm.get('dateSpan').value === '1w'"> | ||||
|           <input type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 1W | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320"> | ||||
|           <input ngbButton type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 1M | ||||
|         <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-fee-rates' | relativeUrl]" formControlName="dateSpan"> 1M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 12960"> | ||||
|           <input ngbButton type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 3M | ||||
|         <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/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 3M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920"> | ||||
|           <input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 6M | ||||
|         <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/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 6M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 52560"> | ||||
|           <input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 1Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'"> | ||||
|           <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 1Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 105120"> | ||||
|           <input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 2Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'"> | ||||
|           <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 2Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 157680"> | ||||
|           <input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 3Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'"> | ||||
|           <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 3Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|           <input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> ALL | ||||
|         <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-fee-rates' | relativeUrl]" formControlName="dateSpan"> ALL | ||||
|         </label> | ||||
|       </div> | ||||
|     </form> | ||||
|  | ||||
| @ -5,7 +5,7 @@ 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 { FormBuilder, FormGroup } from '@angular/forms'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; | ||||
| import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| import { MiningService } from '../../services/mining.service'; | ||||
| @ -33,7 +33,7 @@ export class BlockFeeRatesGraphComponent implements OnInit { | ||||
|   @Input() left: number | string = 75; | ||||
| 
 | ||||
|   miningWindowPreference: string; | ||||
|   radioGroupForm: FormGroup; | ||||
|   radioGroupForm: UntypedFormGroup; | ||||
| 
 | ||||
|   chartOptions: EChartsOption = {}; | ||||
|   chartInitOptions = { | ||||
| @ -50,7 +50,7 @@ export class BlockFeeRatesGraphComponent implements OnInit { | ||||
|     @Inject(LOCALE_ID) public locale: string, | ||||
|     private seoService: SeoService, | ||||
|     private apiService: ApiService, | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private storageService: StorageService, | ||||
|     private miningService: MiningService, | ||||
|     private stateService: StateService, | ||||
|  | ||||
| @ -10,27 +10,27 @@ | ||||
|     </div>   | ||||
| 
 | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320"> | ||||
|           <input ngbButton type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"> 1M | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|         <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> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 12960"> | ||||
|           <input ngbButton type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"> 3M | ||||
|         <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/block-fees' | relativeUrl]" formControlName="dateSpan"> 3M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920"> | ||||
|           <input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"> 6M | ||||
|         <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/block-fees' | relativeUrl]" formControlName="dateSpan"> 6M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 52560"> | ||||
|           <input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"> 1Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'"> | ||||
|           <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]" formControlName="dateSpan"> 1Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 105120"> | ||||
|           <input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"> 2Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'"> | ||||
|           <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]" formControlName="dateSpan"> 2Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 157680"> | ||||
|           <input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"> 3Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'"> | ||||
|           <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]" formControlName="dateSpan"> 3Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|           <input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"> ALL | ||||
|         <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-fees' | relativeUrl]" formControlName="dateSpan"> ALL | ||||
|         </label> | ||||
|       </div> | ||||
|     </form> | ||||
|  | ||||
| @ -5,7 +5,7 @@ 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 { FormBuilder, FormGroup } from '@angular/forms'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; | ||||
| import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| import { MiningService } from '../../services/mining.service'; | ||||
| @ -31,7 +31,7 @@ export class BlockFeesGraphComponent implements OnInit { | ||||
|   @Input() left: number | string = 75; | ||||
| 
 | ||||
|   miningWindowPreference: string; | ||||
|   radioGroupForm: FormGroup; | ||||
|   radioGroupForm: UntypedFormGroup; | ||||
| 
 | ||||
|   chartOptions: EChartsOption = {}; | ||||
|   chartInitOptions = { | ||||
| @ -48,7 +48,7 @@ export class BlockFeesGraphComponent implements OnInit { | ||||
|     @Inject(LOCALE_ID) public locale: string, | ||||
|     private seoService: SeoService, | ||||
|     private apiService: ApiService, | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private storageService: StorageService, | ||||
|     private miningService: MiningService, | ||||
|     private route: ActivatedRoute, | ||||
|  | ||||
| @ -1,7 +1,8 @@ | ||||
| <div class="block-overview-graph"> | ||||
|   <canvas class="block-overview-canvas" [class.clickable]="!!hoverTx" #blockCanvas></canvas> | ||||
|   <div class="loader-wrapper" [class.hidden]="!isLoading || disableSpinner"> | ||||
|     <div class="spinner-border ml-3 loading" role="status"></div> | ||||
|   <div class="loader-wrapper" [class.hidden]="(!isLoading || disableSpinner) && !unavailable"> | ||||
|     <div *ngIf="isLoading" class="spinner-border ml-3 loading" role="status"></div> | ||||
|     <div *ngIf="!isLoading && unavailable" class="ml-3" i18n="block.not-available">not available</div> | ||||
|   </div> | ||||
| 
 | ||||
|   <app-block-overview-tooltip | ||||
|  | ||||
| @ -18,7 +18,10 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|   @Input() orientation = 'left'; | ||||
|   @Input() flip = true; | ||||
|   @Input() disableSpinner = false; | ||||
|   @Input() mirrorTxid: string | void; | ||||
|   @Input() unavailable: boolean = false; | ||||
|   @Output() txClickEvent = new EventEmitter<TransactionStripped>(); | ||||
|   @Output() txHoverEvent = new EventEmitter<string>(); | ||||
|   @Output() readyEvent = new EventEmitter(); | ||||
| 
 | ||||
|   @ViewChild('blockCanvas') | ||||
| @ -37,6 +40,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|   scene: BlockScene; | ||||
|   hoverTx: TxView | void; | ||||
|   selectedTx: TxView | void; | ||||
|   mirrorTx: TxView | void; | ||||
|   tooltipPosition: Position; | ||||
| 
 | ||||
|   readyNextFrame = false; | ||||
| @ -63,6 +67,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|         this.scene.setOrientation(this.orientation, this.flip); | ||||
|       } | ||||
|     } | ||||
|     if (changes.mirrorTxid) { | ||||
|       this.setMirror(this.mirrorTxid); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy(): void { | ||||
| @ -76,6 +83,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|     this.exit(direction); | ||||
|     this.hoverTx = null; | ||||
|     this.selectedTx = null; | ||||
|     this.onTxHover(null); | ||||
|     this.start(); | ||||
|   } | ||||
| 
 | ||||
| @ -181,7 +189,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|       this.gl.viewport(0, 0, this.displayWidth, this.displayHeight); | ||||
|     } | ||||
|     if (this.scene) { | ||||
|       this.scene.resize({ width: this.displayWidth, height: this.displayHeight }); | ||||
|       this.scene.resize({ width: this.displayWidth, height: this.displayHeight, animate: false }); | ||||
|       this.start(); | ||||
|     } else { | ||||
|       this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, | ||||
| @ -301,6 +309,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|       } | ||||
|       this.hoverTx = null; | ||||
|       this.selectedTx = null; | ||||
|       this.onTxHover(null); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| @ -352,17 +361,20 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|             this.selectedTx = selected; | ||||
|           } else { | ||||
|             this.hoverTx = selected; | ||||
|             this.onTxHover(this.hoverTx ? this.hoverTx.txid : null); | ||||
|           } | ||||
|         } else { | ||||
|           if (clicked) { | ||||
|             this.selectedTx = null; | ||||
|           } | ||||
|           this.hoverTx = null; | ||||
|           this.onTxHover(null); | ||||
|         } | ||||
|       } else if (clicked) { | ||||
|         if (selected === this.selectedTx) { | ||||
|           this.hoverTx = this.selectedTx; | ||||
|           this.selectedTx = null; | ||||
|           this.onTxHover(this.hoverTx ? this.hoverTx.txid : null); | ||||
|         } else { | ||||
|           this.selectedTx = selected; | ||||
|         } | ||||
| @ -370,6 +382,18 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   setMirror(txid: string | void) { | ||||
|     if (this.mirrorTx) { | ||||
|       this.scene.setHover(this.mirrorTx, false); | ||||
|       this.start(); | ||||
|     } | ||||
|     if (txid && this.scene.txs[txid]) { | ||||
|       this.mirrorTx = this.scene.txs[txid]; | ||||
|       this.scene.setHover(this.mirrorTx, true); | ||||
|       this.start(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onTxClick(cssX: number, cssY: number) { | ||||
|     const x = cssX * window.devicePixelRatio; | ||||
|     const y = cssY * window.devicePixelRatio; | ||||
| @ -378,6 +402,10 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|       this.txClickEvent.emit(selected); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onTxHover(hoverId: string) { | ||||
|     this.txHoverEvent.emit(hoverId); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // WebGL shader attributes
 | ||||
|  | ||||
| @ -29,7 +29,7 @@ export default class BlockScene { | ||||
|     this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray }); | ||||
|   } | ||||
| 
 | ||||
|   resize({ width = this.width, height = this.height }: { width?: number, height?: number}): void { | ||||
|   resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void { | ||||
|     this.width = width; | ||||
|     this.height = height; | ||||
|     this.gridSize = this.width / this.gridWidth; | ||||
| @ -38,7 +38,7 @@ export default class BlockScene { | ||||
| 
 | ||||
|     this.dirty = true; | ||||
|     if (this.initialised && this.scene) { | ||||
|       this.updateAll(performance.now(), 50); | ||||
|       this.updateAll(performance.now(), 50, 'left', animate); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| @ -212,7 +212,7 @@ export default class BlockScene { | ||||
|     this.vbytesPerUnit = blockLimit / Math.pow(resolution / 1.02, 2); | ||||
|     this.gridWidth = resolution; | ||||
|     this.gridHeight = resolution; | ||||
|     this.resize({ width, height }); | ||||
|     this.resize({ width, height, animate: true }); | ||||
|     this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }); | ||||
| 
 | ||||
|     this.txs = {}; | ||||
| @ -225,14 +225,14 @@ export default class BlockScene { | ||||
|     this.animateUntil = Math.max(this.animateUntil, tx.update(update)); | ||||
|   } | ||||
| 
 | ||||
|   private updateTx(tx: TxView, startTime: number, delay: number, direction: string = 'left'): void { | ||||
|   private updateTx(tx: TxView, startTime: number, delay: number, direction: string = 'left', animate: boolean = true): void { | ||||
|     if (tx.dirty || this.dirty) { | ||||
|       this.saveGridToScreenPosition(tx); | ||||
|       this.setTxOnScreen(tx, startTime, delay, direction); | ||||
|       this.setTxOnScreen(tx, startTime, delay, direction, animate); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private setTxOnScreen(tx: TxView, startTime: number, delay: number = 50, direction: string = 'left'): void { | ||||
|   private setTxOnScreen(tx: TxView, startTime: number, delay: number = 50, direction: string = 'left', animate: boolean = true): void { | ||||
|     if (!tx.initialised) { | ||||
|       const txColor = tx.getColor(); | ||||
|       this.applyTxUpdate(tx, { | ||||
| @ -252,30 +252,42 @@ export default class BlockScene { | ||||
|           position: tx.screenPosition, | ||||
|           color: txColor | ||||
|         }, | ||||
|         duration: 1000, | ||||
|         duration: animate ? 1000 : 1, | ||||
|         start: startTime, | ||||
|         delay, | ||||
|         delay: animate ? delay : 0, | ||||
|       }); | ||||
|     } else { | ||||
|       this.applyTxUpdate(tx, { | ||||
|         display: { | ||||
|           position: tx.screenPosition | ||||
|         }, | ||||
|         duration: 1000, | ||||
|         minDuration: 500, | ||||
|         duration: animate ? 1000 : 0, | ||||
|         minDuration: animate ? 500 : 0, | ||||
|         start: startTime, | ||||
|         delay, | ||||
|         adjust: true | ||||
|         delay: animate ? delay : 0, | ||||
|         adjust: animate | ||||
|       }); | ||||
|       if (!animate) { | ||||
|         this.applyTxUpdate(tx, { | ||||
|           display: { | ||||
|             position: tx.screenPosition | ||||
|           }, | ||||
|           duration: 0, | ||||
|           minDuration: 0, | ||||
|           start: startTime, | ||||
|           delay: 0, | ||||
|           adjust: false | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private updateAll(startTime: number, delay: number = 50, direction: string = 'left'): void { | ||||
|   private updateAll(startTime: number, delay: number = 50, direction: string = 'left', animate: boolean = true): void { | ||||
|     this.scene.count = 0; | ||||
|     const ids = this.getTxList(); | ||||
|     startTime = startTime || performance.now(); | ||||
|     for (const id of ids) { | ||||
|       this.updateTx(this.txs[id], startTime, delay, direction); | ||||
|       this.updateTx(this.txs[id], startTime, delay, direction, animate); | ||||
|     } | ||||
|     this.dirty = false; | ||||
|   } | ||||
|  | ||||
| @ -3,17 +3,18 @@ import { FastVertexArray } from './fast-vertex-array'; | ||||
| import { TransactionStripped } from '../../interfaces/websocket.interface'; | ||||
| import { SpriteUpdateParams, Square, Color, ViewUpdateParams } from './sprite-types'; | ||||
| import { feeLevels, mempoolFeeColors } from '../../app.constants'; | ||||
| import BlockScene from './block-scene'; | ||||
| 
 | ||||
| const hoverTransitionTime = 300; | ||||
| const defaultHoverColor = hexToColor('1bd8f4'); | ||||
| 
 | ||||
| const feeColors = mempoolFeeColors.map(hexToColor); | ||||
| const auditFeeColors = feeColors.map((color) => desaturate(color, 0.3)); | ||||
| const auditFeeColors = feeColors.map((color) => darken(desaturate(color, 0.3), 0.9)); | ||||
| const auditColors = { | ||||
|   censored: hexToColor('f344df'), | ||||
|   missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7), | ||||
|   added: hexToColor('03E1E5'), | ||||
|   selected: darken(desaturate(hexToColor('039BE5'), 0.3), 0.7), | ||||
|   added: hexToColor('0099ff'), | ||||
|   selected: darken(desaturate(hexToColor('0099ff'), 0.3), 0.7), | ||||
| } | ||||
| 
 | ||||
| // convert from this class's update format to TxSprite's update format
 | ||||
| @ -34,7 +35,8 @@ export default class TxView implements TransactionStripped { | ||||
|   vsize: number; | ||||
|   value: number; | ||||
|   feerate: number; | ||||
|   status?: 'found' | 'missing' | 'added' | 'censored' | 'selected'; | ||||
|   status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; | ||||
|   context?: 'projected' | 'actual'; | ||||
| 
 | ||||
|   initialised: boolean; | ||||
|   vertexArray: FastVertexArray; | ||||
| @ -48,6 +50,7 @@ export default class TxView implements TransactionStripped { | ||||
|   dirty: boolean; | ||||
| 
 | ||||
|   constructor(tx: TransactionStripped, vertexArray: FastVertexArray) { | ||||
|     this.context = tx.context; | ||||
|     this.txid = tx.txid; | ||||
|     this.fee = tx.fee; | ||||
|     this.vsize = tx.vsize; | ||||
| @ -159,12 +162,18 @@ export default class TxView implements TransactionStripped { | ||||
|         return auditColors.censored; | ||||
|       case 'missing': | ||||
|         return auditColors.missing; | ||||
|       case 'fresh': | ||||
|         return auditColors.missing; | ||||
|       case 'added': | ||||
|         return auditColors.added; | ||||
|       case 'selected': | ||||
|         return auditColors.selected; | ||||
|       case 'found': | ||||
|         if (this.context === 'projected') { | ||||
|           return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1]; | ||||
|         } else { | ||||
|           return feeLevelColor; | ||||
|         } | ||||
|       default: | ||||
|         return feeLevelColor; | ||||
|     } | ||||
|  | ||||
| @ -37,9 +37,10 @@ | ||||
|         <ng-container [ngSwitch]="tx?.status"> | ||||
|           <td *ngSwitchCase="'found'" i18n="transaction.audit.match">match</td> | ||||
|           <td *ngSwitchCase="'censored'" i18n="transaction.audit.removed">removed</td> | ||||
|           <td *ngSwitchCase="'missing'" i18n="transaction.audit.missing">missing</td> | ||||
|           <td *ngSwitchCase="'missing'" i18n="transaction.audit.marginal">marginal fee rate</td> | ||||
|           <td *ngSwitchCase="'fresh'" i18n="transaction.audit.recently-broadcast">recently broadcast</td> | ||||
|           <td *ngSwitchCase="'added'" i18n="transaction.audit.added">added</td> | ||||
|           <td *ngSwitchCase="'selected'" i18n="transaction.audit.included">included</td> | ||||
|           <td *ngSwitchCase="'selected'" i18n="transaction.audit.marginal">marginal fee rate</td> | ||||
|         </ng-container> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|  | ||||
| @ -10,36 +10,36 @@ | ||||
|     </div>   | ||||
| 
 | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 144"> | ||||
|           <input ngbButton type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 24h | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|         <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> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 432"> | ||||
|           <input ngbButton type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 3D | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 432" [class.active]="radioGroupForm.get('dateSpan').value === '3d'"> | ||||
|           <input type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 3D | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 1008"> | ||||
|           <input ngbButton type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 1W | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 1008" [class.active]="radioGroupForm.get('dateSpan').value === '1w'"> | ||||
|           <input type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 1W | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320"> | ||||
|           <input ngbButton type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 1M | ||||
|         <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-prediction' | relativeUrl]" formControlName="dateSpan"> 1M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 12960"> | ||||
|           <input ngbButton type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 3M | ||||
|         <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/block-prediction' | relativeUrl]" formControlName="dateSpan"> 3M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920"> | ||||
|           <input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 6M | ||||
|         <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/block-prediction' | relativeUrl]" formControlName="dateSpan"> 6M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 52560"> | ||||
|           <input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 1Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'"> | ||||
|           <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 1Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 105120"> | ||||
|           <input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 2Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'"> | ||||
|           <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 2Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 157680"> | ||||
|           <input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 3Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'"> | ||||
|           <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 3Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount > 157680"> | ||||
|           <input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> ALL | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount > 157680" [class.active]="radioGroupForm.get('dateSpan').value === 'all'"> | ||||
|           <input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> ALL | ||||
|         </label> | ||||
|       </div> | ||||
|     </form> | ||||
|  | ||||
| @ -5,7 +5,7 @@ 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 { FormBuilder, FormGroup } from '@angular/forms'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; | ||||
| import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| import { ActivatedRoute, Router } from '@angular/router'; | ||||
| @ -31,7 +31,7 @@ export class BlockPredictionGraphComponent implements OnInit { | ||||
|   @Input() left: number | string = 75; | ||||
| 
 | ||||
|   miningWindowPreference: string; | ||||
|   radioGroupForm: FormGroup; | ||||
|   radioGroupForm: UntypedFormGroup; | ||||
| 
 | ||||
|   chartOptions: EChartsOption = {}; | ||||
|   chartInitOptions = { | ||||
| @ -48,7 +48,7 @@ export class BlockPredictionGraphComponent implements OnInit { | ||||
|     @Inject(LOCALE_ID) public locale: string, | ||||
|     private seoService: SeoService, | ||||
|     private apiService: ApiService, | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private storageService: StorageService, | ||||
|     private zone: NgZone, | ||||
|     private route: ActivatedRoute, | ||||
|  | ||||
| @ -11,27 +11,27 @@ | ||||
|     </div>   | ||||
|    | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320"> | ||||
|           <input ngbButton type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> 1M | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|         <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> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 12960"> | ||||
|           <input ngbButton type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> 3M | ||||
|         <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/block-rewards' | relativeUrl]" formControlName="dateSpan"> 3M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920"> | ||||
|           <input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> 6M | ||||
|         <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/block-rewards' | relativeUrl]" formControlName="dateSpan"> 6M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 52560"> | ||||
|           <input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> 1Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'"> | ||||
|           <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" formControlName="dateSpan"> 1Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 105120"> | ||||
|           <input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> 2Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'"> | ||||
|           <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" formControlName="dateSpan"> 2Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 157680"> | ||||
|           <input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> 3Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'"> | ||||
|           <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" formControlName="dateSpan"> 3Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|           <input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> ALL | ||||
|         <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 | ||||
|         </label> | ||||
|       </div> | ||||
|     </form> | ||||
|  | ||||
| @ -5,7 +5,7 @@ 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 { FormBuilder, FormGroup } from '@angular/forms'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; | ||||
| import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils'; | ||||
| import { MiningService } from '../../services/mining.service'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| @ -31,7 +31,7 @@ export class BlockRewardsGraphComponent implements OnInit { | ||||
|   @Input() left: number | string = 75; | ||||
| 
 | ||||
|   miningWindowPreference: string; | ||||
|   radioGroupForm: FormGroup; | ||||
|   radioGroupForm: UntypedFormGroup; | ||||
| 
 | ||||
|   chartOptions: EChartsOption = {}; | ||||
|   chartInitOptions = { | ||||
| @ -48,7 +48,7 @@ export class BlockRewardsGraphComponent implements OnInit { | ||||
|     @Inject(LOCALE_ID) public locale: string, | ||||
|     private seoService: SeoService, | ||||
|     private apiService: ApiService, | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private miningService: MiningService, | ||||
|     private storageService: StorageService, | ||||
|     private route: ActivatedRoute, | ||||
|  | ||||
| @ -9,36 +9,36 @@ | ||||
|     </div>   | ||||
| 
 | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(blockSizesWeightsObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 144"> | ||||
|           <input ngbButton type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 24h | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|         <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> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 432"> | ||||
|           <input ngbButton type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 3D | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 432" [class.active]="radioGroupForm.get('dateSpan').value === '3d'"> | ||||
|           <input type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 3D | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 1008"> | ||||
|           <input ngbButton type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 1W | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 1008" [class.active]="radioGroupForm.get('dateSpan').value === '1w'"> | ||||
|           <input type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 1W | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320"> | ||||
|           <input ngbButton type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 1M | ||||
|         <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-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 1M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 12960"> | ||||
|           <input ngbButton type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 3M | ||||
|         <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/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 3M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920"> | ||||
|           <input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 6M | ||||
|         <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/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 6M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 52560"> | ||||
|           <input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 1Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'"> | ||||
|           <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 1Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 105120"> | ||||
|           <input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 2Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'"> | ||||
|           <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 2Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 157680"> | ||||
|           <input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 3Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'"> | ||||
|           <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 3Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|           <input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> ALL | ||||
|         <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-sizes-weights' | relativeUrl]" formControlName="dateSpan"> ALL | ||||
|         </label> | ||||
|       </div> | ||||
|     </form> | ||||
|  | ||||
| @ -5,7 +5,7 @@ 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 { FormBuilder, FormGroup } from '@angular/forms'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| import { MiningService } from '../../services/mining.service'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| @ -30,7 +30,7 @@ export class BlockSizesWeightsGraphComponent implements OnInit { | ||||
|   @Input() left: number | string = 75; | ||||
| 
 | ||||
|   miningWindowPreference: string; | ||||
|   radioGroupForm: FormGroup; | ||||
|   radioGroupForm: UntypedFormGroup; | ||||
| 
 | ||||
|   chartOptions: EChartsOption = {}; | ||||
|   chartInitOptions = { | ||||
| @ -49,7 +49,7 @@ export class BlockSizesWeightsGraphComponent implements OnInit { | ||||
|     @Inject(LOCALE_ID) public locale: string, | ||||
|     private seoService: SeoService, | ||||
|     private apiService: ApiService, | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private storageService: StorageService, | ||||
|     private miningService: MiningService, | ||||
|     private route: ActivatedRoute, | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
| 
 | ||||
|   <div class="title-block" [class.time-ltr]="timeLtr" id="block"> | ||||
|     <h1> | ||||
|       <ng-container *ngIf="blockHeight > 0; else genesis" i18n="shared.block-title">Block</ng-container> | ||||
|       <ng-container *ngIf="blockHeight == null || blockHeight > 0; else genesis" i18n="shared.block-title">Block</ng-container> | ||||
|       <ng-template #genesis i18n="@@2303359202781425764">Genesis</ng-template> | ||||
|       <span class="next-previous-blocks"> | ||||
|         <a *ngIf="showNextBlocklink" class="nav-arrow next" [routerLink]="['/block/' | relativeUrl, nextBlockHeight]" (click)="navigateToNextBlock()" i18n-ngbTooltip="Next Block" ngbTooltip="Next Block" placement="bottom"> | ||||
| @ -54,7 +54,19 @@ | ||||
|                 <td i18n="block.weight">Weight</td> | ||||
|                 <td [innerHTML]="'‎' + (block.weight | wuBytes: 2)"></td> | ||||
|               </tr> | ||||
|               <ng-template [ngIf]="webGlEnabled"> | ||||
|               <tr *ngIf="auditEnabled"> | ||||
|                 <td i18n="block.health">Block health</td> | ||||
|                 <td> | ||||
|                   <span *ngIf="blockAudit?.matchRate != null">{{ blockAudit.matchRate }}%</span> | ||||
|                   <span *ngIf="blockAudit?.matchRate === null" i18n="unknown">Unknown</span> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <ng-container *ngIf="!indexingAvailable && webGlEnabled"> | ||||
|                 <tr *ngIf="isMobile && auditEnabled"></tr> | ||||
|                 <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|                   <td i18n="mempool-block.fee-span">Fee span</td> | ||||
|                   <td><span>{{ block.extras.feeRange[0] | number:'1.0-0' }} - {{ block.extras.feeRange[block.extras.feeRange.length - 1] | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span></td> | ||||
|                 </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> | ||||
| @ -98,26 +110,19 @@ | ||||
|                 <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|                   <td i18n="block.miner">Miner</td> | ||||
|                   <td *ngIf="stateService.env.MINING_DASHBOARD"> | ||||
|                     <a [attr.data-cy]="'block-details-miner-badge'" placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge" | ||||
|                     <a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge" | ||||
|                       [class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'"> | ||||
|                       {{ block.extras.pool.name }} | ||||
|                     </a> | ||||
|                   </td> | ||||
|                   <td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'"> | ||||
|                     <span [attr.data-cy]="'block-details-miner-badge'" placement="bottom" class="badge" | ||||
|                     <span placement="bottom" class="badge" | ||||
|                       [class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'"> | ||||
|                       {{ block.extras.pool.name }} | ||||
|                   </span> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|                 <tr *ngIf="indexingAvailable"> | ||||
|                   <td i18n="block.health">Block health</td> | ||||
|                   <td> | ||||
|                     <a *ngIf="block.extras?.matchRate != null" [routerLink]="['/block-audit/' | relativeUrl, blockHash]">{{ block.extras.matchRate }}%</a> | ||||
|                     <span *ngIf="block.extras?.matchRate == null" i18n="unknown">Unknown</span> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|               </ng-template> | ||||
|               </ng-container> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
| @ -138,7 +143,11 @@ | ||||
|               <tr> | ||||
|                 <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|               <ng-template [ngIf]="webGlEnabled"> | ||||
|               <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|                 <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|               </tr> | ||||
|               <ng-container *ngIf="!indexingAvailable && webGlEnabled"> | ||||
|                 <tr *ngIf="isMobile && !auditEnabled"></tr> | ||||
|                 <tr> | ||||
|                   <td class="td-width" colspan="2"><span class="skeleton-loader"></span></td> | ||||
|                 </tr> | ||||
| @ -148,17 +157,25 @@ | ||||
|                 <tr> | ||||
|                   <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                 <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|                   <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|                 </tr> | ||||
|               </ng-template> | ||||
|                 <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|                   <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|                 </tr> | ||||
|               </ng-container> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|       </ng-template> | ||||
|       <div class="col-sm" *ngIf="!webGlEnabled"> | ||||
|         <table class="table table-borderless table-striped" *ngIf="!isLoadingBlock"> | ||||
|       <div class="col-sm"> | ||||
|         <table class="table table-borderless table-striped" *ngIf="!isLoadingBlock && (indexingAvailable || !webGlEnabled)"> | ||||
|           <tbody> | ||||
|             <tr *ngIf="isMobile && auditEnabled"></tr> | ||||
|             <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|               <td i18n="mempool-block.fee-span">Fee span</td> | ||||
|               <td><span>{{ block.extras.feeRange[0] | number:'1.0-0' }} - {{ block.extras.feeRange[block.extras.feeRange.length - 1] | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span></td> | ||||
|             </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> | ||||
| @ -216,8 +233,9 @@ | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|         <table class="table table-borderless table-striped" *ngIf="isLoadingBlock"> | ||||
|         <table class="table table-borderless table-striped" *ngIf="isLoadingBlock && (indexingAvailable || !webGlEnabled)"> | ||||
|           <tbody> | ||||
|             <tr *ngIf="isMobile && !auditEnabled"></tr> | ||||
|             <tr> | ||||
|               <td class="td-width" colspan="2"><span class="skeleton-loader"></span></td> | ||||
|             </tr> | ||||
| @ -230,12 +248,14 @@ | ||||
|             <tr> | ||||
|               <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|             </tr> | ||||
|             <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|               <td colspan="2"><span class="skeleton-loader"></span></td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|       </div> | ||||
|       <div class="col-sm chart-container" *ngIf="webGlEnabled"> | ||||
|         <div class="col-sm chart-container" *ngIf="webGlEnabled && !indexingAvailable"> | ||||
|           <app-block-overview-graph | ||||
|           #blockGraph | ||||
|             #blockGraphActual | ||||
|             [isLoading]="isLoadingOverview" | ||||
|             [resolution]="75" | ||||
|             [blockLimit]="stateService.blockVSize" | ||||
| @ -246,6 +266,36 @@ | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| 
 | ||||
|   <span id="overview"></span> | ||||
| 
 | ||||
|   <br> | ||||
| 
 | ||||
|   <!-- VISUALIZATIONS --> | ||||
|   <div class="box" *ngIf="!error && webGlEnabled && indexingAvailable"> | ||||
|     <div class="nav nav-tabs" *ngIf="isMobile && auditEnabled"> | ||||
|       <a class="nav-link" [class.active]="mode === 'projected'" i18n="block.projected" | ||||
|         fragment="projected" (click)="changeMode('projected')">Projected</a> | ||||
|       <a class="nav-link" [class.active]="mode === 'actual'" i18n="block.actual" | ||||
|         fragment="actual" (click)="changeMode('actual')">Actual</a> | ||||
|     </div> | ||||
|     <div class="row"> | ||||
|       <div class="col-sm"> | ||||
|         <h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.projected-block">Projected Block</h3> | ||||
|         <app-block-overview-graph #blockGraphProjected [isLoading]="isLoadingOverview" [resolution]="75" | ||||
|           [blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" [mirrorTxid]="hoverTx" | ||||
|           (txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($event)" [unavailable]="!isMobile && !auditEnabled"></app-block-overview-graph>  | ||||
|       </div> | ||||
|       <div class="col-sm" *ngIf="!isMobile"> | ||||
|         <h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.actual-block">Actual Block</h3> | ||||
|         <app-block-overview-graph #blockGraphActual [isLoading]="isLoadingOverview" [resolution]="75" | ||||
|           [blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" [mirrorTxid]="hoverTx" mode="mined" | ||||
|           (txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($event)" [unavailable]="isMobile && !auditEnabled"></app-block-overview-graph> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|    | ||||
|   <ng-template [ngIf]="!isLoadingBlock && !error"> | ||||
|     <div [hidden]="!showDetails" id="details"> | ||||
|       <br> | ||||
| @ -273,6 +323,7 @@ | ||||
|           <div class="col-sm" *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|             <table class="table table-borderless table-striped"> | ||||
|               <tbody> | ||||
|                 <tr *ngIf="isMobile"></tr> | ||||
|                 <tr> | ||||
|                   <td class="td-width" i18n="block.difficulty">Difficulty</td> | ||||
|                   <td>{{ block.difficulty }}</td> | ||||
|  | ||||
| @ -171,3 +171,35 @@ h1 { | ||||
|     margin: auto; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .menu-button { | ||||
|   @media (min-width: 768px) { | ||||
|     max-width: 150px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .block-subtitle { | ||||
|   text-align: center; | ||||
| } | ||||
| 
 | ||||
| .nav-tabs { | ||||
|   border-color: white; | ||||
|   border-width: 1px; | ||||
| } | ||||
| 
 | ||||
| .nav-tabs .nav-link { | ||||
|   background: inherit; | ||||
|   border-width: 1px; | ||||
|   border-bottom: none; | ||||
|   border-color: transparent; | ||||
|   margin-bottom: -1px; | ||||
|   cursor: pointer; | ||||
| 
 | ||||
|   &.active { | ||||
|     background: #24273e; | ||||
|   } | ||||
| 
 | ||||
|   &.active, &:hover { | ||||
|     border-color: white; | ||||
|   } | ||||
| } | ||||
| @ -1,15 +1,15 @@ | ||||
| import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; | ||||
| import { Component, OnInit, OnDestroy, ViewChildren, QueryList } from '@angular/core'; | ||||
| import { Location } from '@angular/common'; | ||||
| import { ActivatedRoute, ParamMap, Router } from '@angular/router'; | ||||
| import { ElectrsApiService } from '../../services/electrs-api.service'; | ||||
| import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise } from 'rxjs/operators'; | ||||
| import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise, filter } from 'rxjs/operators'; | ||||
| import { Transaction, Vout } from '../../interfaces/electrs.interface'; | ||||
| import { Observable, of, Subscription, asyncScheduler, EMPTY } from 'rxjs'; | ||||
| import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest } from 'rxjs'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { WebsocketService } from '../../services/websocket.service'; | ||||
| import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; | ||||
| import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; | ||||
| import { BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; | ||||
| import { detectWebGL } from '../../shared/graphs.utils'; | ||||
| @ -17,11 +17,20 @@ import { detectWebGL } from '../../shared/graphs.utils'; | ||||
| @Component({ | ||||
|   selector: 'app-block', | ||||
|   templateUrl: './block.component.html', | ||||
|   styleUrls: ['./block.component.scss'] | ||||
|   styleUrls: ['./block.component.scss'], | ||||
|   styles: [` | ||||
|     .loadingGraphs { | ||||
|       position: absolute; | ||||
|       top: 50%; | ||||
|       left: calc(50% - 15px); | ||||
|       z-index: 100; | ||||
|     } | ||||
|   `],
 | ||||
| }) | ||||
| export class BlockComponent implements OnInit, OnDestroy { | ||||
|   network = ''; | ||||
|   block: BlockExtended; | ||||
|   blockAudit: BlockAudit = undefined; | ||||
|   blockHeight: number; | ||||
|   lastBlockHeight: number; | ||||
|   nextBlockHeight: number; | ||||
| @ -48,9 +57,16 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|   overviewError: any = null; | ||||
|   webGlEnabled = true; | ||||
|   indexingAvailable = false; | ||||
|   auditEnabled = true; | ||||
|   isMobile = window.innerWidth <= 767.98; | ||||
|   hoverTx: string; | ||||
|   numMissing: number = 0; | ||||
|   numUnexpected: number = 0; | ||||
|   mode: 'projected' | 'actual' = 'projected'; | ||||
| 
 | ||||
|   transactionSubscription: Subscription; | ||||
|   overviewSubscription: Subscription; | ||||
|   auditSubscription: Subscription; | ||||
|   keyNavigationSubscription: Subscription; | ||||
|   blocksSubscription: Subscription; | ||||
|   networkChangedSubscription: Subscription; | ||||
| @ -60,8 +76,10 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|   nextBlockTxListSubscription: Subscription = undefined; | ||||
|   timeLtrSubscription: Subscription; | ||||
|   timeLtr: boolean; | ||||
|   childChangeSubscription: Subscription; | ||||
| 
 | ||||
|   @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; | ||||
|   @ViewChildren('blockGraphProjected') blockGraphProjected: QueryList<BlockOverviewGraphComponent>; | ||||
|   @ViewChildren('blockGraphActual') blockGraphActual: QueryList<BlockOverviewGraphComponent>; | ||||
| 
 | ||||
|   constructor( | ||||
|     private route: ActivatedRoute, | ||||
| @ -87,8 +105,8 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|       this.timeLtr = !!ltr; | ||||
|     }); | ||||
| 
 | ||||
|     this.indexingAvailable = (this.stateService.env.BASE_MODULE === 'mempool' && | ||||
|       this.stateService.env.MINING_DASHBOARD === true); | ||||
|     this.indexingAvailable = (this.stateService.env.BASE_MODULE === 'mempool' && this.stateService.env.MINING_DASHBOARD === true); | ||||
|     this.auditEnabled = this.indexingAvailable; | ||||
| 
 | ||||
|     this.txsLoadingStatus$ = this.route.paramMap | ||||
|       .pipe( | ||||
| @ -192,7 +210,7 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|           setTimeout(() => { | ||||
|             this.nextBlockSubscription = this.apiService.getBlock$(block.previousblockhash).subscribe(); | ||||
|             this.nextBlockTxListSubscription = this.electrsApiService.getBlockTransactions$(block.previousblockhash).subscribe(); | ||||
|             this.nextBlockSummarySubscription = this.apiService.getStrippedBlockTransactions$(block.previousblockhash).subscribe(); | ||||
|             this.apiService.getBlockAudit$(block.previousblockhash); | ||||
|           }, 100); | ||||
|         } | ||||
| 
 | ||||
| @ -240,6 +258,7 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|       this.isLoadingOverview = false; | ||||
|     }); | ||||
| 
 | ||||
|     if (!this.indexingAvailable) { | ||||
|       this.overviewSubscription = block$.pipe( | ||||
|         startWith(null), | ||||
|         pairwise(), | ||||
| @ -262,18 +281,103 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|       .subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => { | ||||
|         this.strippedTransactions = transactions; | ||||
|         this.isLoadingOverview = false; | ||||
|       if (this.blockGraph) { | ||||
|         this.blockGraph.destroy(); | ||||
|         this.blockGraph.setup(this.strippedTransactions); | ||||
|       } | ||||
|         this.setupBlockGraphs(); | ||||
|       }, | ||||
|       (error) => { | ||||
|         this.error = error; | ||||
|         this.isLoadingOverview = false; | ||||
|       if (this.blockGraph) { | ||||
|         this.blockGraph.destroy(); | ||||
|       } | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     if (this.indexingAvailable) { | ||||
|       this.auditSubscription = block$.pipe( | ||||
|         startWith(null), | ||||
|         pairwise(), | ||||
|         switchMap(([prevBlock, block]) => this.apiService.getBlockAudit$(block.id) | ||||
|           .pipe( | ||||
|             catchError((err) => { | ||||
|               this.overviewError = err; | ||||
|               return of([]); | ||||
|             }) | ||||
|           ) | ||||
|         ), | ||||
|         filter((response) => response != null), | ||||
|         map((response) => { | ||||
|           const blockAudit = response.body; | ||||
|           const inTemplate = {}; | ||||
|           const inBlock = {}; | ||||
|           const isAdded = {}; | ||||
|           const isCensored = {}; | ||||
|           const isMissing = {}; | ||||
|           const isSelected = {}; | ||||
|           const isFresh = {}; | ||||
|           this.numMissing = 0; | ||||
|           this.numUnexpected = 0; | ||||
| 
 | ||||
|           if (blockAudit?.template) { | ||||
|             for (const tx of blockAudit.template) { | ||||
|               inTemplate[tx.txid] = true; | ||||
|             } | ||||
|             for (const tx of blockAudit.transactions) { | ||||
|               inBlock[tx.txid] = true; | ||||
|             } | ||||
|             for (const txid of blockAudit.addedTxs) { | ||||
|               isAdded[txid] = true; | ||||
|             } | ||||
|             for (const txid of blockAudit.missingTxs) { | ||||
|               isCensored[txid] = true; | ||||
|             } | ||||
|             for (const txid of blockAudit.freshTxs || []) { | ||||
|               isFresh[txid] = true; | ||||
|             } | ||||
|             // set transaction statuses
 | ||||
|             for (const tx of blockAudit.template) { | ||||
|               tx.context = 'projected'; | ||||
|               if (isCensored[tx.txid]) { | ||||
|                 tx.status = 'censored'; | ||||
|               } else if (inBlock[tx.txid]) { | ||||
|                 tx.status = 'found'; | ||||
|               } else { | ||||
|                 tx.status = isFresh[tx.txid] ? 'fresh' : 'missing'; | ||||
|                 isMissing[tx.txid] = true; | ||||
|                 this.numMissing++; | ||||
|               } | ||||
|             } | ||||
|             for (const [index, tx] of blockAudit.transactions.entries()) { | ||||
|               tx.context = 'actual'; | ||||
|               if (index === 0) { | ||||
|                 tx.status = null; | ||||
|               } else if (isAdded[tx.txid]) { | ||||
|                 tx.status = 'added'; | ||||
|               } else if (inTemplate[tx.txid]) { | ||||
|                 tx.status = 'found'; | ||||
|               } else { | ||||
|                 tx.status = 'selected'; | ||||
|                 isSelected[tx.txid] = true; | ||||
|                 this.numUnexpected++; | ||||
|               } | ||||
|             } | ||||
|             for (const tx of blockAudit.transactions) { | ||||
|               inBlock[tx.txid] = true; | ||||
|             } | ||||
|             this.auditEnabled = true; | ||||
|           } else { | ||||
|             this.auditEnabled = false; | ||||
|           } | ||||
|           return blockAudit; | ||||
|         }), | ||||
|         catchError((err) => { | ||||
|           console.log(err); | ||||
|           this.error = err; | ||||
|           this.isLoadingOverview = false; | ||||
|           return of(null); | ||||
|         }), | ||||
|       ).subscribe((blockAudit) => { | ||||
|         this.blockAudit = blockAudit; | ||||
|         this.setupBlockGraphs(); | ||||
|         this.isLoadingOverview = false; | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     this.networkChangedSubscription = this.stateService.networkChanged$ | ||||
|       .subscribe((network) => this.network = network); | ||||
| @ -284,6 +388,12 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|       } else { | ||||
|         this.showDetails = false; | ||||
|       } | ||||
|       if (params.view === 'projected') { | ||||
|         this.mode = 'projected'; | ||||
|       } else { | ||||
|         this.mode = 'actual'; | ||||
|       } | ||||
|       this.setupBlockGraphs(); | ||||
|     }); | ||||
| 
 | ||||
|     this.keyNavigationSubscription = this.stateService.keyNavigation$.subscribe((event) => { | ||||
| @ -302,16 +412,24 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   ngAfterViewInit(): void { | ||||
|     this.childChangeSubscription = combineLatest([this.blockGraphProjected.changes.pipe(startWith(null)), this.blockGraphActual.changes.pipe(startWith(null))]).subscribe(() => { | ||||
|       this.setupBlockGraphs(); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy() { | ||||
|     this.stateService.markBlock$.next({}); | ||||
|     this.transactionSubscription.unsubscribe(); | ||||
|     this.overviewSubscription.unsubscribe(); | ||||
|     this.overviewSubscription?.unsubscribe(); | ||||
|     this.auditSubscription?.unsubscribe(); | ||||
|     this.keyNavigationSubscription.unsubscribe(); | ||||
|     this.blocksSubscription.unsubscribe(); | ||||
|     this.networkChangedSubscription.unsubscribe(); | ||||
|     this.queryParamsSubscription.unsubscribe(); | ||||
|     this.timeLtrSubscription.unsubscribe(); | ||||
|     this.unsubscribeNextBlockSubscriptions(); | ||||
|     this.childChangeSubscription.unsubscribe(); | ||||
|   } | ||||
| 
 | ||||
|   unsubscribeNextBlockSubscriptions() { | ||||
| @ -358,7 +476,7 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|       this.showDetails = false; | ||||
|       this.router.navigate([], { | ||||
|         relativeTo: this.route, | ||||
|         queryParams: { showDetails: false }, | ||||
|         queryParams: { showDetails: false, view: this.mode }, | ||||
|         queryParamsHandling: 'merge', | ||||
|         fragment: 'block' | ||||
|       }); | ||||
| @ -366,7 +484,7 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|       this.showDetails = true; | ||||
|       this.router.navigate([], { | ||||
|         relativeTo: this.route, | ||||
|         queryParams: { showDetails: true }, | ||||
|         queryParams: { showDetails: true, view: this.mode }, | ||||
|         queryParamsHandling: 'merge', | ||||
|         fragment: 'details' | ||||
|       }); | ||||
| @ -385,10 +503,6 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|     return this.block && this.block.height > 681393 && (new Date().getTime() / 1000) < 1628640000; | ||||
|   } | ||||
| 
 | ||||
|   onResize(event: any) { | ||||
|     this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5; | ||||
|   } | ||||
| 
 | ||||
|   navigateToPreviousBlock() { | ||||
|     if (!this.block) { | ||||
|       return; | ||||
| @ -419,8 +533,53 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   setupBlockGraphs(): void { | ||||
|     if (this.blockAudit || this.strippedTransactions) { | ||||
|       this.blockGraphProjected.forEach(graph => { | ||||
|         graph.destroy(); | ||||
|         if (this.isMobile && this.mode === 'actual') { | ||||
|           graph.setup(this.blockAudit?.transactions || this.strippedTransactions ||  []); | ||||
|         } else { | ||||
|           graph.setup(this.blockAudit?.template || []); | ||||
|         } | ||||
|       }); | ||||
|       this.blockGraphActual.forEach(graph => { | ||||
|         graph.destroy(); | ||||
|         graph.setup(this.blockAudit?.transactions || this.strippedTransactions || []); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onResize(event: any): void { | ||||
|     const isMobile = event.target.innerWidth <= 767.98; | ||||
|     const changed = isMobile !== this.isMobile; | ||||
|     this.isMobile = isMobile; | ||||
|     this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5; | ||||
| 
 | ||||
|     if (changed) { | ||||
|       this.changeMode(this.mode); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   changeMode(mode: 'projected' | 'actual'): void { | ||||
|     this.router.navigate([], { | ||||
|       relativeTo: this.route, | ||||
|       queryParams: { showDetails: this.showDetails, view: mode }, | ||||
|       queryParamsHandling: 'merge', | ||||
|       fragment: 'overview' | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   onTxClick(event: TransactionStripped): void { | ||||
|     const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); | ||||
|     this.router.navigate([url]); | ||||
|   } | ||||
| 
 | ||||
|   onTxHover(txid: string): void { | ||||
|     if (txid && txid.length) { | ||||
|       this.hoverTx = txid; | ||||
|     } else { | ||||
|       this.hoverTx = null; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -46,22 +46,16 @@ | ||||
|             <app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since> | ||||
|           </td> | ||||
|           <td *ngIf="indexingAvailable" class="health text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"> | ||||
|             <a *ngIf="block.extras?.matchRate != null" class="clear-link" [routerLink]="['/block-audit/' | relativeUrl, block.id]"> | ||||
|             <a class="clear-link" [routerLink]="auditScores[block.id] != null ? ['/block/' | relativeUrl, block.id] : null"> | ||||
|               <div class="progress progress-health"> | ||||
|                 <div class="progress-bar progress-bar-health" role="progressbar" | ||||
|                   [ngStyle]="{'width': (100 - (block.extras?.matchRate || 0)) + '%' }"></div> | ||||
|                   [ngStyle]="{'width': (100 - (auditScores[block.id] || 0)) + '%' }"></div> | ||||
|                 <div class="progress-text"> | ||||
|                   <span>{{ block.extras.matchRate }}%</span> | ||||
|                   <span *ngIf="auditScores[block.id] != null;">{{ auditScores[block.id] }}%</span> | ||||
|                   <span *ngIf="auditScores[block.id] == null">~</span> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </a> | ||||
|             <div *ngIf="block.extras?.matchRate == null" class="progress progress-health"> | ||||
|               <div class="progress-bar progress-bar-health" role="progressbar" | ||||
|                 [ngStyle]="{'width': '100%' }"></div> | ||||
|               <div class="progress-text"> | ||||
|                 <span>~</span> | ||||
|               </div> | ||||
|             </div> | ||||
|           </td> | ||||
|           <td *ngIf="indexingAvailable" class="reward text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"> | ||||
|             <app-amount [satoshis]="block.extras.reward" [noFiat]="true" digitsInfo="1.2-2"></app-amount> | ||||
|  | ||||
| @ -196,6 +196,10 @@ tr, td, th { | ||||
|   @media (max-width: 950px) { | ||||
|     display: none; | ||||
|   } | ||||
| 
 | ||||
|   .progress-text .skeleton-loader { | ||||
|     top: -8.5px; | ||||
|   } | ||||
| } | ||||
| .health.widget { | ||||
|   width: 25%; | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core'; | ||||
| import { BehaviorSubject, combineLatest, concat, Observable, timer } from 'rxjs'; | ||||
| import { delayWhen, map, retryWhen, scan, skip, switchMap, tap } from 'rxjs/operators'; | ||||
| import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input } from '@angular/core'; | ||||
| import { BehaviorSubject, combineLatest, concat, Observable, timer, EMPTY, Subscription, of } from 'rxjs'; | ||||
| import { catchError, delayWhen, map, retryWhen, scan, skip, switchMap, tap } from 'rxjs/operators'; | ||||
| import { BlockExtended } from '../../interfaces/node-api.interface'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| @ -12,10 +12,14 @@ import { WebsocketService } from '../../services/websocket.service'; | ||||
|   styleUrls: ['./blocks-list.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class BlocksList implements OnInit { | ||||
| export class BlocksList implements OnInit, OnDestroy { | ||||
|   @Input() widget: boolean = false; | ||||
| 
 | ||||
|   blocks$: Observable<BlockExtended[]> = undefined; | ||||
|   auditScores: { [hash: string]: number | void } = {}; | ||||
| 
 | ||||
|   auditScoreSubscription: Subscription; | ||||
|   latestScoreSubscription: Subscription; | ||||
| 
 | ||||
|   indexingAvailable = false; | ||||
|   isLoading = true; | ||||
| @ -105,6 +109,53 @@ export class BlocksList implements OnInit { | ||||
|           return acc; | ||||
|         }, []) | ||||
|       ); | ||||
| 
 | ||||
|     if (this.indexingAvailable) { | ||||
|       this.auditScoreSubscription = this.fromHeightSubject.pipe( | ||||
|         switchMap((fromBlockHeight) => { | ||||
|           return this.apiService.getBlockAuditScores$(this.page === 1 ? undefined : fromBlockHeight) | ||||
|             .pipe( | ||||
|               catchError(() => { | ||||
|                 return EMPTY; | ||||
|               }) | ||||
|             ); | ||||
|         }) | ||||
|       ).subscribe((scores) => { | ||||
|         Object.values(scores).forEach(score => { | ||||
|           this.auditScores[score.hash] = score?.matchRate != null ? score.matchRate : null; | ||||
|         }); | ||||
|       }); | ||||
| 
 | ||||
|       this.latestScoreSubscription = this.stateService.blocks$.pipe( | ||||
|         switchMap((block) => { | ||||
|           if (block[0]?.extras?.matchRate != null) { | ||||
|             return of({ | ||||
|               hash: block[0].id, | ||||
|               matchRate: block[0]?.extras?.matchRate, | ||||
|             }); | ||||
|           } | ||||
|           else if (block[0]?.id && this.auditScores[block[0].id] === undefined) { | ||||
|             return this.apiService.getBlockAuditScore$(block[0].id) | ||||
|               .pipe( | ||||
|                 catchError(() => { | ||||
|                   return EMPTY; | ||||
|                 }) | ||||
|               ); | ||||
|           } else { | ||||
|             return EMPTY; | ||||
|           } | ||||
|         }), | ||||
|       ).subscribe((score) => { | ||||
|         if (score && score.hash) { | ||||
|           this.auditScores[score.hash] = score?.matchRate != null ? score.matchRate : null; | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy(): void { | ||||
|     this.auditScoreSubscription?.unsubscribe(); | ||||
|     this.latestScoreSubscription?.unsubscribe(); | ||||
|   } | ||||
| 
 | ||||
|   pageChange(page: number) { | ||||
|  | ||||
| @ -31,24 +31,24 @@ | ||||
|     </div>   | ||||
| 
 | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 12960"> | ||||
|           <input ngbButton type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'3m'"> 3M | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|         <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> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920"> | ||||
|           <input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'6m'"> 6M | ||||
|         <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/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'6m'" formControlName="dateSpan"> 6M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 52560"> | ||||
|           <input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'1y'"> 1Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'"> | ||||
|           <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'1y'" formControlName="dateSpan"> 1Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 105120"> | ||||
|           <input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'2y'"> 2Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'"> | ||||
|           <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'2y'" formControlName="dateSpan"> 2Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 157680"> | ||||
|           <input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'3y'"> 3Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'"> | ||||
|           <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'3y'" formControlName="dateSpan"> 3Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|           <input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'all'"> ALL | ||||
|         <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === 'all'"> | ||||
|           <input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'all'" formControlName="dateSpan"> ALL | ||||
|         </label> | ||||
|       </div> | ||||
|     </form> | ||||
|  | ||||
| @ -5,7 +5,7 @@ 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 { FormBuilder, FormGroup } from '@angular/forms'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; | ||||
| import { selectPowerOfTen } from '../../bitcoin.utils'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| import { MiningService } from '../../services/mining.service'; | ||||
| @ -34,7 +34,7 @@ export class HashrateChartComponent implements OnInit { | ||||
|   @Input() left: number | string = 75; | ||||
| 
 | ||||
|   miningWindowPreference: string; | ||||
|   radioGroupForm: FormGroup; | ||||
|   radioGroupForm: UntypedFormGroup; | ||||
| 
 | ||||
|   chartOptions: EChartsOption = {}; | ||||
|   chartInitOptions = { | ||||
| @ -54,7 +54,7 @@ export class HashrateChartComponent implements OnInit { | ||||
|     @Inject(LOCALE_ID) public locale: string, | ||||
|     private seoService: SeoService, | ||||
|     private apiService: ApiService, | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private storageService: StorageService, | ||||
|     private miningService: MiningService, | ||||
|     private route: ActivatedRoute, | ||||
|  | ||||
| @ -11,21 +11,21 @@ | ||||
|     </div>   | ||||
| 
 | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920"> | ||||
|           <input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'6m'"> 6M | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|         <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> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 52560"> | ||||
|           <input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'1y'"> 1Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'"> | ||||
|           <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'1y'" formControlName="dateSpan"> 1Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 105120"> | ||||
|           <input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'2y'"> 2Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'"> | ||||
|           <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'2y'" formControlName="dateSpan"> 2Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 157680"> | ||||
|           <input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'3y'"> 3Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'"> | ||||
|           <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'3y'" formControlName="dateSpan"> 3Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|           <input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'all'"> ALL | ||||
|         <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === 'all'"> | ||||
|           <input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'all'" formControlName="dateSpan"> ALL | ||||
|         </label> | ||||
|       </div> | ||||
|     </form> | ||||
|  | ||||
| @ -4,7 +4,7 @@ import { Observable } from 'rxjs'; | ||||
| import { delay, map, retryWhen, share, startWith, switchMap, tap } from 'rxjs/operators'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { FormBuilder, FormGroup } from '@angular/forms'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; | ||||
| import { poolsColor } from '../../app.constants'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| import { MiningService } from '../../services/mining.service'; | ||||
| @ -30,7 +30,7 @@ export class HashrateChartPoolsComponent implements OnInit { | ||||
|   @Input() left: number | string = 25; | ||||
| 
 | ||||
|   miningWindowPreference: string; | ||||
|   radioGroupForm: FormGroup; | ||||
|   radioGroupForm: UntypedFormGroup; | ||||
| 
 | ||||
|   chartOptions: EChartsOption = {}; | ||||
|   chartInitOptions = { | ||||
| @ -48,7 +48,7 @@ export class HashrateChartPoolsComponent implements OnInit { | ||||
|     @Inject(LOCALE_ID) public locale: string, | ||||
|     private seoService: SeoService, | ||||
|     private apiService: ApiService, | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private cd: ChangeDetectorRef, | ||||
|     private storageService: StorageService, | ||||
|     private miningService: MiningService, | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { DOCUMENT } from '@angular/common'; | ||||
| import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core'; | ||||
| import { FormBuilder, FormGroup } from '@angular/forms'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; | ||||
| import { languages } from '../../app.constants'; | ||||
| import { LanguageService } from '../../services/language.service'; | ||||
| 
 | ||||
| @ -11,12 +11,12 @@ import { LanguageService } from '../../services/language.service'; | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush | ||||
| }) | ||||
| export class LanguageSelectorComponent implements OnInit { | ||||
|   languageForm: FormGroup; | ||||
|   languageForm: UntypedFormGroup; | ||||
|   languages = languages; | ||||
| 
 | ||||
|   constructor( | ||||
|     @Inject(DOCUMENT) private document: Document, | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private languageService: LanguageService, | ||||
|   ) { } | ||||
| 
 | ||||
|  | ||||
| @ -17,8 +17,8 @@ import { | ||||
| import { | ||||
|   AbstractControl, | ||||
|   ControlValueAccessor, | ||||
|   FormBuilder, | ||||
|   FormControl, | ||||
|   UntypedFormBuilder, | ||||
|   UntypedFormControl, | ||||
|   NG_VALUE_ACCESSOR, | ||||
|   Validator, | ||||
| } from '@angular/forms'; | ||||
| @ -52,7 +52,7 @@ export class NgxDropdownMultiselectComponent implements OnInit, | ||||
|   private localIsVisible = false; | ||||
|   private workerDocClicked = false; | ||||
| 
 | ||||
|   filterControl: FormControl = this.fb.control(''); | ||||
|   filterControl: UntypedFormControl = this.fb.control(''); | ||||
| 
 | ||||
|   @Input() options: Array<IMultiSelectOption>; | ||||
|   @Input() settings: IMultiSelectSettings; | ||||
| @ -151,7 +151,7 @@ export class NgxDropdownMultiselectComponent implements OnInit, | ||||
|   } | ||||
| 
 | ||||
|   constructor( | ||||
|     private fb: FormBuilder, | ||||
|     private fb: UntypedFormBuilder, | ||||
|     private searchFilter: MultiSelectSearchFilter, | ||||
|     differs: IterableDiffers, | ||||
|     private cdRef: ChangeDetectorRef | ||||
|  | ||||
| @ -40,36 +40,36 @@ | ||||
|     </div>   | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" | ||||
|       *ngIf="!widget && (miningStatsObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 144"> | ||||
|           <input ngbButton type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'24h'"> 24h | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|         <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> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 432"> | ||||
|           <input ngbButton type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'3d'"> 3D | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 432" [class.active]="radioGroupForm.get('dateSpan').value === '3d'"> | ||||
|           <input type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'3d'" formControlName="dateSpan"> 3D | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 1008"> | ||||
|           <input ngbButton type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'1w'"> 1W | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 1008" [class.active]="radioGroupForm.get('dateSpan').value === '1w'"> | ||||
|           <input type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'1w'" formControlName="dateSpan"> 1W | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 4320"> | ||||
|           <input ngbButton type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'1m'"> 1M | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 4320" [class.active]="radioGroupForm.get('dateSpan').value === '1m'"> | ||||
|           <input type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'1m'" formControlName="dateSpan"> 1M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 12960"> | ||||
|           <input ngbButton type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'3m'"> 3M | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 12960" [class.active]="radioGroupForm.get('dateSpan').value === '3m'"> | ||||
|           <input type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'3m'" formControlName="dateSpan"> 3M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 25920"> | ||||
|           <input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'6m'"> 6M | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 25920" [class.active]="radioGroupForm.get('dateSpan').value === '6m'"> | ||||
|           <input type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'6m'" formControlName="dateSpan"> 6M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 52560"> | ||||
|           <input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'1y'"> 1Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'"> | ||||
|           <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'1y'" formControlName="dateSpan"> 1Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 105120"> | ||||
|           <input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'2y'"> 2Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'"> | ||||
|           <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'2y'" formControlName="dateSpan"> 2Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 157680"> | ||||
|           <input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'3y'"> 3Y | ||||
|         <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'"> | ||||
|           <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'3y'" formControlName="dateSpan"> 3Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|           <input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'all'"><span i18n>All</span> | ||||
|         <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === 'all'"> | ||||
|           <input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'all'" formControlName="dateSpan"><span i18n>All</span> | ||||
|         </label> | ||||
|       </div> | ||||
|     </form> | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import { ChangeDetectionStrategy, Component, Input, NgZone, OnInit, HostBinding } from '@angular/core'; | ||||
| import { FormBuilder, FormGroup } from '@angular/forms'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; | ||||
| import { ActivatedRoute, Router } from '@angular/router'; | ||||
| import { EChartsOption, PieSeriesOption } from 'echarts'; | ||||
| import { concat, Observable } from 'rxjs'; | ||||
| @ -24,7 +24,7 @@ export class PoolRankingComponent implements OnInit { | ||||
|   @Input() widget = false; | ||||
| 
 | ||||
|   miningWindowPreference: string; | ||||
|   radioGroupForm: FormGroup; | ||||
|   radioGroupForm: UntypedFormGroup; | ||||
| 
 | ||||
|   isLoading = true; | ||||
|   chartOptions: EChartsOption = {}; | ||||
| @ -41,7 +41,7 @@ export class PoolRankingComponent implements OnInit { | ||||
|   constructor( | ||||
|     private stateService: StateService, | ||||
|     private storageService: StorageService, | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private miningService: MiningService, | ||||
|     private seoService: SeoService, | ||||
|     private router: Router, | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { FormBuilder, FormGroup, Validators } from '@angular/forms'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| 
 | ||||
| @Component({ | ||||
| @ -8,13 +8,13 @@ import { ApiService } from '../../services/api.service'; | ||||
|   styleUrls: ['./push-transaction.component.scss'] | ||||
| }) | ||||
| export class PushTransactionComponent implements OnInit { | ||||
|   pushTxForm: FormGroup; | ||||
|   pushTxForm: UntypedFormGroup; | ||||
|   error: string = ''; | ||||
|   txId: string = ''; | ||||
|   isLoading = false; | ||||
| 
 | ||||
|   constructor( | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private apiService: ApiService, | ||||
|   ) { } | ||||
| 
 | ||||
|  | ||||
| @ -2,9 +2,7 @@ | ||||
|   <div class="d-flex"> | ||||
|     <div class="search-box-container mr-2"> | ||||
|       <input (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="Explore the full Bitcoin ecosystem"> | ||||
|        | ||||
|       <app-search-results #searchResults [results]="typeAhead$ | async" (selectedResult)="selectedResult($event)"></app-search-results> | ||||
|      | ||||
|       <app-search-results #searchResults [hidden]="dropdownHidden" [results]="typeAhead$ | async" (selectedResult)="selectedResult($event)"></app-search-results> | ||||
|     </div> | ||||
|     <div> | ||||
|       <button [disabled]="isSearching" type="submit" class="btn btn-block btn-primary"> | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener } from '@angular/core'; | ||||
| import { FormBuilder, FormGroup, Validators } from '@angular/forms'; | ||||
| import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener, ElementRef } from '@angular/core'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; | ||||
| import { Router } from '@angular/router'; | ||||
| import { AssetsService } from '../../services/assets.service'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| @ -22,7 +22,17 @@ export class SearchFormComponent implements OnInit { | ||||
|   isSearching = false; | ||||
|   isTypeaheading$ = new BehaviorSubject<boolean>(false); | ||||
|   typeAhead$: Observable<any>; | ||||
|   searchForm: FormGroup; | ||||
|   searchForm: UntypedFormGroup; | ||||
|   dropdownHidden = false; | ||||
| 
 | ||||
|   @HostListener('document:click', ['$event']) | ||||
|   onDocumentClick(event) { | ||||
|     if (this.elementRef.nativeElement.contains(event.target)) { | ||||
|       this.dropdownHidden = false; | ||||
|     } else { | ||||
|       this.dropdownHidden = true; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[A-z]{2,5}1[a-zA-HJ-NP-Z0-9]{39,59})$/; | ||||
|   regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/; | ||||
| @ -38,13 +48,14 @@ export class SearchFormComponent implements OnInit { | ||||
|   } | ||||
| 
 | ||||
|   constructor( | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private router: Router, | ||||
|     private assetsService: AssetsService, | ||||
|     private stateService: StateService, | ||||
|     private electrsApiService: ElectrsApiService, | ||||
|     private apiService: ApiService, | ||||
|     private relativeUrlPipe: RelativeUrlPipe, | ||||
|     private elementRef: ElementRef, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|  | ||||
| @ -13,46 +13,46 @@ | ||||
|           <form [formGroup]="radioGroupForm" class="formRadioGroup" | ||||
|             [class]="(stateService.env.MINING_DASHBOARD || stateService.env.LIGHTNING) ? 'mining' : 'no-menu'" (click)="saveGraphPreference()"> | ||||
|             <div *ngIf="!isMobile()" class="btn-group btn-group-toggle"> | ||||
|               <label ngbButtonLabel class="btn-primary btn-sm mr-2"> | ||||
|               <label class="btn btn-primary btn-sm mr-2"> | ||||
|                 <a [routerLink]="['/tv' | relativeUrl]" style="color: white" id="btn-tv"> | ||||
|                   <fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon> | ||||
|                 </a> | ||||
|                 </label> | ||||
|             </div> | ||||
|             <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> | ||||
|               <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|                 <input ngbButton type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h"> 2H | ||||
|             <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '2h'"> | ||||
|                 <input type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h" formControlName="dateSpan"> 2H | ||||
|                 (LIVE) | ||||
|               </label> | ||||
|               <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|                 <input ngbButton type="radio" [value]="'24h'" [routerLink]="['/graphs' | relativeUrl]" fragment="24h"> | ||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '24h'"> | ||||
|                 <input type="radio" [value]="'24h'" [routerLink]="['/graphs' | relativeUrl]" fragment="24h" formControlName="dateSpan"> | ||||
|                 24H | ||||
|               </label> | ||||
|               <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|                 <input ngbButton type="radio" [value]="'1w'" [routerLink]="['/graphs' | relativeUrl]" fragment="1w"> 1W | ||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1w'"> | ||||
|                 <input type="radio" [value]="'1w'" [routerLink]="['/graphs' | relativeUrl]" fragment="1w" formControlName="dateSpan"> 1W | ||||
|               </label> | ||||
|               <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|                 <input ngbButton type="radio" [value]="'1m'" [routerLink]="['/graphs' | relativeUrl]" fragment="1m"> 1M | ||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1m'"> | ||||
|                 <input type="radio" [value]="'1m'" [routerLink]="['/graphs' | relativeUrl]" fragment="1m" formControlName="dateSpan"> 1M | ||||
|               </label> | ||||
|               <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|                 <input ngbButton type="radio" [value]="'3m'" [routerLink]="['/graphs' | relativeUrl]" fragment="3m"> 3M | ||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '3m'"> | ||||
|                 <input type="radio" [value]="'3m'" [routerLink]="['/graphs' | relativeUrl]" fragment="3m" formControlName="dateSpan"> 3M | ||||
|               </label> | ||||
|               <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|                 <input ngbButton type="radio" [value]="'6m'" [routerLink]="['/graphs' | relativeUrl]" fragment="6m"> 6M | ||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '6m'"> | ||||
|                 <input type="radio" [value]="'6m'" [routerLink]="['/graphs' | relativeUrl]" fragment="6m" formControlName="dateSpan"> 6M | ||||
|               </label> | ||||
|               <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|                 <input ngbButton type="radio" [value]="'1y'" [routerLink]="['/graphs' | relativeUrl]" fragment="1y"> 1Y | ||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1y'"> | ||||
|                 <input type="radio" [value]="'1y'" [routerLink]="['/graphs' | relativeUrl]" fragment="1y" formControlName="dateSpan"> 1Y | ||||
|               </label> | ||||
|               <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|                 <input ngbButton type="radio" [value]="'2y'" [routerLink]="['/graphs' | relativeUrl]" fragment="2y"> 2Y | ||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '2y'"> | ||||
|                 <input type="radio" [value]="'2y'" [routerLink]="['/graphs' | relativeUrl]" fragment="2y" formControlName="dateSpan"> 2Y | ||||
|               </label> | ||||
|               <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|                 <input ngbButton type="radio" [value]="'3y'" [routerLink]="['/graphs' | relativeUrl]" fragment="3y"> 3Y | ||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '3y'"> | ||||
|                 <input type="radio" [value]="'3y'" [routerLink]="['/graphs' | relativeUrl]" fragment="3y" formControlName="dateSpan"> 3Y | ||||
|               </label> | ||||
|             </div> | ||||
|             <div class="small-buttons"> | ||||
|               <div ngbDropdown #myDrop="ngbDropdown"> | ||||
|                 <button class="btn btn-primary btn-sm" id="dropdownFees" ngbDropdownAnchor (click)="myDrop.toggle()"> | ||||
|                 <button class="btn btn btn-primary btn-sm" id="dropdownFees" ngbDropdownAnchor (click)="myDrop.toggle()"> | ||||
|                   <fa-icon [icon]="['fas', 'filter']" [fixedWidth]="true" i18n-title="statistics.component-filter.title" | ||||
|                     title="Filter"></fa-icon> | ||||
|                 </button> | ||||
| @ -71,7 +71,7 @@ | ||||
|                 </div> | ||||
|               </div> | ||||
| 
 | ||||
|               <button (click)="invertGraph()" class="btn btn-primary btn-sm"> | ||||
|               <button (click)="invertGraph()" class="btn btn btn-primary btn-sm"> | ||||
|                 <fa-icon [icon]="['fas', 'exchange-alt']" [rotate]="90" [fixedWidth]="true" | ||||
|                   i18n-title="statistics.component-invert.title" title="Invert"></fa-icon> | ||||
|               </button> | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { Component, OnInit, LOCALE_ID, Inject, ViewChild, ElementRef } from '@angular/core'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { FormGroup, FormBuilder } from '@angular/forms'; | ||||
| import { UntypedFormGroup, UntypedFormBuilder } from '@angular/forms'; | ||||
| import { of, merge} from 'rxjs'; | ||||
| import { switchMap } from 'rxjs/operators'; | ||||
| 
 | ||||
| @ -39,7 +39,7 @@ export class StatisticsComponent implements OnInit { | ||||
|   mempoolUnconfirmedTransactionsData: any; | ||||
|   mempoolTransactionsWeightPerSecondData: any; | ||||
| 
 | ||||
|   radioGroupForm: FormGroup; | ||||
|   radioGroupForm: UntypedFormGroup; | ||||
|   graphWindowPreference: string; | ||||
|   inverted: boolean; | ||||
|   feeLevelDropdownData = []; | ||||
| @ -47,7 +47,7 @@ export class StatisticsComponent implements OnInit { | ||||
| 
 | ||||
|   constructor( | ||||
|     @Inject(LOCALE_ID) private locale: string, | ||||
|     private formBuilder: FormBuilder, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private route: ActivatedRoute, | ||||
|     private websocketService: WebsocketService, | ||||
|     private apiService: ApiService, | ||||
|  | ||||
| @ -126,9 +126,13 @@ export class LiquidUnblinding { | ||||
|   } | ||||
| 
 | ||||
|   async checkUnblindedTx(tx: Transaction) { | ||||
|     const windowLocationHash = window.location.hash.substring('#blinded='.length); | ||||
|     if (windowLocationHash.length > 0) { | ||||
|       const blinders = this.parseBlinders(windowLocationHash); | ||||
|     if (!window.location.hash?.length) { | ||||
|       return tx; | ||||
|     } | ||||
|     const fragmentParams = new URLSearchParams(window.location.hash.slice(1) || ''); | ||||
|     const blinderStr = fragmentParams.get('blinded'); | ||||
|     if (blinderStr && blinderStr.length) { | ||||
|       const blinders = this.parseBlinders(blinderStr); | ||||
|       if (blinders) { | ||||
|         this.commitments = await this.makeCommitmentMap(blinders); | ||||
|         return this.tryUnblindTx(tx); | ||||
|  | ||||
| @ -8,10 +8,10 @@ | ||||
|     </div> | ||||
|     <div *ngIf="network !== 'liquid' && network !== 'liquidtestnet'" class="features"> | ||||
|       <app-tx-features [tx]="tx"></app-tx-features> | ||||
|       <span *ngIf="cpfpInfo && cpfpInfo.bestDescendant" class="badge badge-primary mr-1"> | ||||
|       <span *ngIf="cpfpInfo && (cpfpInfo.bestDescendant || cpfpInfo.descendants.length)" class="badge badge-primary mr-1"> | ||||
|         CPFP | ||||
|       </span> | ||||
|       <span *ngIf="cpfpInfo && !cpfpInfo.bestDescendant && cpfpInfo.ancestors.length" class="badge badge-info mr-1"> | ||||
|       <span *ngIf="cpfpInfo && !cpfpInfo.bestDescendant && !cpfpInfo.descendants.length && cpfpInfo.ancestors.length" class="badge badge-info mr-1"> | ||||
|         CPFP | ||||
|       </span> | ||||
|     </div> | ||||
| @ -29,7 +29,7 @@ | ||||
| 
 | ||||
| 
 | ||||
|   <div class="row graph-wrapper"> | ||||
|     <tx-bowtie-graph [tx]="tx" [width]="1112" [height]="346" [network]="network"></tx-bowtie-graph> | ||||
|     <tx-bowtie-graph [tx]="tx" [width]="1132" [height]="346" [network]="network"></tx-bowtie-graph> | ||||
|     <div class="above-bow"> | ||||
|       <p class="field pair"> | ||||
|         <span [innerHTML]="'‎' + (tx.size | bytes: 2)"></span> | ||||
| @ -41,24 +41,20 @@ | ||||
|     </div> | ||||
|     <div class="overlaid"> | ||||
|       <ng-container [ngSwitch]="extraData"> | ||||
|         <table class="opreturns" *ngSwitchCase="'coinbase'"> | ||||
|           <tbody> | ||||
|             <tr> | ||||
|               <td class="label">Coinbase</td> | ||||
|               <td class="message">{{ tx.vin[0].scriptsig | hex2ascii }}</td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|         <table class="opreturns" *ngSwitchCase="'opreturn'"> | ||||
|           <tbody> | ||||
|         <div class="opreturns" *ngSwitchCase="'coinbase'"> | ||||
|             <div class="opreturn-row"> | ||||
|               <span class="label">Coinbase</span> | ||||
|               <span class="message">{{ tx.vin[0].scriptsig | hex2ascii }}</span> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div class="opreturns" *ngSwitchCase="'opreturn'"> | ||||
|             <ng-container *ngFor="let vout of opReturns.slice(0,3)"> | ||||
|               <tr> | ||||
|                 <td class="label">OP_RETURN</td> | ||||
|                 <td *ngIf="vout.scriptpubkey_asm !== 'OP_RETURN'" class="message">{{ vout.scriptpubkey_asm | hex2ascii }}</td> | ||||
|               </tr> | ||||
|               <div class="opreturn-row"> | ||||
|                 <span class="label">OP_RETURN</span> | ||||
|                 <span *ngIf="vout.scriptpubkey_asm !== 'OP_RETURN'" class="message">{{ vout.scriptpubkey_asm | hex2ascii }}</span> | ||||
|               </div> | ||||
|             </ng-container> | ||||
|           </tbody> | ||||
|         </table> | ||||
|         </div> | ||||
|       </ng-container> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
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