diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ff25b22f..54259241b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" strategy: matrix: - node: ["16.16.0", "18.14.1"] + node: ["16", "17", "18"] flavor: ["dev", "prod"] fail-fast: false runs-on: "ubuntu-latest" @@ -55,7 +55,7 @@ jobs: if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" strategy: matrix: - node: ["16.16.0", "18.14.1"] + node: ["16", "17", "18"] flavor: ["dev", "prod"] fail-fast: false runs-on: "ubuntu-latest" diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index c31fe1a09..8f31e152d 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -399,9 +399,13 @@ class BitcoinRoutes { private async getBlockAuditSummary(req: Request, res: Response) { try { - const transactions = await blocks.$getBlockAuditSummary(req.params.hash); - res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); - res.json(transactions); + const auditSummary = await blocks.$getBlockAuditSummary(req.params.hash); + if (auditSummary) { + res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); + res.json(auditSummary); + } else { + return res.status(404).send(`audit not available`); + } } catch (e) { res.status(500).send(e instanceof Error ? e.message : e); } diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index a91281050..94e1414a4 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -730,6 +730,11 @@ class Blocks { this.currentDifficulty = block.difficulty; } + // wait for pending async callbacks to finish + this.updateTimerProgress(timer, `waiting for async callbacks to complete for ${this.currentBlockHeight}`); + await Promise.all(callbackPromises); + this.updateTimerProgress(timer, `async callbacks completed for ${this.currentBlockHeight}`); + this.blocks.push(blockExtended); if (this.blocks.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) { this.blocks = this.blocks.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4); @@ -746,11 +751,6 @@ class Blocks { diskCache.$saveCacheToDisk(); } - // wait for pending async callbacks to finish - this.updateTimerProgress(timer, `waiting for async callbacks to complete for ${this.currentBlockHeight}`); - await Promise.all(callbackPromises); - this.updateTimerProgress(timer, `async callbacks completed for ${this.currentBlockHeight}`); - handledBlocks++; } @@ -1007,19 +1007,11 @@ class Blocks { } public async $getBlockAuditSummary(hash: string): Promise { - let summary; if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { - summary = await BlocksAuditsRepository.$getBlockAudit(hash); + return BlocksAuditsRepository.$getBlockAudit(hash); + } else { + return null; } - - // fallback to non-audited transaction summary - if (!summary?.transactions?.length) { - const strippedTransactions = await this.$getStrippedBlockTransactions(hash); - summary = { - transactions: strippedTransactions - }; - } - return summary; } public getLastDifficultyAdjustmentTime(): number { diff --git a/backend/src/api/statistics/statistics-api.ts b/backend/src/api/statistics/statistics-api.ts index 1e8b0b7bb..9df12d704 100644 --- a/backend/src/api/statistics/statistics-api.ts +++ b/backend/src/api/statistics/statistics-api.ts @@ -211,7 +211,7 @@ class StatisticsApi { CAST(avg(vsize_1800) as DOUBLE) as vsize_1800, CAST(avg(vsize_2000) as DOUBLE) as vsize_2000 \ FROM statistics \ - WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() \ + ${interval === 'all' ? '' : `WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`} \ GROUP BY UNIX_TIMESTAMP(added) DIV ${div} \ ORDER BY statistics.added DESC;`; } @@ -259,7 +259,7 @@ class StatisticsApi { vsize_1800, vsize_2000 \ FROM statistics \ - WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() \ + ${interval === 'all' ? '' : `WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`} \ GROUP BY UNIX_TIMESTAMP(added) DIV ${div} \ ORDER BY statistics.added DESC;`; } @@ -386,6 +386,17 @@ class StatisticsApi { } } + public async $listAll(): Promise { + try { + const query = this.getQueryForDays(43200, 'all'); // 12h interval + const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout }); + return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); + } catch (e) { + logger.err('$listAll() error' + (e instanceof Error ? e.message : e)); + return []; + } + } + private mapStatisticToOptimizedStatistic(statistic: Statistic[]): OptimizedStatistic[] { return statistic.map((s) => { return { diff --git a/backend/src/api/statistics/statistics.routes.ts b/backend/src/api/statistics/statistics.routes.ts index 2a5871dd6..31db5198c 100644 --- a/backend/src/api/statistics/statistics.routes.ts +++ b/backend/src/api/statistics/statistics.routes.ts @@ -15,10 +15,11 @@ class StatisticsRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2y', this.$getStatisticsByTime.bind(this, '2y')) .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3y', this.$getStatisticsByTime.bind(this, '3y')) .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/4y', this.$getStatisticsByTime.bind(this, '4y')) + .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/all', this.$getStatisticsByTime.bind(this, 'all')) ; } - private async $getStatisticsByTime(time: '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y' | '4y', req: Request, res: Response) { + private async $getStatisticsByTime(time: '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y' | '4y' | 'all', req: Request, res: Response) { res.header('Pragma', 'public'); res.header('Cache-control', 'public'); res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); @@ -26,10 +27,6 @@ class StatisticsRoutes { try { let result; switch (time as string) { - case '2h': - result = await statisticsApi.$list2H(); - res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); - break; case '24h': result = await statisticsApi.$list24H(); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); @@ -58,8 +55,13 @@ class StatisticsRoutes { case '4y': result = await statisticsApi.$list4Y(); break; + case 'all': + result = await statisticsApi.$listAll(); + break; default: result = await statisticsApi.$list2H(); + res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); + break; } res.json(result); } catch (e) { diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 8aaab5ab5..1e2e381c2 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -22,6 +22,14 @@ import { deepClone } from '../utils/clone'; import priceUpdater from '../tasks/price-updater'; import { ApiPrice } from '../repositories/PricesRepository'; +// valid 'want' subscriptions +const wantable = [ + 'blocks', + 'mempool-blocks', + 'live-2h-chart', + 'stats', +]; + class WebsocketHandler { private wss: WebSocket.Server | undefined; private extraInitProperties = {}; @@ -30,7 +38,7 @@ class WebsocketHandler { private numConnected = 0; private numDisconnected = 0; - private initData: { [key: string]: string } = {}; + private socketData: { [key: string]: string } = {}; private serializedInitData: string = '{}'; constructor() { } @@ -39,28 +47,28 @@ class WebsocketHandler { this.wss = wss; } - setExtraInitProperties(property: string, value: any) { + setExtraInitData(property: string, value: any) { this.extraInitProperties[property] = value; - this.setInitDataFields(this.extraInitProperties); + this.updateSocketDataFields(this.extraInitProperties); } - private setInitDataFields(data: { [property: string]: any }): void { + private updateSocketDataFields(data: { [property: string]: any }): void { for (const property of Object.keys(data)) { if (data[property] != null) { - this.initData[property] = JSON.stringify(data[property]); + this.socketData[property] = JSON.stringify(data[property]); } else { - delete this.initData[property]; + delete this.socketData[property]; } } this.serializedInitData = '{' - + Object.keys(this.initData).map(key => `"${key}": ${this.initData[key]}`).join(', ') - + '}'; + + Object.keys(this.socketData).map(key => `"${key}": ${this.socketData[key]}`).join(', ') + + '}'; } - private updateInitData(): void { + private updateSocketData(): void { const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT); const da = difficultyAdjustment.getDifficultyAdjustment(); - this.setInitDataFields({ + this.updateSocketDataFields({ 'mempoolInfo': memPool.getMempoolInfo(), 'vBytesPerSecond': memPool.getVBytesPerSecond(), 'blocks': _blocks, @@ -94,11 +102,33 @@ class WebsocketHandler { const parsedMessage: WebsocketResponse = JSON.parse(message); const response = {}; - if (parsedMessage.action === 'want') { - client['want-blocks'] = parsedMessage.data.indexOf('blocks') > -1; - client['want-mempool-blocks'] = parsedMessage.data.indexOf('mempool-blocks') > -1; - client['want-live-2h-chart'] = parsedMessage.data.indexOf('live-2h-chart') > -1; - client['want-stats'] = parsedMessage.data.indexOf('stats') > -1; + const wantNow = {}; + if (parsedMessage && parsedMessage.action === 'want' && Array.isArray(parsedMessage.data)) { + for (const sub of wantable) { + const key = `want-${sub}`; + const wants = parsedMessage.data.includes(sub); + if (wants && client['wants'] && !client[key]) { + wantNow[key] = true; + } + client[key] = wants; + } + client['wants'] = true; + } + + // send initial data when a client first starts a subscription + if (wantNow['want-blocks'] || (parsedMessage && parsedMessage['refresh-blocks'])) { + response['blocks'] = this.socketData['blocks']; + } + + if (wantNow['want-mempool-blocks']) { + response['mempool-blocks'] = this.socketData['mempool-blocks']; + } + + if (wantNow['want-stats']) { + response['mempoolInfo'] = this.socketData['mempoolInfo']; + response['vBytesPerSecond'] = this.socketData['vBytesPerSecond']; + response['fees'] = this.socketData['fees']; + response['da'] = this.socketData['da']; } if (parsedMessage && parsedMessage['track-tx']) { @@ -109,21 +139,21 @@ class WebsocketHandler { if (parsedMessage['watch-mempool']) { const rbfCacheTxid = rbfCache.getReplacedBy(trackTxid); if (rbfCacheTxid) { - response['txReplaced'] = { + response['txReplaced'] = JSON.stringify({ txid: rbfCacheTxid, - }; + }); client['track-tx'] = null; } else { // It might have appeared before we had the time to start watching for it const tx = memPool.getMempool()[trackTxid]; if (tx) { if (config.MEMPOOL.BACKEND === 'esplora') { - response['tx'] = tx; + response['tx'] = JSON.stringify(tx); } else { // tx.prevout is missing from transactions when in bitcoind mode try { const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true); - response['tx'] = fullTx; + response['tx'] = JSON.stringify(fullTx); } catch (e) { logger.debug('Error finding transaction: ' + (e instanceof Error ? e.message : e)); } @@ -131,7 +161,7 @@ class WebsocketHandler { } else { try { const fullTx = await transactionUtils.$getMempoolTransactionExtended(client['track-tx'], true); - response['tx'] = fullTx; + response['tx'] = JSON.stringify(fullTx); } catch (e) { logger.debug('Error finding transaction. ' + (e instanceof Error ? e.message : e)); client['track-mempool-tx'] = parsedMessage['track-tx']; @@ -141,10 +171,10 @@ class WebsocketHandler { } const tx = memPool.getMempool()[trackTxid]; if (tx && tx.position) { - response['txPosition'] = { + response['txPosition'] = JSON.stringify({ txid: trackTxid, position: tx.position, - }; + }); } } else { client['track-tx'] = null; @@ -177,10 +207,10 @@ class WebsocketHandler { const index = parsedMessage['track-mempool-block']; client['track-mempool-block'] = index; const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions(); - response['projected-block-transactions'] = { + response['projected-block-transactions'] = JSON.stringify({ index: index, blockTransactions: mBlocksWithTransactions[index]?.transactions || [], - }; + }); } else { client['track-mempool-block'] = null; } @@ -189,23 +219,24 @@ class WebsocketHandler { if (parsedMessage && parsedMessage['track-rbf'] !== undefined) { if (['all', 'fullRbf'].includes(parsedMessage['track-rbf'])) { client['track-rbf'] = parsedMessage['track-rbf']; + response['rbfLatest'] = JSON.stringify(rbfCache.getRbfTrees(parsedMessage['track-rbf'] === 'fullRbf')); } else { client['track-rbf'] = false; } } if (parsedMessage.action === 'init') { - if (!this.initData['blocks']?.length || !this.initData['da']) { - this.updateInitData(); + if (!this.socketData['blocks']?.length || !this.socketData['da']) { + this.updateSocketData(); } - if (!this.initData['blocks']?.length) { + if (!this.socketData['blocks']?.length) { return; } client.send(this.serializedInitData); } if (parsedMessage.action === 'ping') { - response['pong'] = true; + response['pong'] = JSON.stringify(true); } if (parsedMessage['track-donation'] && parsedMessage['track-donation'].length === 22) { @@ -221,7 +252,8 @@ class WebsocketHandler { } if (Object.keys(response).length) { - client.send(JSON.stringify(response)); + const serializedResponse = this.serializeResponse(response); + client.send(serializedResponse); } } catch (e) { logger.debug('Error parsing websocket message: ' + (e instanceof Error ? e.message : e)); @@ -250,7 +282,7 @@ class WebsocketHandler { throw new Error('WebSocket.Server is not set'); } - this.setInitDataFields({ 'loadingIndicators': indicators }); + this.updateSocketDataFields({ 'loadingIndicators': indicators }); const response = JSON.stringify({ loadingIndicators: indicators }); this.wss.clients.forEach((client) => { @@ -266,7 +298,7 @@ class WebsocketHandler { throw new Error('WebSocket.Server is not set'); } - this.setInitDataFields({ 'conversions': conversionRates }); + this.updateSocketDataFields({ 'conversions': conversionRates }); const response = JSON.stringify({ conversions: conversionRates }); this.wss.clients.forEach((client) => { @@ -336,11 +368,21 @@ class WebsocketHandler { memPool.addToSpendMap(newTransactions); const recommendedFees = feeApi.getRecommendedFee(); + const latestTransactions = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx)); + // update init data - this.updateInitData(); + this.updateSocketDataFields({ + 'mempoolInfo': mempoolInfo, + 'vBytesPerSecond': vBytesPerSecond, + 'mempool-blocks': mBlocks, + 'transactions': latestTransactions, + 'loadingIndicators': loadingIndicators.getLoadingIndicators(), + 'da': da?.previousTime ? da : undefined, + 'fees': recommendedFees, + }); // cache serialized objects to avoid stringify-ing the same thing for every client - const responseCache = { ...this.initData }; + const responseCache = { ...this.socketData }; function getCachedResponse(key: string, data): string { if (!responseCache[key]) { responseCache[key] = JSON.stringify(data); @@ -371,8 +413,6 @@ class WebsocketHandler { } } - const latestTransactions = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx)); - this.wss.clients.forEach(async (client) => { if (client.readyState !== WebSocket.OPEN) { return; @@ -490,7 +530,7 @@ class WebsocketHandler { if (rbfReplacedBy) { response['rbfTransaction'] = JSON.stringify({ txid: rbfReplacedBy, - }) + }); } const rbfChange = rbfChanges.map[client['track-tx']]; @@ -524,9 +564,7 @@ class WebsocketHandler { } if (Object.keys(response).length) { - const serializedResponse = '{' - + Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ') - + '}'; + const serializedResponse = this.serializeResponse(response); client.send(serializedResponse); } }); @@ -633,11 +671,19 @@ class WebsocketHandler { const da = difficultyAdjustment.getDifficultyAdjustment(); const fees = feeApi.getRecommendedFee(); + const mempoolInfo = memPool.getMempoolInfo(); // update init data - this.updateInitData(); + this.updateSocketDataFields({ + 'mempoolInfo': mempoolInfo, + 'blocks': [...blocks.getBlocks(), block].slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT), + 'mempool-blocks': mBlocks, + 'loadingIndicators': loadingIndicators.getLoadingIndicators(), + 'da': da?.previousTime ? da : undefined, + 'fees': fees, + }); - const responseCache = { ...this.initData }; + const responseCache = { ...this.socketData }; function getCachedResponse(key, data): string { if (!responseCache[key]) { responseCache[key] = JSON.stringify(data); @@ -645,22 +691,26 @@ class WebsocketHandler { return responseCache[key]; } - const mempoolInfo = memPool.getMempoolInfo(); - this.wss.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } - if (!client['want-blocks']) { - return; + const response = {}; + + if (client['want-blocks']) { + response['block'] = getCachedResponse('block', block); } - const response = {}; - response['block'] = getCachedResponse('block', block); - response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo); - response['da'] = getCachedResponse('da', da?.previousTime ? da : undefined); - response['fees'] = getCachedResponse('fees', fees); + if (client['want-stats']) { + response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo); + response['vBytesPerSecond'] = getCachedResponse('vBytesPerSecond', memPool.getVBytesPerSecond()); + response['fees'] = getCachedResponse('fees', fees); + + if (da?.previousTime) { + response['da'] = getCachedResponse('da', da); + } + } if (mBlocks && client['want-mempool-blocks']) { response['mempool-blocks'] = getCachedResponse('mempool-blocks', mBlocks); @@ -755,11 +805,19 @@ class WebsocketHandler { } } - const serializedResponse = '{' + if (Object.keys(response).length) { + const serializedResponse = this.serializeResponse(response); + client.send(serializedResponse); + } + }); + } + + // takes a dictionary of JSON serialized values + // and zips it together into a valid JSON object + private serializeResponse(response): string { + return '{' + Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ') + '}'; - client.send(serializedResponse); - }); } private printLogs(): void { diff --git a/backend/src/index.ts b/backend/src/index.ts index 9f543d644..81863a208 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -150,7 +150,7 @@ class Server { if (config.BISQ.ENABLED) { bisq.startBisqService(); - bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitProperties('bsq-price', price)); + bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitData('bsq-price', price)); blocks.setNewBlockCallback(bisq.handleNewBitcoinBlock.bind(bisq)); bisqMarkets.startBisqService(); } diff --git a/backend/src/repositories/BlocksAuditsRepository.ts b/backend/src/repositories/BlocksAuditsRepository.ts index 1fa2b0209..8ad035f32 100644 --- a/backend/src/repositories/BlocksAuditsRepository.ts +++ b/backend/src/repositories/BlocksAuditsRepository.ts @@ -64,7 +64,6 @@ class BlocksAuditRepositories { const [rows]: any[] = await DB.query( `SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size, blocks.weight, blocks.tx_count, - transactions, template, missing_txs as missingTxs, added_txs as addedTxs, @@ -76,7 +75,6 @@ class BlocksAuditRepositories { FROM blocks_audits JOIN blocks ON blocks.hash = blocks_audits.hash JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash - JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash WHERE blocks_audits.hash = "${hash}" `); @@ -85,12 +83,9 @@ class BlocksAuditRepositories { rows[0].addedTxs = JSON.parse(rows[0].addedTxs); rows[0].freshTxs = JSON.parse(rows[0].freshTxs); rows[0].sigopTxs = JSON.parse(rows[0].sigopTxs); - rows[0].transactions = JSON.parse(rows[0].transactions); rows[0].template = JSON.parse(rows[0].template); - if (rows[0].transactions.length) { - return rows[0]; - } + return rows[0]; } return null; } catch (e: any) { diff --git a/frontend/src/app/components/about/about.component.html b/frontend/src/app/components/about/about.component.html index 2936606ad..381353948 100644 --- a/frontend/src/app/components/about/about.component.html +++ b/frontend/src/app/components/about/about.component.html @@ -173,6 +173,21 @@ Exodus + + + + + + + + + + Luminex + @@ -205,7 +220,7 @@ myNode - + RoninDojo diff --git a/frontend/src/app/components/block/block.component.html b/frontend/src/app/components/block/block.component.html index 1a0a81026..00cb2fcb1 100644 --- a/frontend/src/app/components/block/block.component.html +++ b/frontend/src/app/components/block/block.component.html @@ -63,7 +63,7 @@ *ngIf="blockAudit?.matchRate != null; else nullHealth" >{{ blockAudit?.matchRate }}% - + Unknown diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index 17e6e9b7f..927222dbc 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -2,9 +2,9 @@ import { Component, OnInit, OnDestroy, ViewChildren, QueryList } from '@angular/ import { Location } from '@angular/common'; import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { ElectrsApiService } from '../../services/electrs-api.service'; -import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise, filter } from 'rxjs/operators'; +import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith } from 'rxjs/operators'; import { Transaction, Vout } from '../../interfaces/electrs.interface'; -import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest } from 'rxjs'; +import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest, forkJoin } from 'rxjs'; import { StateService } from '../../services/state.service'; import { SeoService } from '../../services/seo.service'; import { WebsocketService } from '../../services/websocket.service'; @@ -44,7 +44,6 @@ export class BlockComponent implements OnInit, OnDestroy { strippedTransactions: TransactionStripped[]; overviewTransitionDirection: string; isLoadingOverview = true; - isLoadingAudit = true; error: any; blockSubsidy: number; fees: number; @@ -281,143 +280,111 @@ export class BlockComponent implements OnInit, OnDestroy { this.isLoadingOverview = false; }); - if (!this.auditSupported) { - this.overviewSubscription = block$.pipe( - startWith(null), - pairwise(), - switchMap(([prevBlock, block]) => this.apiService.getStrippedBlockTransactions$(block.id) - .pipe( - catchError((err) => { - this.overviewError = err; - return of([]); - }), - switchMap((transactions) => { - if (prevBlock) { - return of({ transactions, direction: (prevBlock.height < block.height) ? 'right' : 'left' }); - } else { - return of({ transactions, direction: 'down' }); - } - }) - ) - ), - ) - .subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => { - this.strippedTransactions = transactions; - this.isLoadingOverview = false; - this.setupBlockGraphs(); - }, - (error) => { - this.error = error; - this.isLoadingOverview = false; - }); - } - - if (this.auditSupported) { - this.auditSubscription = block$.pipe( - startWith(null), - pairwise(), - switchMap(([prevBlock, block]) => { - this.isLoadingAudit = true; - this.blockAudit = null; - return this.apiService.getBlockAudit$(block.id) + this.overviewSubscription = block$.pipe( + switchMap((block) => { + return forkJoin([ + this.apiService.getStrippedBlockTransactions$(block.id) .pipe( catchError((err) => { this.overviewError = err; - this.isLoadingAudit = false; - return of([]); + return of(null); }) - ); - } - ), - filter((response) => response != null), - map((response) => { - const blockAudit = response.body; - const inTemplate = {}; - const inBlock = {}; - const isAdded = {}; - const isCensored = {}; - const isMissing = {}; - const isSelected = {}; - const isFresh = {}; - const isSigop = {}; - this.numMissing = 0; - this.numUnexpected = 0; + ), + !this.isAuditAvailableFromBlockHeight(block.height) ? of(null) : this.apiService.getBlockAudit$(block.id) + .pipe( + catchError((err) => { + this.overviewError = err; + return of(null); + }) + ) + ]); + }) + ) + .subscribe(([transactions, blockAudit]) => { + if (transactions) { + this.strippedTransactions = transactions; + } else { + this.strippedTransactions = []; + } - if (blockAudit?.template) { - for (const tx of blockAudit.template) { - inTemplate[tx.txid] = true; - } - for (const tx of blockAudit.transactions) { - inBlock[tx.txid] = true; - } - for (const txid of blockAudit.addedTxs) { - isAdded[txid] = true; - } - for (const txid of blockAudit.missingTxs) { - isCensored[txid] = true; - } - for (const txid of blockAudit.freshTxs || []) { - isFresh[txid] = true; - } - for (const txid of blockAudit.sigopTxs || []) { - isSigop[txid] = true; - } - // set transaction statuses - for (const tx of blockAudit.template) { - tx.context = 'projected'; - if (isCensored[tx.txid]) { - tx.status = 'censored'; - } else if (inBlock[tx.txid]) { - tx.status = 'found'; - } else { - tx.status = isFresh[tx.txid] ? 'fresh' : (isSigop[tx.txid] ? 'sigop' : 'missing'); - isMissing[tx.txid] = true; - this.numMissing++; - } - } - for (const [index, tx] of blockAudit.transactions.entries()) { - tx.context = 'actual'; - if (index === 0) { - tx.status = null; - } else if (isAdded[tx.txid]) { - tx.status = 'added'; - } else if (inTemplate[tx.txid]) { - tx.status = 'found'; - } else { - tx.status = 'selected'; - isSelected[tx.txid] = true; - this.numUnexpected++; - } - } - for (const tx of blockAudit.transactions) { - inBlock[tx.txid] = true; - } + this.blockAudit = null; + if (transactions && blockAudit) { + const inTemplate = {}; + const inBlock = {}; + const isAdded = {}; + const isCensored = {}; + const isMissing = {}; + const isSelected = {}; + const isFresh = {}; + const isSigop = {}; + this.numMissing = 0; + this.numUnexpected = 0; - blockAudit.feeDelta = blockAudit.expectedFees > 0 ? (blockAudit.expectedFees - this.block.extras.totalFees) / blockAudit.expectedFees : 0; - blockAudit.weightDelta = blockAudit.expectedWeight > 0 ? (blockAudit.expectedWeight - this.block.weight) / blockAudit.expectedWeight : 0; - blockAudit.txDelta = blockAudit.template.length > 0 ? (blockAudit.template.length - this.block.tx_count) / blockAudit.template.length : 0; - - this.setAuditAvailable(true); - } else { - this.setAuditAvailable(false); + if (blockAudit?.template) { + for (const tx of blockAudit.template) { + inTemplate[tx.txid] = true; } - return blockAudit; - }), - catchError((err) => { - console.log(err); - this.error = err; - this.isLoadingOverview = false; - this.isLoadingAudit = false; + for (const tx of transactions) { + inBlock[tx.txid] = true; + } + for (const txid of blockAudit.addedTxs) { + isAdded[txid] = true; + } + for (const txid of blockAudit.missingTxs) { + isCensored[txid] = true; + } + for (const txid of blockAudit.freshTxs || []) { + isFresh[txid] = true; + } + for (const txid of blockAudit.sigopTxs || []) { + isSigop[txid] = true; + } + // set transaction statuses + for (const tx of blockAudit.template) { + tx.context = 'projected'; + if (isCensored[tx.txid]) { + tx.status = 'censored'; + } else if (inBlock[tx.txid]) { + tx.status = 'found'; + } else { + tx.status = isFresh[tx.txid] ? 'fresh' : (isSigop[tx.txid] ? 'sigop' : 'missing'); + isMissing[tx.txid] = true; + this.numMissing++; + } + } + for (const [index, tx] of transactions.entries()) { + tx.context = 'actual'; + if (index === 0) { + tx.status = null; + } else if (isAdded[tx.txid]) { + tx.status = 'added'; + } else if (inTemplate[tx.txid]) { + tx.status = 'found'; + } else { + tx.status = 'selected'; + isSelected[tx.txid] = true; + this.numUnexpected++; + } + } + for (const tx of transactions) { + inBlock[tx.txid] = true; + } + + blockAudit.feeDelta = blockAudit.expectedFees > 0 ? (blockAudit.expectedFees - this.block.extras.totalFees) / blockAudit.expectedFees : 0; + blockAudit.weightDelta = blockAudit.expectedWeight > 0 ? (blockAudit.expectedWeight - this.block.weight) / blockAudit.expectedWeight : 0; + blockAudit.txDelta = blockAudit.template.length > 0 ? (blockAudit.template.length - this.block.tx_count) / blockAudit.template.length : 0; + this.blockAudit = blockAudit; + this.setAuditAvailable(true); + } else { this.setAuditAvailable(false); - return of(null); - }), - ).subscribe((blockAudit) => { - this.blockAudit = blockAudit; - this.setupBlockGraphs(); - this.isLoadingOverview = false; - this.isLoadingAudit = false; - }); - } + } + } else { + this.setAuditAvailable(false); + } + + this.isLoadingOverview = false; + this.setupBlockGraphs(); + }); this.networkChangedSubscription = this.stateService.networkChanged$ .subscribe((network) => this.network = network); @@ -652,25 +619,32 @@ export class BlockComponent implements OnInit, OnDestroy { } updateAuditAvailableFromBlockHeight(blockHeight: number): void { - if (!this.auditSupported) { + if (!this.isAuditAvailableFromBlockHeight(blockHeight)) { this.setAuditAvailable(false); } + } + + isAuditAvailableFromBlockHeight(blockHeight: number): boolean { + if (!this.auditSupported) { + return false; + } switch (this.stateService.network) { case 'testnet': if (blockHeight < this.stateService.env.TESTNET_BLOCK_AUDIT_START_HEIGHT) { - this.setAuditAvailable(false); + return false; } break; case 'signet': if (blockHeight < this.stateService.env.SIGNET_BLOCK_AUDIT_START_HEIGHT) { - this.setAuditAvailable(false); + return false; } break; default: if (blockHeight < this.stateService.env.MAINNET_BLOCK_AUDIT_START_HEIGHT) { - this.setAuditAvailable(false); + return false; } } + return true; } getMinBlockFee(block: BlockExtended): number { diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts index 561f01585..a72d24899 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -143,6 +143,8 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { this.mempoolBlocksFull = JSON.parse(stringifiedBlocks); this.mempoolBlocks = this.reduceMempoolBlocksToFitScreen(JSON.parse(stringifiedBlocks)); + this.now = Date.now(); + this.updateMempoolBlockStyles(); this.calculateTransactionPosition(); return this.mempoolBlocks; @@ -152,7 +154,8 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { this.difficultyAdjustments$ = this.stateService.difficultyAdjustment$ .pipe( map((da) => { - this.now = new Date().getTime(); + this.now = Date.now(); + this.cd.markForCheck(); return da; }) ); diff --git a/frontend/src/app/components/statistics/statistics.component.html b/frontend/src/app/components/statistics/statistics.component.html index 1b291e978..29089e43d 100644 --- a/frontend/src/app/components/statistics/statistics.component.html +++ b/frontend/src/app/components/statistics/statistics.component.html @@ -20,39 +20,46 @@ -
- - - - - - - - - - +
+
+ + + + + +
+
+ + + + + + +
diff --git a/frontend/src/app/components/statistics/statistics.component.scss b/frontend/src/app/components/statistics/statistics.component.scss index f7230ce4b..d883e48c3 100644 --- a/frontend/src/app/components/statistics/statistics.component.scss +++ b/frontend/src/app/components/statistics/statistics.component.scss @@ -53,17 +53,17 @@ } } .formRadioGroup.mining { - @media (min-width: 991px) { + @media (min-width: 1035px) { position: relative; top: -100px; } - @media (min-width: 830px) and (max-width: 991px) { + @media (min-width: 830px) and (max-width: 1035px) { position: relative; top: 0px; } } .formRadioGroup.no-menu { - @media (min-width: 991px) { + @media (min-width: 1035px) { position: relative; top: -33px; } @@ -183,3 +183,43 @@ } } } + +.btn-toggle-rows { + display: flex; + flex-direction: row; + align-items: stretch; + justify-content: stretch; + + .btn-group { + flex-grow: 1; + flex-shrink: 1; + } + + @media (min-width: 500px) { + .btn-group:first-child > .btn:last-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + .btn-group:last-child > .btn:first-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } + + @media (max-width: 499px) { + flex-direction: column; + + .btn-group:first-child > .btn:first-child { + border-bottom-left-radius: 0; + } + .btn-group:first-child > .btn:last-child { + border-bottom-right-radius: 0; + } + .btn-group:last-child > .btn:first-child { + border-top-left-radius: 0; + } + .btn-group:last-child > .btn:last-child { + border-top-right-radius: 0; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/statistics/statistics.component.ts b/frontend/src/app/components/statistics/statistics.component.ts index 35137bff1..eebee5bce 100644 --- a/frontend/src/app/components/statistics/statistics.component.ts +++ b/frontend/src/app/components/statistics/statistics.component.ts @@ -72,8 +72,10 @@ export class StatisticsComponent implements OnInit { this.route .fragment .subscribe((fragment) => { - if (['2h', '24h', '1w', '1m', '3m', '6m', '1y', '2y', '3y', '4y'].indexOf(fragment) > -1) { + if (['2h', '24h', '1w', '1m', '3m', '6m', '1y', '2y', '3y', '4y', 'all'].indexOf(fragment) > -1) { this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); + } else { + this.radioGroupForm.controls.dateSpan.setValue('2h', { emitEvent: false }); } }); @@ -114,7 +116,12 @@ export class StatisticsComponent implements OnInit { if (this.radioGroupForm.controls.dateSpan.value === '3y') { return this.apiService.list3YStatistics$(); } - return this.apiService.list4YStatistics$(); + if (this.radioGroupForm.controls.dateSpan.value === '4y') { + return this.apiService.list4YStatistics$(); + } + if (this.radioGroupForm.controls.dateSpan.value === 'all') { + return this.apiService.listAllTimeStatistics$(); + } }) ) .subscribe((mempoolStats: any) => { diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index a5a44ee90..216114cd0 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -105,7 +105,7 @@ - + diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index dd32b05e8..e7fbaa913 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -19,7 +19,7 @@ import { WebsocketService } from '../../services/websocket.service'; import { AudioService } from '../../services/audio.service'; import { ApiService } from '../../services/api.service'; import { SeoService } from '../../services/seo.service'; -import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition } from '../../interfaces/node-api.interface'; +import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition, DifficultyAdjustment } from '../../interfaces/node-api.interface'; import { LiquidUnblinding } from './liquid-ublinding'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { Price, PriceService } from '../../services/price.service'; @@ -65,7 +65,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { fetchCachedTx$ = new Subject(); isCached: boolean = false; now = Date.now(); - timeAvg$: Observable; + da$: Observable; liquidUnblinding = new LiquidUnblinding(); inputIndex: number; outputIndex: number; @@ -117,11 +117,11 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.setFlowEnabled(); }); - this.timeAvg$ = timer(0, 1000) - .pipe( - switchMap(() => this.stateService.difficultyAdjustment$), - map((da) => da.timeAvg) - ); + this.da$ = this.stateService.difficultyAdjustment$.pipe( + tap(() => { + this.now = Date.now(); + }) + ); this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => { this.fragmentParams = new URLSearchParams(fragment || ''); @@ -236,6 +236,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { }); this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => { + this.now = Date.now(); if (txPosition && txPosition.txid === this.txId && txPosition.position) { this.mempoolPosition = txPosition.position; if (this.tx && !this.tx.status.confirmed) { @@ -434,12 +435,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { }); this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$.subscribe((mempoolBlocks) => { + this.now = Date.now(); + if (!this.tx || this.mempoolPosition) { return; } - this.now = Date.now(); - const txFeePerVSize = this.tx.effectiveFeePerVsize || this.tx.fee / (this.tx.weight / 4); diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 2f9b95ab1..648eb38ea 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -153,6 +153,8 @@ export interface BlockExtended extends Block { export interface BlockAudit extends BlockExtended { missingTxs: string[], addedTxs: string[], + freshTxs: string[], + sigopTxs: string[], matchRate: number, expectedFees: number, expectedWeight: number, @@ -169,6 +171,7 @@ export interface TransactionStripped { vsize: number; value: number; status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'added' | 'censored' | 'selected'; + context?: 'projected' | 'actual'; } interface RbfTransaction extends TransactionStripped { diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 83a0c636e..41643fb73 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -31,6 +31,7 @@ export interface WebsocketResponse { 'track-rbf'?: string; 'watch-mempool'?: boolean; 'track-bisq-market'?: string; + 'refresh-blocks'?: boolean; } export interface ReplacedTransaction extends Transaction { diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 8521ddc83..e2d3be9be 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, - PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree } from '../interfaces/node-api.interface'; + PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree, BlockAudit } from '../interfaces/node-api.interface'; import { Observable } from 'rxjs'; import { StateService } from './state.service'; import { WebsocketResponse } from '../interfaces/websocket.interface'; @@ -72,6 +72,10 @@ export class ApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/statistics/4y'); } + listAllTimeStatistics$(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/statistics/all'); + } + getTransactionTimes$(txIds: string[]): Observable { let params = new HttpParams(); txIds.forEach((txId: string) => { @@ -245,9 +249,9 @@ export class ApiService { ); } - getBlockAudit$(hash: string) : Observable { - return this.httpClient.get( - this.apiBaseUrl + this.apiBasePath + `/api/v1/block/${hash}/audit-summary`, { observe: 'response' } + getBlockAudit$(hash: string) : Observable { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + `/api/v1/block/${hash}/audit-summary` ); } diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index af7a465f8..d22717b2a 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -235,6 +235,8 @@ export class WebsocketService { } handleResponse(response: WebsocketResponse) { + let reinitBlocks = false; + if (response.blocks && response.blocks.length) { const blocks = response.blocks; let maxHeight = 0; @@ -256,9 +258,11 @@ export class WebsocketService { } if (response.block) { - if (response.block.height > this.stateService.latestBlockHeight) { + if (response.block.height === this.stateService.latestBlockHeight + 1) { this.stateService.updateChainTip(response.block.height); this.stateService.blocks$.next([response.block, response.txConfirmed || '']); + } else if (response.block.height > this.stateService.latestBlockHeight + 1) { + reinitBlocks = true; } if (response.txConfirmed) { @@ -369,5 +373,9 @@ export class WebsocketService { if (response['git-commit']) { this.stateService.backendInfo$.next(response['git-commit']); } + + if (reinitBlocks) { + this.websocketSubject.next({'refresh-blocks': true}); + } } } diff --git a/production/nginx-cache-warmer b/production/nginx-cache-warmer index 6b58e5b78..6a82871a5 100755 --- a/production/nginx-cache-warmer +++ b/production/nginx-cache-warmer @@ -21,6 +21,7 @@ do for url in / \ '/api/v1/statistics/2y' \ '/api/v1/statistics/3y' \ '/api/v1/statistics/4y' \ + '/api/v1/statistics/all' \ '/api/v1/mining/pools/24h' \ '/api/v1/mining/pools/3d' \ '/api/v1/mining/pools/1w' \