diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index c8c6f4a98..30f9fbf78 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -578,7 +578,7 @@ class Blocks { // Index the response if needed if (Common.blocksSummariesIndexingEnabled() === true) { - await BlocksSummariesRepository.$saveSummary(block.height, summary); + await BlocksSummariesRepository.$saveSummary(block.height, summary, null); } return summary.transactions; diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index e0a592b01..0b43095cb 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 = 31; + private static currentVersion = 32; private queryTimeout = 120000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -297,7 +297,10 @@ class DatabaseMigration { await this.$executeQuery('ALTER TABLE `prices` ADD `id` int NULL AUTO_INCREMENT UNIQUE'); await this.$executeQuery('DROP TABLE IF EXISTS `blocks_prices`'); await this.$executeQuery(this.getCreateBlocksPricesTableQuery(), await this.$checkIfTableExists('blocks_prices')); + } + if (databaseSchemaVersion < 32 && isBitcoin == true) { + await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD `template` JSON DEFAULT "[]"'); } } diff --git a/backend/src/api/mining/mining-routes.ts b/backend/src/api/mining/mining-routes.ts index 02262353f..f52d42d1f 100644 --- a/backend/src/api/mining/mining-routes.ts +++ b/backend/src/api/mining/mining-routes.ts @@ -26,7 +26,8 @@ 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/:hash', this.$getBlockAudit) + ; } private async $getPool(req: Request, res: Response): Promise { @@ -233,6 +234,18 @@ class MiningRoutes { res.status(500).send(e instanceof Error ? e.message : e); } } + + public async $getBlockAudit(req: Request, res: Response) { + try { + const audit = await BlocksAuditsRepository.$getBlockAudit(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); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } } export default new MiningRoutes(); diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 300341ef5..dee44ba63 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -17,6 +17,7 @@ import rbfCache from './rbf-cache'; import difficultyAdjustment from './difficulty-adjustment'; import feeApi from './fee-api'; import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository'; +import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository'; class WebsocketHandler { private wss: WebSocket.Server | undefined; @@ -442,6 +443,19 @@ class WebsocketHandler { mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); if (Common.indexingEnabled()) { + const stripped = _mempoolBlocks[0].transactions.map((tx) => { + return { + txid: tx.txid, + vsize: tx.vsize, + fee: tx.fee ? Math.round(tx.fee) : 0, + value: tx.value, + }; + }); + BlocksSummariesRepository.$saveSummary(block.height, null, { + id: block.id, + transactions: stripped + }); + BlocksAuditsRepository.$saveAudit({ time: block.timestamp, height: block.height, diff --git a/backend/src/repositories/BlocksAuditsRepository.ts b/backend/src/repositories/BlocksAuditsRepository.ts index 31d8ec785..54b723959 100644 --- a/backend/src/repositories/BlocksAuditsRepository.ts +++ b/backend/src/repositories/BlocksAuditsRepository.ts @@ -1,3 +1,4 @@ +import transactionUtils from '../api/transaction-utils'; import DB from '../database'; import logger from '../logger'; import { BlockAudit } from '../mempool.interfaces'; @@ -45,6 +46,30 @@ class BlocksAuditRepositories { throw e; } } + + public async $getBlockAudit(hash: string): Promise { + try { + 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 + FROM blocks_audits + JOIN blocks ON blocks.hash = blocks_audits.hash + JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash + WHERE blocks_audits.hash = "${hash}" + `); + + rows[0].missingTxs = JSON.parse(rows[0].missingTxs); + rows[0].addedTxs = JSON.parse(rows[0].addedTxs); + rows[0].transactions = JSON.parse(rows[0].transactions); + rows[0].template = JSON.parse(rows[0].template); + + return rows[0]; + } 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/backend/src/repositories/BlocksSummariesRepository.ts b/backend/src/repositories/BlocksSummariesRepository.ts index 66c6b97f2..2f47f7a35 100644 --- a/backend/src/repositories/BlocksSummariesRepository.ts +++ b/backend/src/repositories/BlocksSummariesRepository.ts @@ -17,14 +17,24 @@ class BlocksSummariesRepository { return undefined; } - public async $saveSummary(height: number, summary: BlockSummary) { + public async $saveSummary(height: number, mined: BlockSummary | null = null, template: BlockSummary | null = null) { + const blockId = mined?.id ?? template?.id; try { - await DB.query(`INSERT INTO blocks_summaries VALUE (?, ?, ?)`, [height, summary.id, JSON.stringify(summary.transactions)]); + const [dbSummary]: any[] = await DB.query(`SELECT * FROM blocks_summaries WHERE id = "${blockId}"`); + if (dbSummary.length === 0) { // First insertion + await DB.query(`INSERT INTO blocks_summaries VALUE (?, ?, ?, ?)`, [ + height, blockId, JSON.stringify(mined?.transactions ?? []), JSON.stringify(template?.transactions ?? []) + ]); + } else if (mined !== null) { // Update mined block summary + await DB.query(`UPDATE blocks_summaries SET transactions = ? WHERE id = "${mined.id}"`, [JSON.stringify(mined?.transactions)]); + } else if (template !== null) { // Update template block summary + await DB.query(`UPDATE blocks_summaries SET template = ? WHERE id = "${template.id}"`, [JSON.stringify(template?.transactions)]); + } } catch (e: any) { if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart - logger.debug(`Cannot save block summary for ${summary.id} because it has already been indexed, ignoring`); + logger.debug(`Cannot save block summary for ${blockId} because it has already been indexed, ignoring`); } else { - logger.debug(`Cannot save block summary for ${summary.id}. Reason: ${e instanceof Error ? e.message : e}`); + logger.debug(`Cannot save block summary for ${blockId}. Reason: ${e instanceof Error ? e.message : e}`); throw e; } } @@ -44,7 +54,7 @@ class BlocksSummariesRepository { /** * Delete blocks from the database from blockHeight */ - public async $deleteBlocksFrom(blockHeight: number) { + public async $deleteBlocksFrom(blockHeight: number) { logger.info(`Delete newer blocks summary from height ${blockHeight} from the database`); try { diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 564b8653b..1a658c44b 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -3,6 +3,7 @@ import { Routes, RouterModule, PreloadAllModules } from '@angular/router'; 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'; @@ -88,6 +89,15 @@ let routes: Routes = [ }, ], }, + { + path: 'block-audit', + children: [ + { + path: ':id', + component: BlockAuditComponent, + }, + ], + }, { path: 'docs', loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) @@ -182,6 +192,15 @@ let routes: Routes = [ }, ], }, + { + path: 'block-audit', + children: [ + { + path: ':id', + component: BlockAuditComponent, + }, + ], + }, { path: 'docs', loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) @@ -273,6 +292,15 @@ let routes: Routes = [ }, ], }, + { + path: 'block-audit', + 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 new file mode 100644 index 000000000..0ee6bef44 --- /dev/null +++ b/frontend/src/app/components/block-audit/block-audit.component.html @@ -0,0 +1,111 @@ +
+ +
+
+

+ + Block +   + {{ blockAudit.height }} +   + Template vs Mined + +

+ +
+ + +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
Hash{{ blockAudit.id | shortenString : 13 }} + +
Timestamp + ‎{{ blockAudit.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} +
+ ( + ) +
+
Size
Weight
+
+ + +
+ + + + + + + + + + + + + + + + + + + +
Transactions{{ blockAudit.tx_count }}
Match rate{{ blockAudit.matchRate }}%
Missing txs{{ blockAudit.missingTxs.length }}
Added txs{{ blockAudit.addedTxs.length }}
+
+
+
+ + + +
+ + +
+
+ +
+ +
+ + +
+ +
+
+
+ + + +
\ 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 new file mode 100644 index 000000000..7ec503891 --- /dev/null +++ b/frontend/src/app/components/block-audit/block-audit.component.scss @@ -0,0 +1,40 @@ +.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; + } +} \ 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 new file mode 100644 index 000000000..044552a3b --- /dev/null +++ b/frontend/src/app/components/block-audit/block-audit.component.ts @@ -0,0 +1,120 @@ +import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ActivatedRoute, ParamMap, Router } from '@angular/router'; +import { Observable } from 'rxjs'; +import { map, share, switchMap, tap } from 'rxjs/operators'; +import { BlockAudit, TransactionStripped } from 'src/app/interfaces/node-api.interface'; +import { ApiService } from 'src/app/services/api.service'; +import { StateService } from 'src/app/services/state.service'; +import { detectWebGL } from 'src/app/shared/graphs.utils'; +import { RelativeUrlPipe } from 'src/app/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, OnDestroy { + blockAudit: BlockAudit = undefined; + transactions: string[]; + auditObservable$: Observable; + + paginationMaxSize: number; + page = 1; + itemsPerPage: number; + + mode: 'missing' | 'added' = 'missing'; + isLoading = true; + webGlEnabled = true; + isMobile = window.innerWidth <= 767.98; + + @ViewChild('blockGraphTemplate') blockGraphTemplate: BlockOverviewGraphComponent; + @ViewChild('blockGraphMined') blockGraphMined: BlockOverviewGraphComponent; + + constructor( + private route: ActivatedRoute, + public stateService: StateService, + private router: Router, + private apiService: ApiService + ) { + this.webGlEnabled = detectWebGL(); + } + + ngOnDestroy(): void { + } + + ngOnInit(): void { + this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; + this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE; + + this.auditObservable$ = this.route.paramMap.pipe( + switchMap((params: ParamMap) => { + const blockHash: string = params.get('id') || ''; + return this.apiService.getBlockAudit$(blockHash) + .pipe( + map((response) => { + const blockAudit = response.body; + for (let i = 0; i < blockAudit.template.length; ++i) { + if (blockAudit.missingTxs.includes(blockAudit.template[i].txid)) { + blockAudit.template[i].status = 'missing'; + } else if (blockAudit.addedTxs.includes(blockAudit.template[i].txid)) { + blockAudit.template[i].status = 'added'; + } else { + blockAudit.template[i].status = 'found'; + } + } + for (let i = 0; i < blockAudit.transactions.length; ++i) { + if (blockAudit.missingTxs.includes(blockAudit.transactions[i].txid)) { + blockAudit.transactions[i].status = 'missing'; + } else if (blockAudit.addedTxs.includes(blockAudit.transactions[i].txid)) { + blockAudit.transactions[i].status = 'added'; + } else { + blockAudit.transactions[i].status = 'found'; + } + } + return blockAudit; + }), + tap((blockAudit) => { + this.changeMode(this.mode); + if (this.blockGraphTemplate) { + this.blockGraphTemplate.destroy(); + this.blockGraphTemplate.setup(blockAudit.template); + } + if (this.blockGraphMined) { + this.blockGraphMined.destroy(); + this.blockGraphMined.setup(blockAudit.transactions); + } + this.isLoading = false; + }), + ); + }), + share() + ); + } + + onResize(event: any) { + this.isMobile = event.target.innerWidth <= 767.98; + this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5; + } + + changeMode(mode: 'missing' | 'added') { + this.router.navigate([], { fragment: mode }); + this.mode = mode; + } + + onTxClick(event: TransactionStripped): void { + const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); + this.router.navigate([url]); + } + + pageChange(page: number, target: HTMLElement) { + } +} diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts index e2774ac03..6e62a2fd0 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts @@ -1,6 +1,5 @@ import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit, OnDestroy } from '@angular/core'; -import { MempoolBlockDelta, TransactionStripped } from 'src/app/interfaces/websocket.interface'; -import { WebsocketService } from 'src/app/services/websocket.service'; +import { TransactionStripped } from 'src/app/interfaces/websocket.interface'; import { FastVertexArray } from './fast-vertex-array'; import BlockScene from './block-scene'; import TxSprite from './tx-sprite'; 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 03718d79d..edcbf2f58 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -25,6 +25,7 @@ export default class TxView implements TransactionStripped { vsize: number; value: number; feerate: number; + status?: 'found' | 'missing' | 'added'; initialised: boolean; vertexArray: FastVertexArray; @@ -43,6 +44,7 @@ export default class TxView implements TransactionStripped { this.vsize = tx.vsize; this.value = tx.value; this.feerate = tx.fee / tx.vsize; + this.status = tx.status; this.initialised = false; this.vertexArray = vertexArray; @@ -140,6 +142,16 @@ export default class TxView implements TransactionStripped { } getColor(): Color { + // Block audit + if (this.status === 'found') { + // return hexToColor('1a4987'); + } else if (this.status === 'missing') { + return hexToColor('039BE5'); + } else if (this.status === 'added') { + return hexToColor('D81B60'); + } + + // Block component const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1; return hexToColor(mempoolFeeColors[feeLevelIndex] || mempoolFeeColors[mempoolFeeColors.length - 1]); } diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index 1032998ef..621956d20 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -12,6 +12,7 @@ import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url. import { BlockExtended, TransactionStripped } from 'src/app/interfaces/node-api.interface'; import { ApiService } from 'src/app/services/api.service'; import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component'; +import { detectWebGL } from 'src/app/shared/graphs.utils'; @Component({ selector: 'app-block', @@ -390,10 +391,4 @@ export class BlockComponent implements OnInit, OnDestroy { const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); this.router.navigate([url]); } -} - -function detectWebGL() { - const canvas = document.createElement('canvas'); - const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); - return (gl && gl instanceof WebGLRenderingContext); -} +} \ No newline at end of file diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 6d3e7c0d8..5f3506db5 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -128,11 +128,20 @@ export interface BlockExtended extends Block { extras?: BlockExtension; } +export interface BlockAudit extends BlockExtended { + missingTxs: string[], + addedTxs: string[], + matchRate: number, + template: TransactionStripped[], + transactions: TransactionStripped[], +} + export interface TransactionStripped { txid: string; fee: number; vsize: number; value: number; + status?: 'found' | 'missing' | 'added'; } export interface RewardStats { diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index d7f0addea..e4ceefb44 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -70,6 +70,7 @@ export interface TransactionStripped { fee: number; vsize: number; value: number; + status?: 'found' | 'missing' | 'added'; } export interface IBackendInfo { diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index ddeb538d9..8e3da7e09 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -228,6 +228,12 @@ export class ApiService { ); } + getBlockAudit$(hash: string) : Observable { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/audit/` + hash, { observe: 'response' } + ); + } + getRewardStats$(blockCount: number = 144): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`); } diff --git a/frontend/src/app/shared/graphs.utils.ts b/frontend/src/app/shared/graphs.utils.ts index 5f23ed800..2e103ecda 100644 --- a/frontend/src/app/shared/graphs.utils.ts +++ b/frontend/src/app/shared/graphs.utils.ts @@ -84,3 +84,9 @@ export const download = (href, name) => { a.click(); document.body.removeChild(a); }; + +export function detectWebGL() { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); + return (gl && gl instanceof WebGLRenderingContext); +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 77e4cb046..cec162ad9 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -44,6 +44,7 @@ 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'; @@ -114,6 +115,7 @@ import { TimestampComponent } from './components/timestamp/timestamp.component'; StartComponent, TransactionComponent, BlockComponent, + BlockAuditComponent, BlockOverviewGraphComponent, BlockOverviewTooltipComponent, TransactionsListComponent, @@ -213,6 +215,7 @@ import { TimestampComponent } from './components/timestamp/timestamp.component'; StartComponent, TransactionComponent, BlockComponent, + BlockAuditComponent, BlockOverviewGraphComponent, BlockOverviewTooltipComponent, TransactionsListComponent,