diff --git a/backend/src/api/audit.ts b/backend/src/api/audit.ts index 1cbfe7a84..6aafc9ded 100644 --- a/backend/src/api/audit.ts +++ b/backend/src/api/audit.ts @@ -10,9 +10,9 @@ const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first class Audit { auditBlock(transactions: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended }) - : { censored: string[], added: string[], score: number } { + : { censored: string[], added: string[], fresh: string[], score: number } { if (!projectedBlocks?.[0]?.transactionIds || !mempool) { - return { censored: [], added: [], score: 0 }; + return { censored: [], added: [], fresh: [], score: 0 }; } const matches: string[] = []; // present in both mined block and template @@ -83,7 +83,17 @@ class Audit { } else { if (!isDisplaced[tx.txid]) { added.push(tx.txid); + } else { } + let blockIndex = -1; + let index = -1; + projectedBlocks.forEach((block, bi) => { + const i = block.transactionIds.indexOf(tx.txid); + if (i >= 0) { + blockIndex = bi; + index = i; + } + }); overflowWeight += tx.weight; } totalWeight += tx.weight; @@ -119,48 +129,10 @@ class Audit { return { censored: Object.keys(isCensored), added, + fresh, score }; } - - public async $getBlockAuditScores(fromHeight?: number, limit: number = 15): Promise { - 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(); \ No newline at end of file diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 3740cccd4..cdcc589fd 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -89,6 +89,7 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', this.getBlocks.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', this.getBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions) + .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/audit-summary', this.getBlockAuditSummary) .post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion) ; @@ -324,6 +325,16 @@ class BitcoinRoutes { } } + private async getStrippedBlockTransactions(req: Request, res: Response) { + try { + const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); + res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); + res.json(transactions); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + private async getBlock(req: Request, res: Response) { try { const block = await blocks.$getBlock(req.params.hash); @@ -356,9 +367,9 @@ class BitcoinRoutes { } } - private async getStrippedBlockTransactions(req: Request, res: Response) { + private async getBlockAuditSummary(req: Request, res: Response) { try { - const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); + const transactions = await blocks.$getBlockAuditSummary(req.params.hash); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.json(transactions); } catch (e) { diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 562f49de1..111d0fa1e 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -590,7 +590,7 @@ class Blocks { if (skipMemoryCache === false) { // Check the memory cache const cachedSummary = this.getBlockSummaries().find((b) => b.id === hash); - if (cachedSummary) { + if (cachedSummary?.transactions?.length) { return cachedSummary.transactions; } } @@ -598,7 +598,7 @@ class Blocks { // Check if it's indexed in db if (skipDBLookup === false && Common.blocksSummariesIndexingEnabled() === true) { const indexedSummary = await BlocksSummariesRepository.$getByBlockId(hash); - if (indexedSummary !== undefined) { + if (indexedSummary !== undefined && indexedSummary?.transactions?.length) { return indexedSummary.transactions; } } @@ -651,6 +651,19 @@ class Blocks { return returnBlocks; } + public async $getBlockAuditSummary(hash: string): Promise { + let summary = await BlocksAuditsRepository.$getBlockAudit(hash); + + // fallback to non-audited transaction summary + if (!summary?.transactions?.length) { + const strippedTransactions = await this.$getStrippedBlockTransactions(hash); + summary = { + transactions: strippedTransactions + }; + } + return summary; + } + public getLastDifficultyAdjustmentTime(): number { return this.lastDifficultyAdjustmentTime; } diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 6dbfab723..d8e96d57d 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -4,7 +4,7 @@ import logger from '../logger'; import { Common } from './common'; class DatabaseMigration { - private static currentVersion = 44; + private static currentVersion = 45; private queryTimeout = 900_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -365,6 +365,10 @@ class DatabaseMigration { await this.$executeQuery('TRUNCATE TABLE `blocks_audits`'); await this.$executeQuery('UPDATE blocks_summaries SET template = NULL'); } + + if (databaseSchemaVersion < 45 && isBitcoin === true) { + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fresh_txs JSON DEFAULT "[]"'); + } } /** diff --git a/backend/src/api/mining/mining-routes.ts b/backend/src/api/mining/mining-routes.ts index 73d38d841..81c7b5a99 100644 --- a/backend/src/api/mining/mining-routes.ts +++ b/backend/src/api/mining/mining-routes.ts @@ -283,9 +283,12 @@ class MiningRoutes { private async $getBlockAuditScores(req: Request, res: Response) { try { - const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10); + let height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10); + if (height == null) { + height = await BlocksRepository.$mostRecentBlockHeight(); + } res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); - res.json(await audits.$getBlockAuditScores(height, 15)); + res.json(await BlocksAuditsRepository.$getBlockAuditScores(height, height - 15)); } catch (e) { res.status(500).send(e instanceof Error ? e.message : e); } diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 375869902..0499fe842 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -428,7 +428,7 @@ class WebsocketHandler { if (Common.indexingEnabled() && memPool.isInSync()) { const projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions(); - const { censored, added, score } = Audit.auditBlock(transactions, projectedBlocks, _memPool); + const { censored, added, fresh, score } = Audit.auditBlock(transactions, projectedBlocks, _memPool); matchRate = Math.round(score * 100 * 100) / 100; const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => { @@ -454,6 +454,7 @@ class WebsocketHandler { hash: block.id, addedTxs: added, missingTxs: censored, + freshTxs: fresh, matchRate: matchRate, }); diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 24bfa1565..0e68d2ed5 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -28,6 +28,7 @@ export interface BlockAudit { height: number, hash: string, missingTxs: string[], + freshTxs: string[], addedTxs: string[], matchRate: number, } diff --git a/backend/src/repositories/BlocksAuditsRepository.ts b/backend/src/repositories/BlocksAuditsRepository.ts index 2aa1fb260..c6156334b 100644 --- a/backend/src/repositories/BlocksAuditsRepository.ts +++ b/backend/src/repositories/BlocksAuditsRepository.ts @@ -1,3 +1,4 @@ +import blocks from '../api/blocks'; import DB from '../database'; import logger from '../logger'; import { BlockAudit, AuditScore } from '../mempool.interfaces'; @@ -5,9 +6,9 @@ import { BlockAudit, AuditScore } from '../mempool.interfaces'; class BlocksAuditRepositories { public async $saveAudit(audit: BlockAudit): Promise { try { - await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, match_rate) - VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs), - JSON.stringify(audit.addedTxs), audit.matchRate]); + await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, fresh_txs, match_rate) + VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs), + JSON.stringify(audit.addedTxs), JSON.stringify(audit.freshTxs), audit.matchRate]); } catch (e: any) { if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart logger.debug(`Cannot save block audit for block ${audit.hash} because it has already been indexed, ignoring`); @@ -51,7 +52,7 @@ class BlocksAuditRepositories { const [rows]: any[] = await DB.query( `SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size, blocks.weight, blocks.tx_count, - transactions, template, missing_txs as missingTxs, added_txs as addedTxs, match_rate as matchRate + transactions, template, missing_txs as missingTxs, added_txs as addedTxs, fresh_txs as freshTxs, match_rate as matchRate FROM blocks_audits JOIN blocks ON blocks.hash = blocks_audits.hash JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash @@ -61,11 +62,15 @@ class BlocksAuditRepositories { if (rows.length) { rows[0].missingTxs = JSON.parse(rows[0].missingTxs); rows[0].addedTxs = JSON.parse(rows[0].addedTxs); + rows[0].freshTxs = JSON.parse(rows[0].freshTxs); rows[0].transactions = JSON.parse(rows[0].transactions); rows[0].template = JSON.parse(rows[0].template); + + if (rows[0].transactions.length) { + return rows[0]; + } } - - return rows[0]; + return null; } catch (e: any) { logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e)); throw e; @@ -85,6 +90,20 @@ class BlocksAuditRepositories { throw e; } } + + public async $getBlockAuditScores(maxHeight: number, minHeight: number): Promise { + try { + const [rows]: any[] = await DB.query( + `SELECT hash, match_rate as matchRate + FROM blocks_audits + WHERE blocks_audits.height BETWEEN ? AND ? + `, [minHeight, maxHeight]); + return rows; + } catch (e: any) { + logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } } export default new BlocksAuditRepositories(); diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 69c78fc83..d9c6a93bb 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -4,7 +4,6 @@ import { AppPreloadingStrategy } from './app.preloading-strategy' import { StartComponent } from './components/start/start.component'; import { TransactionComponent } from './components/transaction/transaction.component'; import { BlockComponent } from './components/block/block.component'; -import { BlockAuditComponent } from './components/block-audit/block-audit.component'; import { AddressComponent } from './components/address/address.component'; import { MasterPageComponent } from './components/master-page/master-page.component'; import { AboutComponent } from './components/about/about.component'; @@ -103,16 +102,6 @@ let routes: Routes = [ }, ], }, - { - path: 'block-audit', - data: { networkSpecific: true }, - children: [ - { - path: ':id', - component: BlockAuditComponent, - }, - ], - }, { path: 'docs', loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule), @@ -219,16 +208,6 @@ let routes: Routes = [ }, ], }, - { - path: 'block-audit', - data: { networkSpecific: true }, - children: [ - { - path: ':id', - component: BlockAuditComponent, - }, - ], - }, { path: 'docs', loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) @@ -331,16 +310,6 @@ let routes: Routes = [ }, ], }, - { - path: 'block-audit', - data: { networkSpecific: true }, - children: [ - { - path: ':id', - component: BlockAuditComponent - }, - ], - }, { path: 'docs', loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) diff --git a/frontend/src/app/components/block-audit/block-audit.component.html b/frontend/src/app/components/block-audit/block-audit.component.html deleted file mode 100644 index a3f2e2ada..000000000 --- a/frontend/src/app/components/block-audit/block-audit.component.html +++ /dev/null @@ -1,172 +0,0 @@ -
- -
-

- - Block Audit -   - {{ blockAudit.height }} -   - -

- -
- - -
- -
- - - -
-
- -
- - - - - - - - - - - - - - - - - - - -
Hash{{ blockHash | shortenString : 13 }} - -
Timestamp - ‎{{ blockAudit.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} -
- ( - ) -
-
Size
Weight
-
- - -
- - - - - - - - - - - - - - - - - - - -
Transactions{{ blockAudit.tx_count }}
Block health{{ blockAudit.matchRate }}%
Removed txs{{ blockAudit.missingTxs.length }}
Added txs{{ blockAudit.addedTxs.length }}
-
-
-
- - - -
- - - -
-
- -
- - - - - - - -
-
- - -
- - - - - - - -
-
-
-
- - - -
- - -
-
- audit unavailable -

- {{ error.error }} -
-
-
- -
-
- Error loading data. -

- {{ error }} -
-
-
-
-
- - -
-
- -
-

Projected Block

- -
- - -
-

Actual Block

- -
-
-
- -
\ No newline at end of file diff --git a/frontend/src/app/components/block-audit/block-audit.component.scss b/frontend/src/app/components/block-audit/block-audit.component.scss deleted file mode 100644 index 1e35b7c63..000000000 --- a/frontend/src/app/components/block-audit/block-audit.component.scss +++ /dev/null @@ -1,44 +0,0 @@ -.title-block { - border-top: none; -} - -.table { - tr td { - &:last-child { - text-align: right; - @media (min-width: 768px) { - text-align: left; - } - } - } -} - -.block-tx-title { - display: flex; - justify-content: space-between; - flex-direction: column; - position: relative; - @media (min-width: 550px) { - flex-direction: row; - } - h2 { - line-height: 1; - margin: 0; - position: relative; - padding-bottom: 10px; - @media (min-width: 550px) { - padding-bottom: 0px; - align-self: end; - } - } -} - -.menu-button { - @media (min-width: 768px) { - max-width: 150px; - } -} - -.block-subtitle { - text-align: center; -} \ No newline at end of file diff --git a/frontend/src/app/components/block-audit/block-audit.component.ts b/frontend/src/app/components/block-audit/block-audit.component.ts deleted file mode 100644 index 3787796fd..000000000 --- a/frontend/src/app/components/block-audit/block-audit.component.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { Component, OnDestroy, OnInit, AfterViewInit, ViewChildren, QueryList } from '@angular/core'; -import { ActivatedRoute, ParamMap, Router } from '@angular/router'; -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'; -import { BlockOverviewGraphComponent } from '../block-overview-graph/block-overview-graph.component'; - -@Component({ - selector: 'app-block-audit', - templateUrl: './block-audit.component.html', - styleUrls: ['./block-audit.component.scss'], - styles: [` - .loadingGraphs { - position: absolute; - top: 50%; - left: calc(50% - 15px); - z-index: 100; - } - `], -}) -export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy { - blockAudit: BlockAudit = undefined; - transactions: string[]; - auditSubscription: Subscription; - urlFragmentSubscription: Subscription; - - paginationMaxSize: number; - page = 1; - itemsPerPage: number; - - mode: 'projected' | 'actual' = 'projected'; - error: any; - isLoading = true; - webGlEnabled = true; - isMobile = window.innerWidth <= 767.98; - hoverTx: string; - - childChangeSubscription: Subscription; - - blockHash: string; - numMissing: number = 0; - numUnexpected: number = 0; - - @ViewChildren('blockGraphProjected') blockGraphProjected: QueryList; - @ViewChildren('blockGraphActual') blockGraphActual: QueryList; - - constructor( - private route: ActivatedRoute, - public stateService: StateService, - private router: Router, - private apiService: ApiService, - private electrsApiService: ElectrsApiService, - ) { - this.webGlEnabled = detectWebGL(); - } - - ngOnDestroy() { - this.childChangeSubscription.unsubscribe(); - this.urlFragmentSubscription.unsubscribe(); - } - - ngOnInit(): void { - this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; - this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE; - - this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => { - if (fragment === 'actual') { - this.mode = 'actual'; - } else { - this.mode = 'projected' - } - this.setupBlockGraphs(); - }); - - this.auditSubscription = this.route.paramMap.pipe( - switchMap((params: ParamMap) => { - 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) - }), - 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 of(null); - }), - ).subscribe((blockAudit) => { - this.blockAudit = blockAudit; - this.setupBlockGraphs(); - this.isLoading = false; - }); - } - - ngAfterViewInit() { - this.childChangeSubscription = combineLatest([this.blockGraphProjected.changes.pipe(startWith(null)), this.blockGraphActual.changes.pipe(startWith(null))]).subscribe(() => { - this.setupBlockGraphs(); - }) - } - - setupBlockGraphs() { - if (this.blockAudit) { - this.blockGraphProjected.forEach(graph => { - graph.destroy(); - if (this.isMobile && this.mode === 'actual') { - graph.setup(this.blockAudit.transactions); - } else { - graph.setup(this.blockAudit.template); - } - }) - this.blockGraphActual.forEach(graph => { - graph.destroy(); - graph.setup(this.blockAudit.transactions); - }) - } - } - - onResize(event: any) { - const isMobile = event.target.innerWidth <= 767.98; - const changed = isMobile !== this.isMobile; - this.isMobile = isMobile; - this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5; - - if (changed) { - this.changeMode(this.mode); - } - } - - changeMode(mode: 'projected' | 'actual') { - this.router.navigate([], { fragment: mode }); - } - - onTxClick(event: TransactionStripped): void { - const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); - this.router.navigate([url]); - } - - onTxHover(txid: string): void { - if (txid && txid.length) { - this.hoverTx = txid; - } else { - this.hoverTx = null; - } - } -} diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html index 782cbe25e..77ee62cae 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html @@ -1,7 +1,8 @@
-
-
+
+
+
not available
(); @Output() txHoverEvent = new EventEmitter(); @Output() readyEvent = new EventEmitter(); diff --git a/frontend/src/app/components/block-overview-graph/tx-view.ts b/frontend/src/app/components/block-overview-graph/tx-view.ts index f07d96eb0..f73b83fd4 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -3,12 +3,13 @@ import { FastVertexArray } from './fast-vertex-array'; import { TransactionStripped } from '../../interfaces/websocket.interface'; import { SpriteUpdateParams, Square, Color, ViewUpdateParams } from './sprite-types'; import { feeLevels, mempoolFeeColors } from '../../app.constants'; +import BlockScene from './block-scene'; const hoverTransitionTime = 300; const defaultHoverColor = hexToColor('1bd8f4'); const feeColors = mempoolFeeColors.map(hexToColor); -const auditFeeColors = feeColors.map((color) => desaturate(color, 0.3)); +const auditFeeColors = feeColors.map((color) => darken(desaturate(color, 0.3), 0.9)); const auditColors = { censored: hexToColor('f344df'), missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7), @@ -34,7 +35,8 @@ export default class TxView implements TransactionStripped { vsize: number; value: number; feerate: number; - status?: 'found' | 'missing' | 'added' | 'censored' | 'selected'; + status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; + context?: 'projected' | 'actual'; initialised: boolean; vertexArray: FastVertexArray; @@ -48,6 +50,7 @@ export default class TxView implements TransactionStripped { dirty: boolean; constructor(tx: TransactionStripped, vertexArray: FastVertexArray) { + this.context = tx.context; this.txid = tx.txid; this.fee = tx.fee; this.vsize = tx.vsize; @@ -159,12 +162,18 @@ export default class TxView implements TransactionStripped { return auditColors.censored; case 'missing': return auditColors.missing; + case 'fresh': + return auditColors.missing; case 'added': return auditColors.added; case 'selected': return auditColors.selected; case 'found': - return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1]; + if (this.context === 'projected') { + return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1]; + } else { + return feeLevelColor; + } default: return feeLevelColor; } diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html index 8c1002025..71801bfb4 100644 --- a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html @@ -37,9 +37,10 @@ match removed - omitted + marginal fee rate + recently broadcast added - extra + marginal fee rate diff --git a/frontend/src/app/components/block/block.component.html b/frontend/src/app/components/block/block.component.html index ba8f3aef3..91b20278c 100644 --- a/frontend/src/app/components/block/block.component.html +++ b/frontend/src/app/components/block/block.component.html @@ -54,7 +54,19 @@ Weight - + + Block health + + {{ blockAudit.matchRate }}% + Unknown + + + + + + Fee span + {{ block.extras.feeRange[0] | number:'1.0-0' }} - {{ block.extras.feeRange[block.extras.feeRange.length - 1] | number:'1.0-0' }} sat/vB + Median fee ~{{ block?.extras?.medianFee | number:'1.0-0' }} sat/vB @@ -98,26 +110,19 @@ Miner - {{ block.extras.pool.name }} - {{ block.extras.pool.name }} - - Block health - - {{ block.extras.matchRate }}% - Unknown - - - +
@@ -138,7 +143,11 @@ - + + + + + @@ -148,17 +157,25 @@ - + - + + + +
-
- +
+
+ + + + + @@ -216,8 +233,9 @@
Fee span{{ block.extras.feeRange[0] | number:'1.0-0' }} - {{ block.extras.feeRange[block.extras.feeRange.length - 1] | number:'1.0-0' }} sat/vB
Median fee ~{{ block?.extras?.medianFee | number:'1.0-0' }} sat/vB
- +
+ @@ -230,22 +248,54 @@ + + +
-
-
- +
+ +
+ + + +
+ + +
+ +
+
+

Projected Block

+ +
+
+

Actual Block

+ +
+
+
+

@@ -273,6 +323,7 @@
+ diff --git a/frontend/src/app/components/block/block.component.scss b/frontend/src/app/components/block/block.component.scss index d6c4d65b4..69002de79 100644 --- a/frontend/src/app/components/block/block.component.scss +++ b/frontend/src/app/components/block/block.component.scss @@ -171,3 +171,35 @@ h1 { margin: auto; } } + +.menu-button { + @media (min-width: 768px) { + max-width: 150px; + } +} + +.block-subtitle { + text-align: center; +} + +.nav-tabs { + border-color: white; + border-width: 1px; +} + +.nav-tabs .nav-link { + background: inherit; + border-width: 1px; + border-bottom: none; + border-color: transparent; + margin-bottom: -1px; + cursor: pointer; + + &.active { + background: #24273e; + } + + &.active, &:hover { + border-color: white; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index aff07a95e..916904375 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -1,15 +1,15 @@ -import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; +import { Component, OnInit, OnDestroy, ViewChildren, QueryList } from '@angular/core'; import { Location } from '@angular/common'; import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { ElectrsApiService } from '../../services/electrs-api.service'; -import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise } from 'rxjs/operators'; +import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise, filter } from 'rxjs/operators'; import { Transaction, Vout } from '../../interfaces/electrs.interface'; -import { Observable, of, Subscription, asyncScheduler, EMPTY, Subject } from 'rxjs'; +import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest } from 'rxjs'; import { StateService } from '../../services/state.service'; import { SeoService } from '../../services/seo.service'; import { WebsocketService } from '../../services/websocket.service'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; -import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; +import { BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; import { ApiService } from '../../services/api.service'; import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; import { detectWebGL } from '../../shared/graphs.utils'; @@ -17,11 +17,20 @@ import { detectWebGL } from '../../shared/graphs.utils'; @Component({ selector: 'app-block', templateUrl: './block.component.html', - styleUrls: ['./block.component.scss'] + styleUrls: ['./block.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], }) export class BlockComponent implements OnInit, OnDestroy { network = ''; block: BlockExtended; + blockAudit: BlockAudit = undefined; blockHeight: number; lastBlockHeight: number; nextBlockHeight: number; @@ -48,9 +57,16 @@ export class BlockComponent implements OnInit, OnDestroy { overviewError: any = null; webGlEnabled = true; indexingAvailable = false; + auditEnabled = true; + isMobile = window.innerWidth <= 767.98; + hoverTx: string; + numMissing: number = 0; + numUnexpected: number = 0; + mode: 'projected' | 'actual' = 'projected'; transactionSubscription: Subscription; overviewSubscription: Subscription; + auditSubscription: Subscription; keyNavigationSubscription: Subscription; blocksSubscription: Subscription; networkChangedSubscription: Subscription; @@ -60,10 +76,10 @@ export class BlockComponent implements OnInit, OnDestroy { nextBlockTxListSubscription: Subscription = undefined; timeLtrSubscription: Subscription; timeLtr: boolean; - fetchAuditScore$ = new Subject(); - fetchAuditScoreSubscription: Subscription; + childChangeSubscription: Subscription; - @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; + @ViewChildren('blockGraphProjected') blockGraphProjected: QueryList; + @ViewChildren('blockGraphActual') blockGraphActual: QueryList; constructor( private route: ActivatedRoute, @@ -89,8 +105,8 @@ export class BlockComponent implements OnInit, OnDestroy { this.timeLtr = !!ltr; }); - this.indexingAvailable = (this.stateService.env.BASE_MODULE === 'mempool' && - this.stateService.env.MINING_DASHBOARD === true); + this.indexingAvailable = (this.stateService.env.BASE_MODULE === 'mempool' && this.stateService.env.MINING_DASHBOARD === true); + this.auditEnabled = this.indexingAvailable; this.txsLoadingStatus$ = this.route.paramMap .pipe( @@ -107,30 +123,12 @@ 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') || ''; @@ -212,7 +210,7 @@ export class BlockComponent implements OnInit, OnDestroy { setTimeout(() => { this.nextBlockSubscription = this.apiService.getBlock$(block.previousblockhash).subscribe(); this.nextBlockTxListSubscription = this.electrsApiService.getBlockTransactions$(block.previousblockhash).subscribe(); - this.nextBlockSummarySubscription = this.apiService.getStrippedBlockTransactions$(block.previousblockhash).subscribe(); + this.apiService.getBlockAudit$(block.previousblockhash); }, 100); } @@ -229,9 +227,6 @@ 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; @@ -263,40 +258,126 @@ export class BlockComponent implements OnInit, OnDestroy { this.isLoadingOverview = false; }); - 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' }); + if (!this.indexingAvailable) { + 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.indexingAvailable) { + this.auditSubscription = block$.pipe( + startWith(null), + pairwise(), + switchMap(([prevBlock, block]) => this.apiService.getBlockAudit$(block.id) + .pipe( + catchError((err) => { + this.overviewError = err; + return of([]); + }) + ) + ), + filter((response) => response != null), + map((response) => { + const blockAudit = response.body; + const inTemplate = {}; + const inBlock = {}; + const isAdded = {}; + const isCensored = {}; + const isMissing = {}; + const isSelected = {}; + const isFresh = {}; + this.numMissing = 0; + this.numUnexpected = 0; + + if (blockAudit?.template) { + for (const tx of blockAudit.template) { + inTemplate[tx.txid] = true; } - }) - ) - ), - ) - .subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => { - this.strippedTransactions = transactions; - this.isLoadingOverview = false; - if (this.blockGraph) { - this.blockGraph.destroy(); - this.blockGraph.setup(this.strippedTransactions); - } - }, - (error) => { - this.error = error; - this.isLoadingOverview = false; - if (this.blockGraph) { - this.blockGraph.destroy(); - } - }); + for (const tx of blockAudit.transactions) { + inBlock[tx.txid] = true; + } + for (const txid of blockAudit.addedTxs) { + isAdded[txid] = true; + } + for (const txid of blockAudit.missingTxs) { + isCensored[txid] = true; + } + for (const txid of blockAudit.freshTxs || []) { + isFresh[txid] = true; + } + // set transaction statuses + for (const tx of blockAudit.template) { + tx.context = 'projected'; + if (isCensored[tx.txid]) { + tx.status = 'censored'; + } else if (inBlock[tx.txid]) { + tx.status = 'found'; + } else { + tx.status = isFresh[tx.txid] ? 'fresh' : 'missing'; + isMissing[tx.txid] = true; + this.numMissing++; + } + } + for (const [index, tx] of blockAudit.transactions.entries()) { + tx.context = 'actual'; + if (index === 0) { + tx.status = null; + } else if (isAdded[tx.txid]) { + tx.status = 'added'; + } else if (inTemplate[tx.txid]) { + tx.status = 'found'; + } else { + tx.status = 'selected'; + isSelected[tx.txid] = true; + this.numUnexpected++; + } + } + for (const tx of blockAudit.transactions) { + inBlock[tx.txid] = true; + } + this.auditEnabled = true; + } else { + this.auditEnabled = false; + } + return blockAudit; + }), + catchError((err) => { + console.log(err); + this.error = err; + this.isLoadingOverview = false; + return of(null); + }), + ).subscribe((blockAudit) => { + this.blockAudit = blockAudit; + this.setupBlockGraphs(); + this.isLoadingOverview = false; + }); + } this.networkChangedSubscription = this.stateService.networkChanged$ .subscribe((network) => this.network = network); @@ -307,6 +388,12 @@ export class BlockComponent implements OnInit, OnDestroy { } else { this.showDetails = false; } + if (params.view === 'projected') { + this.mode = 'projected'; + } else { + this.mode = 'actual'; + } + this.setupBlockGraphs(); }); this.keyNavigationSubscription = this.stateService.keyNavigation$.subscribe((event) => { @@ -325,17 +412,24 @@ export class BlockComponent implements OnInit, OnDestroy { }); } + ngAfterViewInit(): void { + this.childChangeSubscription = combineLatest([this.blockGraphProjected.changes.pipe(startWith(null)), this.blockGraphActual.changes.pipe(startWith(null))]).subscribe(() => { + this.setupBlockGraphs(); + }); + } + ngOnDestroy() { this.stateService.markBlock$.next({}); this.transactionSubscription.unsubscribe(); - this.overviewSubscription.unsubscribe(); + this.overviewSubscription?.unsubscribe(); + this.auditSubscription?.unsubscribe(); this.keyNavigationSubscription.unsubscribe(); this.blocksSubscription.unsubscribe(); this.networkChangedSubscription.unsubscribe(); this.queryParamsSubscription.unsubscribe(); this.timeLtrSubscription.unsubscribe(); - this.fetchAuditScoreSubscription?.unsubscribe(); this.unsubscribeNextBlockSubscriptions(); + this.childChangeSubscription.unsubscribe(); } unsubscribeNextBlockSubscriptions() { @@ -382,7 +476,7 @@ export class BlockComponent implements OnInit, OnDestroy { this.showDetails = false; this.router.navigate([], { relativeTo: this.route, - queryParams: { showDetails: false }, + queryParams: { showDetails: false, view: this.mode }, queryParamsHandling: 'merge', fragment: 'block' }); @@ -390,7 +484,7 @@ export class BlockComponent implements OnInit, OnDestroy { this.showDetails = true; this.router.navigate([], { relativeTo: this.route, - queryParams: { showDetails: true }, + queryParams: { showDetails: true, view: this.mode }, queryParamsHandling: 'merge', fragment: 'details' }); @@ -409,10 +503,6 @@ export class BlockComponent implements OnInit, OnDestroy { return this.block && this.block.height > 681393 && (new Date().getTime() / 1000) < 1628640000; } - onResize(event: any) { - this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5; - } - navigateToPreviousBlock() { if (!this.block) { return; @@ -443,8 +533,53 @@ export class BlockComponent implements OnInit, OnDestroy { } } + setupBlockGraphs(): void { + if (this.blockAudit || this.strippedTransactions) { + this.blockGraphProjected.forEach(graph => { + graph.destroy(); + if (this.isMobile && this.mode === 'actual') { + graph.setup(this.blockAudit?.transactions || this.strippedTransactions || []); + } else { + graph.setup(this.blockAudit?.template || []); + } + }); + this.blockGraphActual.forEach(graph => { + graph.destroy(); + graph.setup(this.blockAudit?.transactions || this.strippedTransactions || []); + }); + } + } + + onResize(event: any): void { + const isMobile = event.target.innerWidth <= 767.98; + const changed = isMobile !== this.isMobile; + this.isMobile = isMobile; + this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5; + + if (changed) { + this.changeMode(this.mode); + } + } + + changeMode(mode: 'projected' | 'actual'): void { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { showDetails: this.showDetails, view: mode }, + queryParamsHandling: 'merge', + fragment: 'overview' + }); + } + onTxClick(event: TransactionStripped): void { const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); this.router.navigate([url]); } + + onTxHover(txid: string): void { + if (txid && txid.length) { + this.hoverTx = txid; + } else { + this.hoverTx = null; + } + } } \ No newline at end of file diff --git a/frontend/src/app/components/blocks-list/blocks-list.component.html b/frontend/src/app/components/blocks-list/blocks-list.component.html index 69bcf3141..628efb51b 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.html +++ b/frontend/src/app/components/blocks-list/blocks-list.component.html @@ -46,14 +46,13 @@
Difficulty {{ block.difficulty }} - +
{{ auditScores[block.id] }}% - - ~ + ~
diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 39d0c3d5d..5df095432 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -141,7 +141,7 @@ export interface TransactionStripped { fee: number; vsize: number; value: number; - status?: 'found' | 'missing' | 'added' | 'censored' | 'selected'; + status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; } export interface RewardStats { diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 67cc0ffc7..96f7530c9 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -70,7 +70,8 @@ export interface TransactionStripped { fee: number; vsize: number; value: number; - status?: 'found' | 'missing' | 'added' | 'censored' | 'selected'; + status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; + context?: 'projected' | 'actual'; } export interface IBackendInfo { diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index dfed35d72..f813959e3 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -230,7 +230,7 @@ export class ApiService { getBlockAudit$(hash: string) : Observable { return this.httpClient.get( - this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/audit/` + hash, { observe: 'response' } + this.apiBaseUrl + this.apiBasePath + `/api/v1/block/${hash}/audit-summary`, { observe: 'response' } ); } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 9e9e2e2a5..ca4501d58 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -45,7 +45,6 @@ import { StartComponent } from '../components/start/start.component'; import { TransactionComponent } from '../components/transaction/transaction.component'; import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component'; import { BlockComponent } from '../components/block/block.component'; -import { BlockAuditComponent } from '../components/block-audit/block-audit.component'; import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component'; import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component'; import { AddressComponent } from '../components/address/address.component'; @@ -120,7 +119,6 @@ import { GeolocationComponent } from '../shared/components/geolocation/geolocati StartComponent, TransactionComponent, BlockComponent, - BlockAuditComponent, BlockOverviewGraphComponent, BlockOverviewTooltipComponent, TransactionsListComponent, @@ -223,7 +221,6 @@ import { GeolocationComponent } from '../shared/components/geolocation/geolocati StartComponent, TransactionComponent, BlockComponent, - BlockAuditComponent, BlockOverviewGraphComponent, BlockOverviewTooltipComponent, TransactionsListComponent,