Merge branch 'master' into nymkappa/feature/rename-mining-pool
This commit is contained in:
		
						commit
						5da8f2b6dc
					
				
							
								
								
									
										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/", | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -1,5 +1,10 @@ | ||||
| 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
 | ||||
| 
 | ||||
| @ -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,6 +76,7 @@ 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); | ||||
| @ -82,11 +86,13 @@ class Audit { | ||||
|         } | ||||
|         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 +100,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]; | ||||
|         } | ||||
| @ -113,6 +122,45 @@ class Audit { | ||||
|       score | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   public async $getBlockAuditScores(fromHeight?: number, limit: number = 15): Promise<AuditScore[]> { | ||||
|     let currentHeight = fromHeight !== undefined ? fromHeight : await blocksRepository.$mostRecentBlockHeight(); | ||||
|     const returnScores: AuditScore[] = []; | ||||
| 
 | ||||
|     if (currentHeight < 0) { | ||||
|       return returnScores; | ||||
|     } | ||||
| 
 | ||||
|     for (let i = 0; i < limit && currentHeight >= 0; i++) { | ||||
|       const block = blocks.getBlocks().find((b) => b.height === currentHeight); | ||||
|       if (block?.extras?.matchRate != null) { | ||||
|         returnScores.push({ | ||||
|           hash: block.id, | ||||
|           matchRate: block.extras.matchRate | ||||
|         }); | ||||
|       } else { | ||||
|         let currentHash; | ||||
|         if (!currentHash && Common.indexingEnabled()) { | ||||
|           const dbBlock = await blocksRepository.$getBlockByHeight(currentHeight); | ||||
|           if (dbBlock && dbBlock['id']) { | ||||
|             currentHash = dbBlock['id']; | ||||
|           } | ||||
|         } | ||||
|         if (!currentHash) { | ||||
|           currentHash = await bitcoinApi.$getBlockHash(currentHeight); | ||||
|         } | ||||
|         if (currentHash) { | ||||
|           const auditScore = await blocksAuditsRepository.$getBlockAuditScore(currentHash); | ||||
|           returnScores.push({ | ||||
|             hash: currentHash, | ||||
|             matchRate: auditScore?.matchRate | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|       currentHeight--; | ||||
|     } | ||||
|     return returnScores; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new Audit(); | ||||
| @ -130,7 +130,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 +195,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; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -4,7 +4,7 @@ import logger from '../logger'; | ||||
| import { Common } from './common'; | ||||
| 
 | ||||
| class DatabaseMigration { | ||||
|   private static currentVersion = 41; | ||||
|   private static currentVersion = 43; | ||||
|   private queryTimeout = 120000; | ||||
|   private statisticsAddedIndexed = false; | ||||
|   private uniqueLogs: string[] = []; | ||||
| @ -352,6 +352,14 @@ 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')); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
| @ -787,6 +795,19 @@ 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;`;
 | ||||
|   } | ||||
| 
 | ||||
|   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)}`); | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -103,12 +103,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 +123,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 +131,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)); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
| @ -197,8 +190,7 @@ class Mempool { | ||||
| 
 | ||||
|     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,52 @@ 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 { | ||||
|       const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); | ||||
|       res.json(await audits.$getBlockAuditScores(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(); | ||||
|  | ||||
| @ -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; | ||||
| @ -119,6 +120,7 @@ interface IConfig { | ||||
| 
 | ||||
| const defaults: IConfig = { | ||||
|   'MEMPOOL': { | ||||
|     'ENABLED': true, | ||||
|     'NETWORK': 'mainnet', | ||||
|     'BACKEND': 'none', | ||||
|     'HTTP_PORT': 8999, | ||||
| @ -224,11 +226,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,7 @@ 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'; | ||||
| 
 | ||||
| class Server { | ||||
|   private wss: WebSocket.Server | undefined; | ||||
| @ -74,7 +74,7 @@ class Server { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async startServer(worker = false) { | ||||
|   async startServer(worker = false): Promise<void> { | ||||
|     logger.notice(`Starting Mempool Server${worker ? ' (worker)' : ''}... (${backendInfo.getShortCommitHash()})`); | ||||
| 
 | ||||
|     this.app | ||||
| @ -92,7 +92,9 @@ class Server { | ||||
|     this.setUpWebsocketHandling(); | ||||
| 
 | ||||
|     await syncAssets.syncAssets$(); | ||||
|     diskCache.loadMempoolCache(); | ||||
|     if (config.MEMPOOL.ENABLED) { | ||||
|       diskCache.loadMempoolCache(); | ||||
|     } | ||||
| 
 | ||||
|     if (config.DATABASE.ENABLED) { | ||||
|       await DB.checkDbConnection(); | ||||
| @ -127,7 +129,10 @@ class Server { | ||||
|     fiatConversion.startService(); | ||||
| 
 | ||||
|     this.setUpHttpApiRoutes(); | ||||
|     this.runMainUpdateLoop(); | ||||
| 
 | ||||
|     if (config.MEMPOOL.ENABLED) { | ||||
|       this.runMainUpdateLoop(); | ||||
|     } | ||||
| 
 | ||||
|     if (config.BISQ.ENABLED) { | ||||
|       bisq.startBisqService(); | ||||
| @ -149,7 +154,7 @@ class Server { | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   async runMainUpdateLoop() { | ||||
|   async runMainUpdateLoop(): Promise<void> { | ||||
|     try { | ||||
|       try { | ||||
|         await memPool.$updateMemPoolInfo(); | ||||
| @ -183,7 +188,7 @@ class Server { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async $runLightningBackend() { | ||||
|   async $runLightningBackend(): Promise<void> { | ||||
|     try { | ||||
|       await fundingTxFetcher.$init(); | ||||
|       await networkSyncService.$startService(); | ||||
| @ -195,7 +200,7 @@ class Server { | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
|   setUpWebsocketHandling() { | ||||
|   setUpWebsocketHandling(): void { | ||||
|     if (this.wss) { | ||||
|       websocketHandler.setWebsocketServer(this.wss); | ||||
|     } | ||||
| @ -209,19 +214,21 @@ class Server { | ||||
|       }); | ||||
|     } | ||||
|     websocketHandler.setupConnectionHandling(); | ||||
|     statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler)); | ||||
|     blocks.setNewBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler)); | ||||
|     memPool.setMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler)); | ||||
|     if (config.MEMPOOL.ENABLED) { | ||||
|       statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler)); | ||||
|       blocks.setNewBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler)); | ||||
|       memPool.setMempoolChangedCallback(websocketHandler.handleMempoolChange.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 +245,4 @@ class Server { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const server = new Server(); | ||||
| ((): Server => new Server())(); | ||||
|  | ||||
| @ -32,6 +32,11 @@ export interface BlockAudit { | ||||
|   matchRate: number, | ||||
| } | ||||
| 
 | ||||
| export interface AuditScore { | ||||
|   hash: string, | ||||
|   matchRate?: number, | ||||
| } | ||||
| 
 | ||||
| export interface MempoolBlock { | ||||
|   blockSize: number; | ||||
|   blockVSize: number; | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| 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> { | ||||
| @ -72,10 +72,10 @@ class BlocksAuditRepositories { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   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}" | ||||
|       `);
 | ||||
|  | ||||
| @ -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 | ||||
|    */ | ||||
|  | ||||
							
								
								
									
										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(); | ||||
| @ -13,6 +13,7 @@ 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'; | ||||
| 
 | ||||
| class NetworkSyncService { | ||||
|   loggerTimer = 0; | ||||
| @ -63,6 +64,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 +86,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); | ||||
|       } | ||||
|       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`); | ||||
|     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); | ||||
| @ -309,7 +326,7 @@ class NetworkSyncService { | ||||
|          └──────────────────┘ | ||||
|   */ | ||||
| 
 | ||||
|   private async $runClosedChannelsForensics(): Promise<void> { | ||||
|   private async $runClosedChannelsForensics(skipUnresolved: boolean = false): Promise<void> { | ||||
|     if (!config.ESPLORA.REST_API_URL) { | ||||
|       return; | ||||
|     } | ||||
| @ -318,9 +335,18 @@ class NetworkSyncService { | ||||
| 
 | ||||
|     try { | ||||
|       logger.info(`Started running closed channel forensics...`); | ||||
|       const channels = await channelsApi.$getClosedChannelsWithoutReason(); | ||||
|       let channels; | ||||
|       const closedChannels = await channelsApi.$getClosedChannelsWithoutReason(); | ||||
|       if (skipUnresolved) { | ||||
|         channels = closedChannels; | ||||
|       } else { | ||||
|         const unresolvedChannels = await channelsApi.$getUnresolvedClosedChannels(); | ||||
|         channels = [...closedChannels, ...unresolvedChannels]; | ||||
|       } | ||||
| 
 | ||||
|       for (const channel of channels) { | ||||
|         let reason = 0; | ||||
|         let resolvedForceClose = false; | ||||
|         // Only Esplora backend can retrieve spent transaction outputs
 | ||||
|         try { | ||||
|           let outspends: IEsploraApi.Outspend[] | undefined; | ||||
| @ -350,6 +376,7 @@ class NetworkSyncService { | ||||
|               reason = 3; | ||||
|             } else { | ||||
|               reason = 2; | ||||
|               resolvedForceClose = true; | ||||
|             } | ||||
|           } else { | ||||
|             /* | ||||
| @ -374,6 +401,9 @@ class NetworkSyncService { | ||||
|           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]); | ||||
|             } | ||||
|           } | ||||
|         } catch (e) { | ||||
|           logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`); | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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 = {}; | ||||
| @ -67,10 +68,17 @@ if (process.env.DOCKER_COMMIT_HASH) { | ||||
| 
 | ||||
| const newConfig = `(function (window) {
 | ||||
|   window.__env = window.__env || {};${settings.reduce((str, obj) => `${str} | ||||
|     window.__env.${obj.key} = ${ typeof obj.value === 'string' ? `'${obj.value}'` : obj.value };`, '')}
 | ||||
|     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`); | ||||
| }; | ||||
| } | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
							
								
								
									
										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; | ||||
| @ -41,10 +41,6 @@ | ||||
|                   </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> | ||||
| @ -61,6 +57,10 @@ | ||||
|         <div class="col-sm" *ngIf="blockAudit"> | ||||
|           <table class="table table-borderless table-striped"> | ||||
|             <tbody> | ||||
|               <tr> | ||||
|                 <td class="td-width" i18n="shared.transaction-count">Transactions</td> | ||||
|                 <td>{{ blockAudit.tx_count }}</td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td i18n="block.health">Block health</td> | ||||
|                 <td>{{ blockAudit.matchRate }}%</td> | ||||
| @ -69,18 +69,10 @@ | ||||
|                 <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> | ||||
| @ -97,21 +89,6 @@ | ||||
|   </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"> | ||||
| @ -123,7 +100,6 @@ | ||||
|               <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> | ||||
| @ -136,7 +112,6 @@ | ||||
|               <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> | ||||
| @ -180,16 +155,16 @@ | ||||
|       <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> | ||||
|           [blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" [mirrorTxid]="hoverTx" | ||||
|           (txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($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> | ||||
|           [blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" [mirrorTxid]="hoverTx" | ||||
|           (txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($event)"></app-block-overview-graph> | ||||
|       </div> | ||||
|     </div> <!-- row --> | ||||
|   </div> <!-- box --> | ||||
|  | ||||
| @ -1,9 +1,10 @@ | ||||
| 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 { Subscription, combineLatest, of } from 'rxjs'; | ||||
| import { map, switchMap, startWith, catchError, filter } from 'rxjs/operators'; | ||||
| import { BlockAudit, TransactionStripped } from '../../interfaces/node-api.interface'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { ElectrsApiService } from '../../services/electrs-api.service'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { detectWebGL } from '../../shared/graphs.utils'; | ||||
| import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; | ||||
| @ -37,6 +38,7 @@ export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|   isLoading = true; | ||||
|   webGlEnabled = true; | ||||
|   isMobile = window.innerWidth <= 767.98; | ||||
|   hoverTx: string; | ||||
| 
 | ||||
|   childChangeSubscription: Subscription; | ||||
| 
 | ||||
| @ -51,7 +53,8 @@ export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|     private route: ActivatedRoute, | ||||
|     public stateService: StateService, | ||||
|     private router: Router, | ||||
|     private apiService: ApiService | ||||
|     private apiService: ApiService, | ||||
|     private electrsApiService: ElectrsApiService, | ||||
|   ) { | ||||
|     this.webGlEnabled = detectWebGL(); | ||||
|   } | ||||
| @ -76,69 +79,95 @@ export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
| 
 | ||||
|     this.auditSubscription = this.route.paramMap.pipe( | ||||
|       switchMap((params: ParamMap) => { | ||||
|         this.blockHash = params.get('id') || null; | ||||
|         if (!this.blockHash) { | ||||
|         const blockHash = params.get('id') || null; | ||||
|         if (!blockHash) { | ||||
|           return null; | ||||
|         } | ||||
| 
 | ||||
|         let isBlockHeight = false; | ||||
|         if (/^[0-9]+$/.test(blockHash)) { | ||||
|           isBlockHeight = true; | ||||
|         } else { | ||||
|           this.blockHash = blockHash; | ||||
|         } | ||||
| 
 | ||||
|         if (isBlockHeight) { | ||||
|           return this.electrsApiService.getBlockHashFromHeight$(parseInt(blockHash, 10)) | ||||
|             .pipe( | ||||
|               switchMap((hash: string) => { | ||||
|                 if (hash) { | ||||
|                   this.blockHash = hash; | ||||
|                   return this.apiService.getBlockAudit$(this.blockHash) | ||||
|                 } else { | ||||
|                   return null; | ||||
|                 } | ||||
|               }), | ||||
|               catchError((err) => { | ||||
|                 this.error = err; | ||||
|                 return of(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; | ||||
|             }) | ||||
|           ); | ||||
|       }), | ||||
|       filter((response) => response != null), | ||||
|       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 (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; | ||||
|         } | ||||
|         return blockAudit; | ||||
|       }), | ||||
|       catchError((err) => { | ||||
|         console.log(err); | ||||
|         this.error = err; | ||||
|         this.isLoading = false; | ||||
|         return null; | ||||
|         return of(null); | ||||
|       }), | ||||
|     ).subscribe((blockAudit) => { | ||||
|       this.blockAudit = blockAudit; | ||||
| @ -189,4 +218,12 @@ export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|     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; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -18,7 +18,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|   @Input() orientation = 'left'; | ||||
|   @Input() flip = true; | ||||
|   @Input() disableSpinner = false; | ||||
|   @Input() mirrorTxid: string | void; | ||||
|   @Output() txClickEvent = new EventEmitter<TransactionStripped>(); | ||||
|   @Output() txHoverEvent = new EventEmitter<string>(); | ||||
|   @Output() readyEvent = new EventEmitter(); | ||||
| 
 | ||||
|   @ViewChild('blockCanvas') | ||||
| @ -37,6 +39,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 +66,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 +82,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|     this.exit(direction); | ||||
|     this.hoverTx = null; | ||||
|     this.selectedTx = null; | ||||
|     this.onTxHover(null); | ||||
|     this.start(); | ||||
|   } | ||||
| 
 | ||||
| @ -301,6 +308,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|       } | ||||
|       this.hoverTx = null; | ||||
|       this.selectedTx = null; | ||||
|       this.onTxHover(null); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| @ -352,17 +360,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 +381,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 +401,10 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|       this.txClickEvent.emit(selected); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onTxHover(hoverId: string) { | ||||
|     this.txHoverEvent.emit(hoverId); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // WebGL shader attributes
 | ||||
|  | ||||
| @ -12,8 +12,8 @@ const auditFeeColors = feeColors.map((color) => desaturate(color, 0.3)); | ||||
| 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
 | ||||
|  | ||||
| @ -37,9 +37,9 @@ | ||||
|         <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.omitted">omitted</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.extra">extra</td> | ||||
|         </ng-container> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|  | ||||
| @ -114,7 +114,7 @@ | ||||
|                   <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> | ||||
|                     <span *ngIf="block.extras?.matchRate === null" i18n="unknown">Unknown</span> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|               </ng-template> | ||||
|  | ||||
| @ -4,7 +4,7 @@ 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 { Transaction, Vout } from '../../interfaces/electrs.interface'; | ||||
| import { Observable, of, Subscription, asyncScheduler, EMPTY } from 'rxjs'; | ||||
| import { Observable, of, Subscription, asyncScheduler, EMPTY, Subject } from 'rxjs'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { WebsocketService } from '../../services/websocket.service'; | ||||
| @ -60,6 +60,8 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|   nextBlockTxListSubscription: Subscription = undefined; | ||||
|   timeLtrSubscription: Subscription; | ||||
|   timeLtr: boolean; | ||||
|   fetchAuditScore$ = new Subject<string>(); | ||||
|   fetchAuditScoreSubscription: Subscription; | ||||
| 
 | ||||
|   @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; | ||||
| 
 | ||||
| @ -105,12 +107,30 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
| 
 | ||||
|         if (block.id === this.blockHash) { | ||||
|           this.block = block; | ||||
|           if (this.block.id && this.block?.extras?.matchRate == null) { | ||||
|             this.fetchAuditScore$.next(this.block.id); | ||||
|           } | ||||
|           if (block?.extras?.reward != undefined) { | ||||
|             this.fees = block.extras.reward / 100000000 - this.blockSubsidy; | ||||
|           } | ||||
|         } | ||||
|       }); | ||||
| 
 | ||||
|     if (this.indexingAvailable) { | ||||
|       this.fetchAuditScoreSubscription = this.fetchAuditScore$ | ||||
|         .pipe( | ||||
|           switchMap((hash) => this.apiService.getBlockAuditScore$(hash)), | ||||
|           catchError(() => EMPTY), | ||||
|         ) | ||||
|         .subscribe((score) => { | ||||
|           if (score && score.hash === this.block.id) { | ||||
|             this.block.extras.matchRate = score.matchRate || null; | ||||
|           } else { | ||||
|             this.block.extras.matchRate = null; | ||||
|           } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     const block$ = this.route.paramMap.pipe( | ||||
|       switchMap((params: ParamMap) => { | ||||
|         const blockHash: string = params.get('id') || ''; | ||||
| @ -209,6 +229,9 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|           this.fees = block.extras.reward / 100000000 - this.blockSubsidy; | ||||
|         } | ||||
|         this.stateService.markBlock$.next({ blockHeight: this.blockHeight }); | ||||
|         if (this.block.id && this.block?.extras?.matchRate == null) { | ||||
|           this.fetchAuditScore$.next(this.block.id); | ||||
|         } | ||||
|         this.isLoadingTransactions = true; | ||||
|         this.transactions = null; | ||||
|         this.transactionsError = null; | ||||
| @ -311,6 +334,7 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|     this.networkChangedSubscription.unsubscribe(); | ||||
|     this.queryParamsSubscription.unsubscribe(); | ||||
|     this.timeLtrSubscription.unsubscribe(); | ||||
|     this.fetchAuditScoreSubscription?.unsubscribe(); | ||||
|     this.unsubscribeNextBlockSubscriptions(); | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -46,22 +46,17 @@ | ||||
|             <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-audit/' | 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] === undefined" class="skeleton-loader"></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) { | ||||
|  | ||||
| @ -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,4 +1,4 @@ | ||||
| import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener } from '@angular/core'; | ||||
| import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener, ElementRef } from '@angular/core'; | ||||
| import { FormBuilder, FormGroup, Validators } from '@angular/forms'; | ||||
| import { Router } from '@angular/router'; | ||||
| import { AssetsService } from '../../services/assets.service'; | ||||
| @ -23,6 +23,16 @@ export class SearchFormComponent implements OnInit { | ||||
|   isTypeaheading$ = new BehaviorSubject<boolean>(false); | ||||
|   typeAhead$: Observable<any>; | ||||
|   searchForm: FormGroup; | ||||
|   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}$/; | ||||
| @ -45,6 +55,7 @@ export class SearchFormComponent implements OnInit { | ||||
|     private electrsApiService: ElectrsApiService, | ||||
|     private apiService: ApiService, | ||||
|     private relativeUrlPipe: RelativeUrlPipe, | ||||
|     private elementRef: ElementRef, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|  | ||||
| @ -117,8 +117,9 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { | ||||
|         }), | ||||
|         switchMap(() => { | ||||
|           let transactionObservable$: Observable<Transaction>; | ||||
|           if (history.state.data && history.state.data.fee !== -1) { | ||||
|             transactionObservable$ = of(history.state.data); | ||||
|           const cached = this.stateService.getTxFromCache(this.txId); | ||||
|           if (cached && cached.fee !== -1) { | ||||
|             transactionObservable$ = of(cached); | ||||
|           } else { | ||||
|             transactionObservable$ = this.electrsApiService | ||||
|               .getTransaction$(this.txId) | ||||
|  | ||||
| @ -3,7 +3,7 @@ | ||||
|   <div class="title-block"> | ||||
|     <div *ngIf="rbfTransaction" class="alert alert-mempool" role="alert"> | ||||
|       <span i18n="transaction.rbf.replacement|RBF replacement">This transaction has been replaced by:</span> | ||||
|       <a class="alert-link" [routerLink]="['/tx/' | relativeUrl, rbfTransaction.txid]" [state]="{ data: rbfTransaction.size ? rbfTransaction : null }"> | ||||
|       <a class="alert-link" [routerLink]="['/tx/' | relativeUrl, rbfTransaction.txid]"> | ||||
|         <span class="d-inline d-lg-none">{{ rbfTransaction.txid | shortenString : 24 }}</span> | ||||
|         <span class="d-none d-lg-inline">{{ rbfTransaction.txid }}</span> | ||||
|       </a> | ||||
|  | ||||
| @ -183,8 +183,9 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|         }), | ||||
|         switchMap(() => { | ||||
|           let transactionObservable$: Observable<Transaction>; | ||||
|           if (history.state.data && history.state.data.fee !== -1) { | ||||
|             transactionObservable$ = of(history.state.data); | ||||
|           const cached = this.stateService.getTxFromCache(this.txId); | ||||
|           if (cached && cached.fee !== -1) { | ||||
|             transactionObservable$ = of(cached); | ||||
|           } else { | ||||
|             transactionObservable$ = this.electrsApiService | ||||
|               .getTransaction$(this.txId) | ||||
| @ -279,6 +280,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|         this.waitingForTransaction = false; | ||||
|       } | ||||
|       this.rbfTransaction = rbfTransaction; | ||||
|       this.stateService.setTxCache([this.rbfTransaction]); | ||||
|     }); | ||||
| 
 | ||||
|     this.queryParamsSubscription = this.route.queryParams.subscribe((params) => { | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| <ng-container *ngFor="let tx of transactions; let i = index; trackBy: trackByFn"> | ||||
|   <div *ngIf="!transactionPage" class="header-bg box tx-page-container"> | ||||
|     <a class="float-left" [routerLink]="['/tx/' | relativeUrl, tx.txid]" [state]="{ data: tx }"> | ||||
|     <a class="float-left" [routerLink]="['/tx/' | relativeUrl, tx.txid]"> | ||||
|       <span style="float: left;" class="d-block d-md-none">{{ tx.txid | shortenString : 16 }}</span> | ||||
|       <span style="float: left;" class="d-none d-md-block">{{ tx.txid }}</span> | ||||
|     </a> | ||||
|  | ||||
| @ -119,7 +119,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { | ||||
|       } | ||||
| 
 | ||||
|       this.transactionsLength = this.transactions.length; | ||||
| 
 | ||||
|       this.stateService.setTxCache(this.transactions); | ||||
| 
 | ||||
|       this.transactions.forEach((tx) => { | ||||
|         tx['@voutLimit'] = true; | ||||
|  | ||||
| @ -152,6 +152,11 @@ export interface RewardStats { | ||||
|   totalTx: number; | ||||
| } | ||||
| 
 | ||||
| export interface AuditScore { | ||||
|   hash: string; | ||||
|   matchRate?: number; | ||||
| } | ||||
| 
 | ||||
| export interface ITopNodesPerChannels { | ||||
|   publicKey: string, | ||||
|   alias: string, | ||||
|  | ||||
							
								
								
									
										31
									
								
								frontend/src/app/lightning/node/liquidity-ad.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								frontend/src/app/lightning/node/liquidity-ad.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | ||||
| export interface ILiquidityAd { | ||||
|   funding_weight: number; | ||||
|   lease_fee_basis: number; // lease fee rate in parts-per-thousandth
 | ||||
|   lease_fee_base_sat: number; // fixed lease fee in sats
 | ||||
|   channel_fee_max_rate: number; // max routing fee rate in parts-per-thousandth
 | ||||
|   channel_fee_max_base: number; // max routing base fee in milli-sats
 | ||||
|   compact_lease?: string; | ||||
| } | ||||
| 
 | ||||
| export function parseLiquidityAdHex(compact_lease: string): ILiquidityAd | false { | ||||
|   if (!compact_lease || compact_lease.length < 20 || compact_lease.length > 28) { | ||||
|     return false; | ||||
|   } | ||||
|   try { | ||||
|     const liquidityAd: ILiquidityAd = { | ||||
|       funding_weight: parseInt(compact_lease.slice(0, 4), 16), | ||||
|       lease_fee_basis: parseInt(compact_lease.slice(4, 8), 16), | ||||
|       channel_fee_max_rate: parseInt(compact_lease.slice(8, 12), 16), | ||||
|       lease_fee_base_sat: parseInt(compact_lease.slice(12, 20), 16), | ||||
|       channel_fee_max_base: compact_lease.length > 20 ? parseInt(compact_lease.slice(20), 16) : 0, | ||||
|     } | ||||
|     if (Object.values(liquidityAd).reduce((valid: boolean, value: number): boolean => (valid && !isNaN(value) && value >= 0), true)) { | ||||
|       liquidityAd.compact_lease = compact_lease; | ||||
|       return liquidityAd; | ||||
|     } else { | ||||
|       return false; | ||||
|     } | ||||
|   } catch (err) { | ||||
|     return false; | ||||
|   } | ||||
| } | ||||
| @ -125,6 +125,93 @@ | ||||
|     <app-clipboard [button]="true" [text]="node.socketsObject[selectedSocketIndex].socket" [leftPadding]="false"></app-clipboard> | ||||
|   </div> | ||||
| 
 | ||||
|   <div *ngIf="hasDetails" [hidden]="!showDetails" id="details" class="details mt-3"> | ||||
|     <div class="box"> | ||||
|       <ng-template [ngIf]="liquidityAd"> | ||||
|         <div class="detail-section"> | ||||
|           <h5 class="mb-3" i18n="node.liquidity-ad">Liquidity ad</h5> | ||||
|           <div class="row"> | ||||
|             <div class="col-md"> | ||||
|               <table class="table table-borderless table-striped"> | ||||
|                 <tbody> | ||||
|                   <tr> | ||||
|                     <td class="label" i18n="liquidity-ad.lease-fee-rate|Liquidity ad lease fee rate">Lease fee rate</td> | ||||
|                     <td> | ||||
|                       <span class="d-inline-block"> | ||||
|                         {{ liquidityAd.lease_fee_basis !== null ? ((liquidityAd.lease_fee_basis * 1000) | amountShortener : 2 : undefined : true) : '-' }} <span class="symbol">ppm {{ liquidityAd.lease_fee_basis !== null ? '(' + (liquidityAd.lease_fee_basis / 10 | amountShortener : 2 : undefined : true) + '%)' : '' }}</span> | ||||
|                       </span> | ||||
|                     </td> | ||||
|                   </tr> | ||||
|                   <tr> | ||||
|                     <td class="label" i18n="liquidity-ad.lease-base-fee">Lease base fee</td> | ||||
|                     <td> | ||||
|                       <app-sats [valueOverride]="liquidityAd.lease_fee_base_sat === null ? '- ' : undefined" [satoshis]="liquidityAd.lease_fee_base_sat"></app-sats> | ||||
|                     </td> | ||||
|                   </tr> | ||||
|                   <tr> | ||||
|                     <td class="label" i18n="liquidity-ad.funding-weight">Funding weight</td> | ||||
|                     <td [innerHTML]="'‎' + (liquidityAd.funding_weight | wuBytes: 2)"></td> | ||||
|                   </tr> | ||||
|                 </tbody> | ||||
|               </table> | ||||
|             </div> | ||||
|             <div class="col-md"> | ||||
|               <table class="table table-borderless table-striped"> | ||||
|                 <tbody> | ||||
|                   <tr> | ||||
|                     <td class="label" i18n="liquidity-ad.channel-fee-rate|Liquidity ad channel fee rate">Channel fee rate</td> | ||||
|                     <td> | ||||
|                       <span class="d-inline-block"> | ||||
|                         {{ liquidityAd.channel_fee_max_rate !== null ? ((liquidityAd.channel_fee_max_rate * 1000) | amountShortener : 2 : undefined : true) : '-' }} <span class="symbol">ppm {{ liquidityAd.channel_fee_max_rate !== null ? '(' + (liquidityAd.channel_fee_max_rate / 10 | amountShortener : 2 : undefined : true) + '%)' : '' }}</span> | ||||
|                       </span> | ||||
|                     </td> | ||||
|                   </tr> | ||||
|                   <tr> | ||||
|                     <td class="label" i18n="liquidity-ad.channel-base-fee">Channel base fee</td> | ||||
|                     <td> | ||||
|                       <span *ngIf="liquidityAd.channel_fee_max_base !== null"> | ||||
|                         {{ liquidityAd.channel_fee_max_base | amountShortener : 0 }} | ||||
|                         <span class="symbol" i18n="shared.m-sats">mSats</span> | ||||
|                       </span> | ||||
|                       <span *ngIf="liquidityAd.channel_fee_max_base === null"> | ||||
|                         - | ||||
|                       </span> | ||||
|                     </td> | ||||
|                   </tr> | ||||
|                   <tr> | ||||
|                     <td class="label" i18n="liquidity-ad.compact-lease">Compact lease</td> | ||||
|                     <td class="compact-lease">{{ liquidityAd.compact_lease }}</td> | ||||
|                   </tr> | ||||
|                 </tbody> | ||||
|               </table> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </ng-template> | ||||
|       <ng-template [ngIf]="tlvRecords?.length"> | ||||
|         <div class="detail-section"> | ||||
|           <h5 class="mb-3" i18n="node.tlv.records">TLV extension records</h5> | ||||
|           <div class="row"> | ||||
|             <div class="col"> | ||||
|               <table class="table table-borderless table-striped"> | ||||
|                 <tbody> | ||||
|                   <tr *ngFor="let recordItem of tlvRecords"> | ||||
|                     <td class="tlv-type">{{ recordItem.type }}</td> | ||||
|                     <td class="tlv-payload">{{ recordItem.payload }}</td> | ||||
|                   </tr> | ||||
|                 </tbody> | ||||
|               </table> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </ng-template> | ||||
|     </div> | ||||
|   </div> | ||||
| 
 | ||||
|   <div *ngIf="hasDetails" class="text-right mt-3"> | ||||
|     <button type="button" class="btn btn-outline-info btn-sm btn-details" (click)="toggleShowDetails()" i18n="node.details|Node Details">Details</button> | ||||
|   </div> | ||||
| 
 | ||||
|   <div *ngIf="!error"> | ||||
|     <div class="row" *ngIf="node.as_number && node.active_channel_count"> | ||||
|       <div class="col-sm"> | ||||
|  | ||||
| @ -72,3 +72,32 @@ app-fiat { | ||||
|     height: 28px !important; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| .details { | ||||
| 
 | ||||
|   .detail-section { | ||||
|     margin-bottom: 1.5rem; | ||||
|     &:last-child { | ||||
|       margin-bottom: 0; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .tlv-type { | ||||
|     font-size: 12px; | ||||
|     color: #ffffff66; | ||||
|   } | ||||
| 
 | ||||
|   .tlv-payload { | ||||
|     font-size: 12px; | ||||
|     width: 100%; | ||||
|     word-break: break-all; | ||||
|     white-space: normal; | ||||
|     font-family: "Courier New", Courier, monospace; | ||||
|   } | ||||
| 
 | ||||
|   .compact-lease { | ||||
|     word-break: break-all; | ||||
|     white-space: normal; | ||||
|     font-family: "Courier New", Courier, monospace; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,10 +1,16 @@ | ||||
| import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; | ||||
| import { ActivatedRoute, ParamMap } from '@angular/router'; | ||||
| import { Observable } from 'rxjs'; | ||||
| import { catchError, map, switchMap } from 'rxjs/operators'; | ||||
| import { catchError, map, switchMap, tap } from 'rxjs/operators'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { LightningApiService } from '../lightning-api.service'; | ||||
| import { GeolocationData } from '../../shared/components/geolocation/geolocation.component'; | ||||
| import { ILiquidityAd, parseLiquidityAdHex } from './liquidity-ad'; | ||||
| 
 | ||||
| interface CustomRecord { | ||||
|   type: string; | ||||
|   payload: string; | ||||
| } | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-node', | ||||
| @ -24,6 +30,10 @@ export class NodeComponent implements OnInit { | ||||
|   channelListLoading = false; | ||||
|   clearnetSocketCount = 0; | ||||
|   torSocketCount = 0; | ||||
|   hasDetails = false; | ||||
|   showDetails = false; | ||||
|   liquidityAd: ILiquidityAd; | ||||
|   tlvRecords: CustomRecord[]; | ||||
| 
 | ||||
|   constructor( | ||||
|     private lightningApiService: LightningApiService, | ||||
| @ -36,6 +46,8 @@ export class NodeComponent implements OnInit { | ||||
|       .pipe( | ||||
|         switchMap((params: ParamMap) => { | ||||
|           this.publicKey = params.get('public_key'); | ||||
|           this.tlvRecords = []; | ||||
|           this.liquidityAd = null; | ||||
|           return this.lightningApiService.getNode$(params.get('public_key')); | ||||
|         }), | ||||
|         map((node) => { | ||||
| @ -79,6 +91,26 @@ export class NodeComponent implements OnInit { | ||||
| 
 | ||||
|           return node; | ||||
|         }), | ||||
|         tap((node) => { | ||||
|           this.hasDetails = Object.keys(node.custom_records).length > 0; | ||||
|           for (const [type, payload] of Object.entries(node.custom_records)) { | ||||
|             if (typeof payload !== 'string') { | ||||
|               break; | ||||
|             } | ||||
| 
 | ||||
|             let parsed = false; | ||||
|             if (type === '1') { | ||||
|               const ad = parseLiquidityAdHex(payload); | ||||
|               if (ad) { | ||||
|                 parsed = true; | ||||
|                 this.liquidityAd = ad; | ||||
|               } | ||||
|             } | ||||
|             if (!parsed) { | ||||
|               this.tlvRecords.push({ type, payload }); | ||||
|             } | ||||
|           } | ||||
|         }), | ||||
|         catchError(err => { | ||||
|           this.error = err; | ||||
|           return [{ | ||||
| @ -89,6 +121,10 @@ export class NodeComponent implements OnInit { | ||||
|       ); | ||||
|   } | ||||
| 
 | ||||
|   toggleShowDetails(): void { | ||||
|     this.showDetails = !this.showDetails; | ||||
|   } | ||||
| 
 | ||||
|   changeSocket(index: number) { | ||||
|     this.selectedSocketIndex = index; | ||||
|   } | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { HttpClient, HttpParams } from '@angular/common/http'; | ||||
| import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, | ||||
|   PoolStat, BlockExtended, TransactionStripped, RewardStats } from '../interfaces/node-api.interface'; | ||||
|   PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore } from '../interfaces/node-api.interface'; | ||||
| import { Observable } from 'rxjs'; | ||||
| import { StateService } from './state.service'; | ||||
| import { WebsocketResponse } from '../interfaces/websocket.interface'; | ||||
| @ -234,6 +234,19 @@ export class ApiService { | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   getBlockAuditScores$(from: number): Observable<AuditScore[]> { | ||||
|     return this.httpClient.get<AuditScore[]>( | ||||
|       this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/audit/scores` + | ||||
|       (from !== undefined ? `/${from}` : ``) | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   getBlockAuditScore$(hash: string) : Observable<any> { | ||||
|     return this.httpClient.get<any>( | ||||
|       this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/audit/score/` + hash | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   getRewardStats$(blockCount: number = 144): Observable<RewardStats> { | ||||
|     return this.httpClient.get<RewardStats>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`); | ||||
|   } | ||||
|  | ||||
| @ -112,6 +112,8 @@ export class StateService { | ||||
|   timeLtr: BehaviorSubject<boolean>; | ||||
|   hideFlow: BehaviorSubject<boolean>; | ||||
| 
 | ||||
|   txCache: { [txid: string]: Transaction } = {}; | ||||
| 
 | ||||
|   constructor( | ||||
|     @Inject(PLATFORM_ID) private platformId: any, | ||||
|     @Inject(LOCALE_ID) private locale: string, | ||||
| @ -265,4 +267,19 @@ export class StateService { | ||||
|   isLiquid() { | ||||
|     return this.network === 'liquid' || this.network === 'liquidtestnet'; | ||||
|   } | ||||
| 
 | ||||
|   setTxCache(transactions) { | ||||
|     this.txCache = {}; | ||||
|     transactions.forEach(tx => { | ||||
|       this.txCache[tx.txid] = tx; | ||||
|     }); | ||||
|   } | ||||
|   | ||||
|   getTxFromCache(txid) { | ||||
|     if (this.txCache && this.txCache[txid]) { | ||||
|       return this.txCache[txid]; | ||||
|     } else { | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,8 +1,10 @@ | ||||
| <!doctype html> | ||||
| <html lang="en"> | ||||
| 
 | ||||
| <head> | ||||
|   <meta charset="utf-8"> | ||||
|   <title>mempool - Bisq Markets</title> | ||||
|   <script src="/resources/config.js"></script> | ||||
|   <base href="/"> | ||||
| 
 | ||||
|   <meta name="description" content="The Mempool Open Source Project™ - Explore the full Bitcoin ecosystem."> | ||||
| @ -31,11 +33,13 @@ | ||||
|   <link rel="manifest" href="/resources/bisq/favicons/site.webmanifest"> | ||||
|   <link rel="mask-icon" href="/resources/bisq/favicons/safari-pinned-tab.svg" color="#5bbad5"> | ||||
|   <link rel="shortcut icon" href="/resources/bisq/favicons/favicon.ico"> | ||||
|   | ||||
| 
 | ||||
|   <link id="canonical" rel="canonical" href="https://bisq.markets"> | ||||
| 
 | ||||
| </head> | ||||
| 
 | ||||
| <body> | ||||
|   <app-root></app-root> | ||||
| </body> | ||||
| 
 | ||||
| </html> | ||||
|  | ||||
| @ -1,8 +1,10 @@ | ||||
| <!doctype html> | ||||
| <html lang="en"> | ||||
| 
 | ||||
| <head> | ||||
|   <meta charset="utf-8"> | ||||
|   <title>mempool - Liquid Network</title> | ||||
|   <script src="/resources/config.js"></script> | ||||
|   <base href="/"> | ||||
| 
 | ||||
|   <meta name="description" content="The Mempool Open Source Project™ - Explore the full Bitcoin ecosystem."> | ||||
| @ -17,7 +19,7 @@ | ||||
|   <meta property="twitter:description" content="Explore the full Bitcoin ecosystem with mempool.space™" /> | ||||
|   <meta property="twitter:image:src" content="https://liquid.network/resources/liquid/liquid-network-preview.png" /> | ||||
|   <meta property="twitter:domain" content="liquid.network"> | ||||
|    | ||||
| 
 | ||||
|   <link rel="apple-touch-icon" sizes="180x180" href="/resources/liquid/favicons/apple-touch-icon.png"> | ||||
|   <link rel="icon" type="image/png" sizes="48x48" href="/resources/liquid/favicons/favicon-48x48.png"> | ||||
|   <link rel="icon" type="image/png" sizes="32x32" href="/resources/liquid/favicons/favicon-32x32.png"> | ||||
| @ -33,7 +35,9 @@ | ||||
| 
 | ||||
|   <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
| </head> | ||||
| 
 | ||||
| <body> | ||||
|   <app-root></app-root> | ||||
| </body> | ||||
| 
 | ||||
| </html> | ||||
|  | ||||
| @ -1,8 +1,10 @@ | ||||
| <!doctype html> | ||||
| <html lang="en"> | ||||
| 
 | ||||
| <head> | ||||
|   <meta charset="utf-8"> | ||||
|   <title>mempool - Bitcoin Explorer</title> | ||||
|   <script src="/resources/config.js"></script> | ||||
|   <base href="/"> | ||||
| 
 | ||||
|   <meta name="description" content="The Mempool Open Source Project™ - Explore the full Bitcoin ecosystem." /> | ||||
| @ -17,7 +19,7 @@ | ||||
|   <meta property="twitter:description" content="Explore the full Bitcoin ecosystem with mempool.space™" /> | ||||
|   <meta property="twitter:image:src" content="https://mempool.space/resources/mempool-space-preview.png" /> | ||||
|   <meta property="twitter:domain" content="mempool.space"> | ||||
|    | ||||
| 
 | ||||
|   <link rel="apple-touch-icon" sizes="180x180" href="/resources/favicons/apple-touch-icon.png"> | ||||
|   <link rel="icon" type="image/png" sizes="32x32" href="/resources/favicons/favicon-32x32.png"> | ||||
|   <link rel="icon" type="image/png" sizes="16x16" href="/resources/favicons/favicon-16x16.png"> | ||||
| @ -32,7 +34,9 @@ | ||||
| 
 | ||||
|   <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
| </head> | ||||
| 
 | ||||
| <body> | ||||
|   <app-root></app-root> | ||||
| </body> | ||||
| 
 | ||||
| </html> | ||||
|  | ||||
| @ -21,6 +21,13 @@ | ||||
| 		try_files $uri @index-redirect; | ||||
| 		expires 1h; | ||||
| 	} | ||||
| 
 | ||||
| 	# only cache /resources/config.* for 5 minutes since it changes often | ||||
| 	location /resources/config. { | ||||
| 		try_files $uri =404; | ||||
| 		expires 5m; | ||||
| 	} | ||||
| 
 | ||||
| 	location @index-redirect { | ||||
| 		rewrite (.*) /$lang/index.html; | ||||
| 	} | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| { | ||||
|   "MEMPOOL": { | ||||
|     "ENABLED": false, | ||||
|     "NETWORK": "mainnet", | ||||
|     "BACKEND": "esplora", | ||||
|     "HTTP_PORT": 8993, | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| { | ||||
|   "MEMPOOL": { | ||||
|     "ENABLED": false, | ||||
|     "NETWORK": "signet", | ||||
|     "BACKEND": "esplora", | ||||
|     "HTTP_PORT": 8991, | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| { | ||||
|   "MEMPOOL": { | ||||
|     "ENABLED": false, | ||||
|     "NETWORK": "testnet", | ||||
|     "BACKEND": "esplora", | ||||
|     "HTTP_PORT": 8992, | ||||
|  | ||||
| @ -81,6 +81,13 @@ location /resources { | ||||
| 	try_files $uri /en-US/index.html; | ||||
| 	expires 1w; | ||||
| } | ||||
| 
 | ||||
| # only cache /resources/config.* for 5 minutes since it changes often | ||||
| location /resources/config. { | ||||
| 	try_files $uri =404; | ||||
| 	expires 5m; | ||||
| } | ||||
| 
 | ||||
| # cache /main.f40e91d908a068a2.js forever since they never change | ||||
| location ~* ^/.+\..+\.(js|css) { | ||||
| 	try_files /$lang/$uri /en-US/$uri =404; | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user