From 3d5c156776ebac6194c60e8bdd1938d8755dc81e Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 14 Mar 2023 15:39:55 +0900 Subject: [PATCH 001/113] Use effective fee rates in mempool block visualizations & tooltips --- backend/src/api/common.ts | 1 + backend/src/mempool.interfaces.ts | 1 + .../src/app/components/block-overview-graph/tx-view.ts | 7 +++++-- .../block-overview-tooltip.component.html | 6 ++++++ .../block-overview-tooltip.component.ts | 2 ++ frontend/src/app/interfaces/websocket.interface.ts | 1 + 6 files changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 1d3b11d66..fed3f4fa2 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -83,6 +83,7 @@ export class Common { fee: tx.fee, vsize: tx.weight / 4, value: tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0), + rate: tx.effectiveFeePerVsize, }; } diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 16b856bcc..dd315f10c 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -145,6 +145,7 @@ export interface TransactionStripped { fee: number; vsize: number; value: number; + rate?: number; // effective fee rate } export interface BlockExtension { 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 fe224ebac..f2e67da5b 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -36,6 +36,7 @@ export default class TxView implements TransactionStripped { vsize: number; value: number; feerate: number; + rate?: number; status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; context?: 'projected' | 'actual'; scene?: BlockScene; @@ -58,7 +59,8 @@ export default class TxView implements TransactionStripped { this.fee = tx.fee; this.vsize = tx.vsize; this.value = tx.value; - this.feerate = tx.fee / tx.vsize; + this.feerate = tx.rate || (tx.fee / tx.vsize); // sort by effective fee rate where available + this.rate = tx.rate; this.status = tx.status; this.initialised = false; this.vertexArray = scene.vertexArray; @@ -157,7 +159,8 @@ export default class TxView implements TransactionStripped { } getColor(): Color { - const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1; + const rate = this.fee / this.vsize; // color by simple single-tx fee rate + const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, rate) < feeLvl) - 1; const feeLevelColor = feeColors[feeLevelIndex] || feeColors[mempoolFeeColors.length - 1]; // Normal mode if (!this.scene?.highlightingEnabled) { 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 e841e291f..7e2de8d67 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 @@ -28,6 +28,12 @@ {{ feeRate | feeRounding }} sat/vB + + Effective fee rate + + {{ effectiveRate | feeRounding }} sat/vB + + Virtual size diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts index ea011d045..61c294263 100644 --- a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts @@ -20,6 +20,7 @@ export class BlockOverviewTooltipComponent implements OnChanges { value = 0; vsize = 1; feeRate = 0; + effectiveRate; tooltipPosition: Position = { x: 0, y: 0 }; @@ -51,6 +52,7 @@ export class BlockOverviewTooltipComponent implements OnChanges { this.value = tx.value || 0; this.vsize = tx.vsize || 1; this.feeRate = this.fee / this.vsize; + this.effectiveRate = tx.rate; } } } diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 46416857e..ac86971f0 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -71,6 +71,7 @@ export interface TransactionStripped { fee: number; vsize: number; value: number; + rate?: number; // effective fee rate status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; context?: 'projected' | 'actual'; } From 4c569c0dedf6901309ff0b8534418112c1e1b411 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 5 Apr 2023 08:40:43 +0900 Subject: [PATCH 002/113] Send mempool effective fee rate changes to frontend & apply --- backend/src/api/mempool-blocks.ts | 8 ++++++-- backend/src/mempool.interfaces.ts | 1 + .../block-overview-graph.component.ts | 4 ++-- .../components/block-overview-graph/block-scene.ts | 11 ++++++++++- .../mempool-block-overview.component.ts | 2 +- frontend/src/app/interfaces/websocket.interface.ts | 1 + 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index aa2804379..bf5c16955 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -112,6 +112,7 @@ class MempoolBlocks { for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) { let added: TransactionStripped[] = []; let removed: string[] = []; + const changed: { txid: string, rate: number | undefined }[] = []; if (mempoolBlocks[i] && !prevBlocks[i]) { added = mempoolBlocks[i].transactions; } else if (!mempoolBlocks[i] && prevBlocks[i]) { @@ -120,7 +121,7 @@ class MempoolBlocks { const prevIds = {}; const newIds = {}; prevBlocks[i].transactions.forEach(tx => { - prevIds[tx.txid] = true; + prevIds[tx.txid] = tx; }); mempoolBlocks[i].transactions.forEach(tx => { newIds[tx.txid] = true; @@ -133,12 +134,15 @@ class MempoolBlocks { mempoolBlocks[i].transactions.forEach(tx => { if (!prevIds[tx.txid]) { added.push(tx); + } else if (tx.rate !== prevIds[tx.txid].rate) { + changed.push({ txid: tx.txid, rate: tx.rate }); } }); } mempoolBlockDeltas.push({ added, - removed + removed, + changed, }); } return mempoolBlockDeltas; diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index dd315f10c..b43d05c13 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -58,6 +58,7 @@ export interface MempoolBlockWithTransactions extends MempoolBlock { export interface MempoolBlockDelta { added: TransactionStripped[]; removed: string[]; + changed: { txid: string, rate: number | undefined }[]; } interface VinStrippedToScriptsig { 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 3f82d63eb..940939470 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 @@ -132,9 +132,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } } - update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void { + update(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined }[], direction: string = 'left', resetLayout: boolean = false): void { if (this.scene) { - this.scene.update(add, remove, direction, resetLayout); + this.scene.update(add, remove, change, direction, resetLayout); this.start(); } } diff --git a/frontend/src/app/components/block-overview-graph/block-scene.ts b/frontend/src/app/components/block-overview-graph/block-scene.ts index e7853d5a1..7fb0a1e99 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -150,7 +150,7 @@ export default class BlockScene { this.updateAll(startTime, 200, direction); } - update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void { + update(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined }[], direction: string = 'left', resetLayout: boolean = false): void { const startTime = performance.now(); const removed = this.removeBatch(remove, startTime, direction); @@ -172,6 +172,15 @@ export default class BlockScene { this.place(tx); }); } else { + // update effective rates + change.forEach(tx => { + if (this.txs[tx.txid]) { + this.txs[tx.txid].feerate = tx.rate || (this.txs[tx.txid].fee / this.txs[tx.txid].vsize); + this.txs[tx.txid].rate = tx.rate; + this.txs[tx.txid].dirty = true; + } + }); + // try to insert new txs directly const remaining = []; add.map(tx => new TxView(tx, this)).sort(feeRateDescending).forEach(tx => { diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts index ed9f4ef75..30632a862 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts @@ -99,7 +99,7 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang const direction = (this.blockIndex == null || this.index < this.blockIndex) ? this.poolDirection : this.chainDirection; this.blockGraph.replace(delta.added, direction); } else { - this.blockGraph.update(delta.added, delta.removed, blockMined ? this.chainDirection : this.poolDirection, blockMined); + this.blockGraph.update(delta.added, delta.removed, delta.changed || [], blockMined ? this.chainDirection : this.poolDirection, blockMined); } this.lastBlockHeight = this.stateService.latestBlockHeight; diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index ac86971f0..50ffd4ebf 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -54,6 +54,7 @@ export interface MempoolBlockWithTransactions extends MempoolBlock { export interface MempoolBlockDelta { added: TransactionStripped[], removed: string[], + changed?: { txid: string, rate: number | undefined }[]; } export interface MempoolInfo { From b0859f91b2e6b62231d88b5fa9086cd939841f38 Mon Sep 17 00:00:00 2001 From: hunicus <93150691+hunicus@users.noreply.github.com> Date: Wed, 19 Apr 2023 22:00:09 -0400 Subject: [PATCH 003/113] Update unchained icon on about page --- .../app/components/about/about.component.html | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/frontend/src/app/components/about/about.component.html b/frontend/src/app/components/about/about.component.html index 8a0e13335..8a139e5a9 100644 --- a/frontend/src/app/components/about/about.component.html +++ b/frontend/src/app/components/about/about.component.html @@ -107,22 +107,7 @@ Blockstream - - - - - - - - - - - + Unchained From 008ec104b60fca5db7444ef2dcb81a77e6296dc5 Mon Sep 17 00:00:00 2001 From: hunicus <93150691+hunicus@users.noreply.github.com> Date: Wed, 19 Apr 2023 22:52:53 -0400 Subject: [PATCH 004/113] Fix clashing class in unchained svg --- frontend/src/app/components/about/about.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/components/about/about.component.html b/frontend/src/app/components/about/about.component.html index 8a139e5a9..9c52d754d 100644 --- a/frontend/src/app/components/about/about.component.html +++ b/frontend/src/app/components/about/about.component.html @@ -107,7 +107,7 @@ Blockstream - + Unchained From 66919a1aba90ad63781df1cbc6cbea400ce516bc Mon Sep 17 00:00:00 2001 From: softsimon Date: Wed, 26 Apr 2023 13:49:01 +0400 Subject: [PATCH 005/113] Backend block tip height endpoint --- backend/src/api/bitcoin/bitcoin.routes.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index c6323d041..298ae3715 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -94,6 +94,7 @@ class BitcoinRoutes { .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) + .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/height', this.getBlockTipHeight) .post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this)) @@ -110,7 +111,6 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/status', this.getTransactionStatus) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/outspends', this.getTransactionOutspends) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/header', this.getBlockHeader) - .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/height', this.getBlockTipHeight) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/hash', this.getBlockTipHash) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/raw', this.getRawBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txids', this.getTxIdsForBlock) @@ -589,10 +589,14 @@ class BitcoinRoutes { } } - private async getBlockTipHeight(req: Request, res: Response) { + private getBlockTipHeight(req: Request, res: Response) { try { - const result = await bitcoinApi.$getBlockHeightTip(); - res.json(result); + const result = blocks.getCurrentBlockHeight(); + if (!result) { + return res.status(503).send(`Service Temporarily Unavailable`); + } + res.setHeader('content-type', 'text/plain'); + res.send(result.toString()); } catch (e) { res.status(500).send(e instanceof Error ? e.message : e); } From 000c46bf572baa84469e9da4d229a6296dbda46a Mon Sep 17 00:00:00 2001 From: softsimon Date: Fri, 28 Apr 2023 12:06:49 +0400 Subject: [PATCH 006/113] Revert TCP socket fallback --- backend/src/api/bitcoin/esplora-api.ts | 82 ++++++++------------------ backend/src/index.ts | 13 ++-- 2 files changed, 32 insertions(+), 63 deletions(-) diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index ee7fa4765..ff6219587 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -3,102 +3,68 @@ import axios, { AxiosRequestConfig } from 'axios'; import http from 'http'; import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory'; import { IEsploraApi } from './esplora-api.interface'; -import logger from '../../logger'; const axiosConnection = axios.create({ httpAgent: new http.Agent({ keepAlive: true, }) }); class ElectrsApi implements AbstractBitcoinApi { - private axiosConfigWithUnixSocket: AxiosRequestConfig = config.ESPLORA.UNIX_SOCKET_PATH ? { + axiosConfig: AxiosRequestConfig = config.ESPLORA.UNIX_SOCKET_PATH ? { socketPath: config.ESPLORA.UNIX_SOCKET_PATH, timeout: 10000, } : { timeout: 10000, }; - private axiosConfigTcpSocketOnly: AxiosRequestConfig = { - timeout: 10000, - }; - unixSocketRetryTimeout; - activeAxiosConfig; - - constructor() { - this.activeAxiosConfig = this.axiosConfigWithUnixSocket; - } - - fallbackToTcpSocket() { - if (!this.unixSocketRetryTimeout) { - logger.err(`Unable to connect to esplora unix socket. Falling back to tcp socket. Retrying unix socket in ${config.ESPLORA.RETRY_UNIX_SOCKET_AFTER / 1000} seconds`); - // Retry the unix socket after a few seconds - this.unixSocketRetryTimeout = setTimeout(() => { - logger.info(`Retrying to use unix socket for esplora now (applied for the next query)`); - this.activeAxiosConfig = this.axiosConfigWithUnixSocket; - this.unixSocketRetryTimeout = undefined; - }, config.ESPLORA.RETRY_UNIX_SOCKET_AFTER); - } - - // Use the TCP socket (reach a different esplora instance through nginx) - this.activeAxiosConfig = this.axiosConfigTcpSocketOnly; - } - - $queryWrapper(url, responseType = 'json'): Promise { - return axiosConnection.get(url, { ...this.activeAxiosConfig, responseType: responseType }) - .then((response) => response.data) - .catch((e) => { - if (e?.code === 'ECONNREFUSED') { - this.fallbackToTcpSocket(); - // Retry immediately - return axiosConnection.get(url, this.activeAxiosConfig) - .then((response) => response.data) - .catch((e) => { - logger.warn(`Cannot query esplora through the unix socket nor the tcp socket. Exception ${e}`); - throw e; - }); - } else { - throw e; - } - }); - } + constructor() { } $getRawMempool(): Promise { - return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/mempool/txids'); + return axiosConnection.get(config.ESPLORA.REST_API_URL + '/mempool/txids', this.axiosConfig) + .then((response) => response.data); } $getRawTransaction(txId: string): Promise { - return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/tx/' + txId); + return axiosConnection.get(config.ESPLORA.REST_API_URL + '/tx/' + txId, this.axiosConfig) + .then((response) => response.data); } $getTransactionHex(txId: string): Promise { - return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex'); + return axiosConnection.get(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex', this.axiosConfig) + .then((response) => response.data); } $getBlockHeightTip(): Promise { - return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/blocks/tip/height'); + return axiosConnection.get(config.ESPLORA.REST_API_URL + '/blocks/tip/height', this.axiosConfig) + .then((response) => response.data); } $getBlockHashTip(): Promise { - return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/blocks/tip/hash'); + return axiosConnection.get(config.ESPLORA.REST_API_URL + '/blocks/tip/hash', this.axiosConfig) + .then((response) => response.data); } $getTxIdsForBlock(hash: string): Promise { - return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids'); + return axiosConnection.get(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids', this.axiosConfig) + .then((response) => response.data); } $getBlockHash(height: number): Promise { - return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/block-height/' + height); + return axiosConnection.get(config.ESPLORA.REST_API_URL + '/block-height/' + height, this.axiosConfig) + .then((response) => response.data); } $getBlockHeader(hash: string): Promise { - return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/block/' + hash + '/header'); + return axiosConnection.get(config.ESPLORA.REST_API_URL + '/block/' + hash + '/header', this.axiosConfig) + .then((response) => response.data); } $getBlock(hash: string): Promise { - return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/block/' + hash); + return axiosConnection.get(config.ESPLORA.REST_API_URL + '/block/' + hash, this.axiosConfig) + .then((response) => response.data); } $getRawBlock(hash: string): Promise { - return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", 'arraybuffer') + return axiosConnection.get(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", { ...this.axiosConfig, responseType: 'arraybuffer' }) .then((response) => { return Buffer.from(response.data); }); } @@ -119,11 +85,13 @@ class ElectrsApi implements AbstractBitcoinApi { } $getOutspend(txId: string, vout: number): Promise { - return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout); + return axiosConnection.get(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout, this.axiosConfig) + .then((response) => response.data); } $getOutspends(txId: string): Promise { - return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends'); + return axiosConnection.get(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends', this.axiosConfig) + .then((response) => response.data); } async $getBatchedOutspends(txId: string[]): Promise { diff --git a/backend/src/index.ts b/backend/src/index.ts index abaec9cef..a7f805313 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -45,8 +45,7 @@ class Server { private wss: WebSocket.Server | undefined; private server: http.Server | undefined; private app: Application; - private currentBackendRetryInterval = 1; - private backendRetryCount = 0; + private currentBackendRetryInterval = 5; private maxHeapSize: number = 0; private heapLogInterval: number = 60; @@ -185,17 +184,17 @@ class Server { indexer.$run(); setTimeout(this.runMainUpdateLoop.bind(this), config.MEMPOOL.POLL_RATE_MS); - this.backendRetryCount = 0; + this.currentBackendRetryInterval = 5; } catch (e: any) { - this.backendRetryCount++; - let loggerMsg = `Exception in runMainUpdateLoop() (count: ${this.backendRetryCount}). Retrying in ${this.currentBackendRetryInterval} sec.`; + let loggerMsg = `Exception in runMainUpdateLoop(). Retrying in ${this.currentBackendRetryInterval} sec.`; loggerMsg += ` Reason: ${(e instanceof Error ? e.message : e)}.`; if (e?.stack) { loggerMsg += ` Stack trace: ${e.stack}`; } // When we get a first Exception, only `logger.debug` it and retry after 5 seconds // From the second Exception, `logger.warn` the Exception and increase the retry delay - if (this.backendRetryCount >= 5) { + // Maximum retry delay is 60 seconds + if (this.currentBackendRetryInterval > 5) { logger.warn(loggerMsg); mempool.setOutOfSync(); } else { @@ -205,6 +204,8 @@ class Server { logger.debug(`AxiosError: ${e?.message}`); } setTimeout(this.runMainUpdateLoop.bind(this), 1000 * this.currentBackendRetryInterval); + this.currentBackendRetryInterval *= 2; + this.currentBackendRetryInterval = Math.min(this.currentBackendRetryInterval, 60); } } From 95df317f564f8709221b4cd2147c2b570dc18a17 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 27 Apr 2023 10:19:12 +0900 Subject: [PATCH 007/113] detect and log stall in main loop --- backend/src/api/blocks.ts | 54 ++++++++++++++++++++++++++++++++++++++ backend/src/api/mempool.ts | 34 ++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 15a218e24..e824efbf6 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -36,6 +36,8 @@ class Blocks { private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = []; private newAsyncBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => Promise)[] = []; + private mainLoopTimeout: number = 120000; + constructor() { } public getBlocks(): BlockExtended[] { @@ -528,8 +530,12 @@ class Blocks { } public async $updateBlocks() { + // warn if this run stalls the main loop for more than 2 minutes + const timer = this.startTimer(); + let fastForwarded = false; const blockHeightTip = await bitcoinApi.$getBlockHeightTip(); + this.updateTimerProgress(timer, 'got block height tip'); if (this.blocks.length === 0) { this.currentBlockHeight = Math.max(blockHeightTip - config.MEMPOOL.INITIAL_BLOCKS_AMOUNT, -1); @@ -547,16 +553,21 @@ class Blocks { if (!this.lastDifficultyAdjustmentTime) { const blockchainInfo = await bitcoinClient.getBlockchainInfo(); + this.updateTimerProgress(timer, 'got blockchain info for initial difficulty adjustment'); if (blockchainInfo.blocks === blockchainInfo.headers) { const heightDiff = blockHeightTip % 2016; const blockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff); + this.updateTimerProgress(timer, 'got block hash for initial difficulty adjustment'); const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash); + this.updateTimerProgress(timer, 'got block for initial difficulty adjustment'); this.lastDifficultyAdjustmentTime = block.timestamp; this.currentDifficulty = block.difficulty; if (blockHeightTip >= 2016) { const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016); + this.updateTimerProgress(timer, 'got previous block hash for initial difficulty adjustment'); const previousPeriodBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(previousPeriodBlockHash); + this.updateTimerProgress(timer, 'got previous block for initial difficulty adjustment'); this.previousDifficultyRetarget = (block.difficulty - previousPeriodBlock.difficulty) / previousPeriodBlock.difficulty * 100; logger.debug(`Initial difficulty adjustment data set.`); } @@ -571,9 +582,11 @@ class Blocks { } else { this.currentBlockHeight++; logger.debug(`New block found (#${this.currentBlockHeight})!`); + this.updateTimerProgress(timer, `getting orphaned blocks for ${this.currentBlockHeight}`); await chainTips.updateOrphanedBlocks(); } + this.updateTimerProgress(timer, `getting block data for ${this.currentBlockHeight}`); const blockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight); const verboseBlock = await bitcoinClient.getBlock(blockHash, 2); const block = BitcoinApi.convertBlock(verboseBlock); @@ -582,39 +595,51 @@ class Blocks { const cpfpSummary: CpfpSummary = Common.calculateCpfp(block.height, transactions); const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions); const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock); + this.updateTimerProgress(timer, `got block data for ${this.currentBlockHeight}`); // start async callbacks + this.updateTimerProgress(timer, `starting async callbacks for ${this.currentBlockHeight}`); const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, transactions)); if (Common.indexingEnabled()) { if (!fastForwarded) { const lastBlock = await blocksRepository.$getBlockByHeight(blockExtended.height - 1); + this.updateTimerProgress(timer, `got block by height for ${this.currentBlockHeight}`); if (lastBlock !== null && blockExtended.previousblockhash !== lastBlock.id) { logger.warn(`Chain divergence detected at block ${lastBlock.height}, re-indexing most recent data`, logger.tags.mining); // We assume there won't be a reorg with more than 10 block depth + this.updateTimerProgress(timer, `rolling back diverged chain from ${this.currentBlockHeight}`); await BlocksRepository.$deleteBlocksFrom(lastBlock.height - 10); await HashratesRepository.$deleteLastEntries(); await cpfpRepository.$deleteClustersFrom(lastBlock.height - 10); + this.updateTimerProgress(timer, `rolled back chain divergence from ${this.currentBlockHeight}`); for (let i = 10; i >= 0; --i) { const newBlock = await this.$indexBlock(lastBlock.height - i); + this.updateTimerProgress(timer, `reindexed block`); await this.$getStrippedBlockTransactions(newBlock.id, true, true); + this.updateTimerProgress(timer, `reindexed block summary`); if (config.MEMPOOL.CPFP_INDEXING) { await this.$indexCPFP(newBlock.id, lastBlock.height - i); + this.updateTimerProgress(timer, `reindexed block cpfp`); } } await mining.$indexDifficultyAdjustments(); await DifficultyAdjustmentsRepository.$deleteLastAdjustment(); + this.updateTimerProgress(timer, `reindexed difficulty adjustments`); logger.info(`Re-indexed 10 blocks and summaries. Also re-indexed the last difficulty adjustments. Will re-index latest hashrates in a few seconds.`, logger.tags.mining); indexer.reindex(); } await blocksRepository.$saveBlockInDatabase(blockExtended); + this.updateTimerProgress(timer, `saved ${this.currentBlockHeight} to database`); const lastestPriceId = await PricesRepository.$getLatestPriceId(); + this.updateTimerProgress(timer, `got latest price id ${this.currentBlockHeight}`); if (priceUpdater.historyInserted === true && lastestPriceId !== null) { await blocksRepository.$saveBlockPrices([{ height: blockExtended.height, priceId: lastestPriceId, }]); + this.updateTimerProgress(timer, `saved prices for ${this.currentBlockHeight}`); } else { logger.debug(`Cannot save block price for ${blockExtended.height} because the price updater hasnt completed yet. Trying again in 10 seconds.`, logger.tags.mining); setTimeout(() => { @@ -625,9 +650,11 @@ class Blocks { // Save blocks summary for visualization if it's enabled if (Common.blocksSummariesIndexingEnabled() === true) { await this.$getStrippedBlockTransactions(blockExtended.id, true); + this.updateTimerProgress(timer, `saved block summary for ${this.currentBlockHeight}`); } if (config.MEMPOOL.CPFP_INDEXING) { this.$saveCpfp(blockExtended.id, this.currentBlockHeight, cpfpSummary); + this.updateTimerProgress(timer, `saved cpfp for ${this.currentBlockHeight}`); } } } @@ -640,6 +667,7 @@ class Blocks { difficulty: block.difficulty, adjustment: Math.round((block.difficulty / this.currentDifficulty) * 1000000) / 1000000, // Remove float point noise }); + this.updateTimerProgress(timer, `saved difficulty adjustment for ${this.currentBlockHeight}`); } this.previousDifficultyRetarget = (block.difficulty - this.currentDifficulty) / this.currentDifficulty * 100; @@ -664,7 +692,33 @@ class Blocks { } // 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.clearTimer(timer); + } + + private startTimer() { + const state: any = { + start: Date.now(), + progress: 'begin $updateBlocks', + timer: null, + }; + state.timer = setTimeout(() => { + logger.err(`$updateBlocks stalled at "${state.progress}`); + }, this.mainLoopTimeout); + return state; + } + + private updateTimerProgress(state, msg) { + state.progress = msg; + } + + private clearTimer(state) { + if (state.timer) { + clearTimeout(state.timer); } } diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 1be1faceb..2268208f2 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -36,6 +36,8 @@ class Mempool { private timer = new Date().getTime(); private missingTxCount = 0; + private mainLoopTimeout: number = 120000; + constructor() { setInterval(this.updateTxPerSecond.bind(this), 1000); } @@ -119,10 +121,15 @@ class Mempool { public async $updateMempool(): Promise { logger.debug(`Updating mempool...`); + + // warn if this run stalls the main loop for more than 2 minutes + const timer = this.startTimer(); + const start = new Date().getTime(); let hasChange: boolean = false; const currentMempoolSize = Object.keys(this.mempoolCache).length; const transactions = await bitcoinApi.$getRawMempool(); + this.updateTimerProgress(timer, 'got raw mempool'); const diff = transactions.length - currentMempoolSize; const newTransactions: TransactionExtended[] = []; @@ -146,6 +153,7 @@ class Mempool { if (!this.mempoolCache[txid]) { try { const transaction = await transactionUtils.$getTransactionExtended(txid); + this.updateTimerProgress(timer, 'fetched new transaction'); this.mempoolCache[txid] = transaction; if (this.inSync) { this.txPerSecondArray.push(new Date().getTime()); @@ -223,12 +231,38 @@ class Mempool { this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions); } if (this.asyncMempoolChangedCallback && (hasChange || deletedTransactions.length)) { + this.updateTimerProgress(timer, 'running async mempool callback'); await this.asyncMempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions); + this.updateTimerProgress(timer, 'completed async mempool callback'); } const end = new Date().getTime(); const time = end - start; logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`); + + this.clearTimer(timer); + } + + private startTimer() { + const state: any = { + start: Date.now(), + progress: 'begin $updateMempool', + timer: null, + }; + state.timer = setTimeout(() => { + logger.err(`$updateMempool stalled at "${state.progress}`); + }, this.mainLoopTimeout); + return state; + } + + private updateTimerProgress(state, msg) { + state.progress = msg; + } + + private clearTimer(state) { + if (state.timer) { + clearTimeout(state.timer); + } } public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) { From e05f2198d584b5a76bcb2a4be2939562b5bf4b43 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 28 Apr 2023 19:05:49 -0600 Subject: [PATCH 008/113] Add explicit timeout to mysql DB queries --- backend/mempool-config.sample.json | 3 +- .../__fixtures__/mempool-config.template.json | 3 +- backend/src/__tests__/config.test.ts | 3 +- backend/src/config.ts | 4 ++- backend/src/database.ts | 28 +++++++++++++++++-- docker/README.md | 1 + docker/backend/mempool-config.json | 3 +- docker/backend/start.sh | 1 + 8 files changed, 39 insertions(+), 7 deletions(-) diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json index 1c7097de4..32becd00d 100644 --- a/backend/mempool-config.sample.json +++ b/backend/mempool-config.sample.json @@ -61,7 +61,8 @@ "SOCKET": "/var/run/mysql/mysql.sock", "DATABASE": "mempool", "USERNAME": "mempool", - "PASSWORD": "mempool" + "PASSWORD": "mempool", + "TIMEOUT": 180000 }, "SYSLOG": { "ENABLED": true, diff --git a/backend/src/__fixtures__/mempool-config.template.json b/backend/src/__fixtures__/mempool-config.template.json index 6c2269a4f..eb082d89f 100644 --- a/backend/src/__fixtures__/mempool-config.template.json +++ b/backend/src/__fixtures__/mempool-config.template.json @@ -62,7 +62,8 @@ "PORT": 18, "DATABASE": "__DATABASE_DATABASE__", "USERNAME": "__DATABASE_USERNAME__", - "PASSWORD": "__DATABASE_PASSWORD__" + "PASSWORD": "__DATABASE_PASSWORD__", + "TIMEOUT": "__DATABASE_TIMEOUT__" }, "SYSLOG": { "ENABLED": false, diff --git a/backend/src/__tests__/config.test.ts b/backend/src/__tests__/config.test.ts index e12f90a86..aa287308b 100644 --- a/backend/src/__tests__/config.test.ts +++ b/backend/src/__tests__/config.test.ts @@ -72,7 +72,8 @@ describe('Mempool Backend Config', () => { PORT: 3306, DATABASE: 'mempool', USERNAME: 'mempool', - PASSWORD: 'mempool' + PASSWORD: 'mempool', + TIMEOUT: 180000, }); expect(config.SYSLOG).toStrictEqual({ diff --git a/backend/src/config.ts b/backend/src/config.ts index 7c0e4e950..ff5ea4f9f 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -86,6 +86,7 @@ interface IConfig { DATABASE: string; USERNAME: string; PASSWORD: string; + TIMEOUT: number; }; SYSLOG: { ENABLED: boolean; @@ -194,7 +195,8 @@ const defaults: IConfig = { 'PORT': 3306, 'DATABASE': 'mempool', 'USERNAME': 'mempool', - 'PASSWORD': 'mempool' + 'PASSWORD': 'mempool', + 'TIMEOUT': 180000, }, 'SYSLOG': { 'ENABLED': true, diff --git a/backend/src/database.ts b/backend/src/database.ts index a504eb0fa..070774c92 100644 --- a/backend/src/database.ts +++ b/backend/src/database.ts @@ -33,8 +33,32 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr OkPacket[] | ResultSetHeader>(query, params?): Promise<[T, FieldPacket[]]> { this.checkDBFlag(); - const pool = await this.getPool(); - return pool.query(query, params); + let hardTimeout; + if (query?.timeout != null) { + hardTimeout = Math.floor(query.timeout * 1.1); + } else { + hardTimeout = config.DATABASE.TIMEOUT; + } + if (hardTimeout > 0) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`DB query failed to return, reject or time out within ${hardTimeout / 1000}s - ${query?.sql?.slice(0, 160) || (typeof(query) === 'string' || query instanceof String ? query?.slice(0, 160) : 'unknown query')}`)); + }, hardTimeout); + + this.getPool().then(pool => { + return pool.query(query, params) as Promise<[T, FieldPacket[]]>; + }).then(result => { + resolve(result); + }).catch(error => { + reject(error); + }).finally(() => { + clearTimeout(timer); + }); + }); + } else { + const pool = await this.getPool(); + return pool.query(query, params); + } } public async checkDbConnection() { diff --git a/docker/README.md b/docker/README.md index ee5ba11c5..b669b37c8 100644 --- a/docker/README.md +++ b/docker/README.md @@ -269,6 +269,7 @@ Corresponding `docker-compose.yml` overrides: DATABASE_DATABASE: "" DATABASE_USERNAME: "" DATABASE_PASSWORD: "" + DATABASE_TIMEOUT: "" ... ``` diff --git a/docker/backend/mempool-config.json b/docker/backend/mempool-config.json index f4543bd2e..fd8abaf02 100644 --- a/docker/backend/mempool-config.json +++ b/docker/backend/mempool-config.json @@ -60,7 +60,8 @@ "PORT": __DATABASE_PORT__, "DATABASE": "__DATABASE_DATABASE__", "USERNAME": "__DATABASE_USERNAME__", - "PASSWORD": "__DATABASE_PASSWORD__" + "PASSWORD": "__DATABASE_PASSWORD__", + "TIMEOUT": "__DATABASE_TIMEOUT__" }, "SYSLOG": { "ENABLED": __SYSLOG_ENABLED__, diff --git a/docker/backend/start.sh b/docker/backend/start.sh index c6ce8f1e7..a54f16ec6 100755 --- a/docker/backend/start.sh +++ b/docker/backend/start.sh @@ -64,6 +64,7 @@ __DATABASE_PORT__=${DATABASE_PORT:=3306} __DATABASE_DATABASE__=${DATABASE_DATABASE:=mempool} __DATABASE_USERNAME__=${DATABASE_USERNAME:=mempool} __DATABASE_PASSWORD__=${DATABASE_PASSWORD:=mempool} +__DATABASE_TIMEOUT__=${DATABASE_TIMEOUT:=180000} # SYSLOG __SYSLOG_ENABLED__=${SYSLOG_ENABLED:=false} From 58b08f2c333e753e6d13d4e7f8b3868e03c0b93b Mon Sep 17 00:00:00 2001 From: softsimon Date: Mon, 1 May 2023 00:16:23 +0400 Subject: [PATCH 009/113] Add end quotes --- backend/src/api/blocks.ts | 2 +- backend/src/api/mempool.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index e824efbf6..2837d40a0 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -707,7 +707,7 @@ class Blocks { timer: null, }; state.timer = setTimeout(() => { - logger.err(`$updateBlocks stalled at "${state.progress}`); + logger.err(`$updateBlocks stalled at "${state.progress}"`); }, this.mainLoopTimeout); return state; } diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 2268208f2..79a2001de 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -250,7 +250,7 @@ class Mempool { timer: null, }; state.timer = setTimeout(() => { - logger.err(`$updateMempool stalled at "${state.progress}`); + logger.err(`$updateMempool stalled at "${state.progress}"`); }, this.mainLoopTimeout); return state; } From f30cf70226097f176c1b074e0a1d8d897269eac9 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sun, 30 Apr 2023 13:59:29 -0600 Subject: [PATCH 010/113] await for mempool change handler after loading disk cache --- backend/src/api/disk-cache.ts | 4 ++-- backend/src/api/mempool-blocks.ts | 2 +- backend/src/api/mempool.ts | 4 ++-- backend/src/index.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/src/api/disk-cache.ts b/backend/src/api/disk-cache.ts index c50d3cef8..8d5745a3b 100644 --- a/backend/src/api/disk-cache.ts +++ b/backend/src/api/disk-cache.ts @@ -124,7 +124,7 @@ class DiskCache { } } - loadMempoolCache(): void { + async loadMempoolCache(): Promise { if (!fs.existsSync(DiskCache.FILE_NAME)) { return; } @@ -168,7 +168,7 @@ class DiskCache { } } - memPool.setMempool(data.mempool); + await memPool.setMempool(data.mempool); blocks.setBlocks(data.blocks); blocks.setBlockSummaries(data.blockSummaries || []); } catch (e) { diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index aa2804379..cd6243bc1 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -205,7 +205,7 @@ class MempoolBlocks { public async updateBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, added: TransactionExtended[], removed: string[], saveResults: boolean = false): Promise { if (!this.txSelectionWorker) { // need to reset the worker - this.makeBlockTemplates(newMempool, saveResults); + await this.makeBlockTemplates(newMempool, saveResults); return; } // prepare a stripped down version of the mempool with only the minimum necessary data diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 79a2001de..4f5a12962 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -80,13 +80,13 @@ class Mempool { return this.mempoolCache; } - public setMempool(mempoolData: { [txId: string]: TransactionExtended }) { + public async setMempool(mempoolData: { [txId: string]: TransactionExtended }) { this.mempoolCache = mempoolData; if (this.mempoolChangedCallback) { this.mempoolChangedCallback(this.mempoolCache, [], []); } if (this.asyncMempoolChangedCallback) { - this.asyncMempoolChangedCallback(this.mempoolCache, [], []); + await this.asyncMempoolChangedCallback(this.mempoolCache, [], []); } } diff --git a/backend/src/index.ts b/backend/src/index.ts index a7f805313..440a24de9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -120,7 +120,7 @@ class Server { await poolsUpdater.updatePoolsJson(); // Needs to be done before loading the disk cache because we sometimes wipe it await syncAssets.syncAssets$(); if (config.MEMPOOL.ENABLED) { - diskCache.loadMempoolCache(); + await diskCache.loadMempoolCache(); } if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && cluster.isPrimary) { From 4597bfa5d79b30c919856ee28968a6accdfde6f7 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sun, 30 Apr 2023 15:28:34 -0600 Subject: [PATCH 011/113] use $ naming convention for async function names --- backend/src/api/disk-cache.ts | 4 ++-- backend/src/api/mempool-blocks.ts | 6 +++--- backend/src/api/mempool.ts | 14 +++++++------- backend/src/api/websocket-handler.ts | 8 ++++---- backend/src/index.ts | 4 ++-- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/backend/src/api/disk-cache.ts b/backend/src/api/disk-cache.ts index 8d5745a3b..f053180b0 100644 --- a/backend/src/api/disk-cache.ts +++ b/backend/src/api/disk-cache.ts @@ -124,7 +124,7 @@ class DiskCache { } } - async loadMempoolCache(): Promise { + async $loadMempoolCache(): Promise { if (!fs.existsSync(DiskCache.FILE_NAME)) { return; } @@ -168,7 +168,7 @@ class DiskCache { } } - await memPool.setMempool(data.mempool); + await memPool.$setMempool(data.mempool); blocks.setBlocks(data.blocks); blocks.setBlockSummaries(data.blockSummaries || []); } catch (e) { diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index cd6243bc1..681271450 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -144,7 +144,7 @@ class MempoolBlocks { return mempoolBlockDeltas; } - public async makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): Promise { + public async $makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): Promise { // prepare a stripped down version of the mempool with only the minimum necessary data // to reduce the overhead of passing this data to the worker thread const strippedMempool: { [txid: string]: ThreadTransaction } = {}; @@ -202,10 +202,10 @@ class MempoolBlocks { return this.mempoolBlocks; } - public async updateBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, added: TransactionExtended[], removed: string[], saveResults: boolean = false): Promise { + public async $updateBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, added: TransactionExtended[], removed: string[], saveResults: boolean = false): Promise { if (!this.txSelectionWorker) { // need to reset the worker - await this.makeBlockTemplates(newMempool, saveResults); + await this.$makeBlockTemplates(newMempool, saveResults); return; } // prepare a stripped down version of the mempool with only the minimum necessary data diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 4f5a12962..0d593f1a3 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -20,7 +20,7 @@ class Mempool { maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 }; private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => void) | undefined; - private asyncMempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[], + private $asyncMempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => Promise) | undefined; private txPerSecondArray: number[] = []; @@ -73,20 +73,20 @@ class Mempool { public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => Promise) { - this.asyncMempoolChangedCallback = fn; + this.$asyncMempoolChangedCallback = fn; } public getMempool(): { [txid: string]: TransactionExtended } { return this.mempoolCache; } - public async setMempool(mempoolData: { [txId: string]: TransactionExtended }) { + public async $setMempool(mempoolData: { [txId: string]: TransactionExtended }) { this.mempoolCache = mempoolData; if (this.mempoolChangedCallback) { this.mempoolChangedCallback(this.mempoolCache, [], []); } - if (this.asyncMempoolChangedCallback) { - await this.asyncMempoolChangedCallback(this.mempoolCache, [], []); + if (this.$asyncMempoolChangedCallback) { + await this.$asyncMempoolChangedCallback(this.mempoolCache, [], []); } } @@ -230,9 +230,9 @@ class Mempool { if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) { this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions); } - if (this.asyncMempoolChangedCallback && (hasChange || deletedTransactions.length)) { + if (this.$asyncMempoolChangedCallback && (hasChange || deletedTransactions.length)) { this.updateTimerProgress(timer, 'running async mempool callback'); - await this.asyncMempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions); + await this.$asyncMempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions); this.updateTimerProgress(timer, 'completed async mempool callback'); } diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 7dbd48c46..f2e721381 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -247,14 +247,14 @@ class WebsocketHandler { }); } - async handleMempoolChange(newMempool: { [txid: string]: TransactionExtended }, + async $handleMempoolChange(newMempool: { [txid: string]: TransactionExtended }, newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]): Promise { if (!this.wss) { throw new Error('WebSocket.Server is not set'); } if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) { - await mempoolBlocks.updateBlockTemplates(newMempool, newTransactions, deletedTransactions.map(tx => tx.txid), true); + await mempoolBlocks.$updateBlockTemplates(newMempool, newTransactions, deletedTransactions.map(tx => tx.txid), true); } else { mempoolBlocks.updateMempoolBlocks(newMempool, true); } @@ -429,7 +429,7 @@ class WebsocketHandler { // a cloned copy of the mempool if we're running a different algorithm for mempool updates const auditMempool = (config.MEMPOOL.ADVANCED_GBT_AUDIT === config.MEMPOOL.ADVANCED_GBT_MEMPOOL) ? _memPool : deepClone(_memPool); if (config.MEMPOOL.ADVANCED_GBT_AUDIT) { - projectedBlocks = await mempoolBlocks.makeBlockTemplates(auditMempool, false); + projectedBlocks = await mempoolBlocks.$makeBlockTemplates(auditMempool, false); } else { projectedBlocks = mempoolBlocks.updateMempoolBlocks(auditMempool, false); } @@ -486,7 +486,7 @@ class WebsocketHandler { } if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) { - await mempoolBlocks.updateBlockTemplates(_memPool, [], removed, true); + await mempoolBlocks.$updateBlockTemplates(_memPool, [], removed, true); } else { mempoolBlocks.updateMempoolBlocks(_memPool, true); } diff --git a/backend/src/index.ts b/backend/src/index.ts index 440a24de9..feddd30c3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -120,7 +120,7 @@ class Server { await poolsUpdater.updatePoolsJson(); // Needs to be done before loading the disk cache because we sometimes wipe it await syncAssets.syncAssets$(); if (config.MEMPOOL.ENABLED) { - await diskCache.loadMempoolCache(); + await diskCache.$loadMempoolCache(); } if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && cluster.isPrimary) { @@ -238,7 +238,7 @@ class Server { websocketHandler.setupConnectionHandling(); if (config.MEMPOOL.ENABLED) { statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler)); - memPool.setAsyncMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler)); + memPool.setAsyncMempoolChangedCallback(websocketHandler.$handleMempoolChange.bind(websocketHandler)); blocks.setNewAsyncBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler)); } priceUpdater.setRatesChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler)); From 3748102bb03711eccf8b2ec1ddb9c608c555c265 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 1 May 2023 13:08:29 -0600 Subject: [PATCH 012/113] Log websocket statistics --- backend/src/api/websocket-handler.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index f2e721381..25275f71c 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -26,6 +26,10 @@ class WebsocketHandler { private wss: WebSocket.Server | undefined; private extraInitProperties = {}; + private numClients = 0; + private numConnected = 0; + private numDisconnected = 0; + constructor() { } setWebsocketServer(wss: WebSocket.Server) { @@ -42,7 +46,11 @@ class WebsocketHandler { } this.wss.on('connection', (client: WebSocket) => { + this.numConnected++; client.on('error', logger.info); + client.on('close', () => { + this.numDisconnected++; + }); client.on('message', async (message: string) => { try { const parsedMessage: WebsocketResponse = JSON.parse(message); @@ -232,6 +240,8 @@ class WebsocketHandler { throw new Error('WebSocket.Server is not set'); } + this.printLogs(); + this.wss.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; @@ -253,6 +263,8 @@ class WebsocketHandler { throw new Error('WebSocket.Server is not set'); } + this.printLogs(); + if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) { await mempoolBlocks.$updateBlockTemplates(newMempool, newTransactions, deletedTransactions.map(tx => tx.txid), true); } else { @@ -421,6 +433,8 @@ class WebsocketHandler { throw new Error('WebSocket.Server is not set'); } + this.printLogs(); + const _memPool = memPool.getMempool(); if (config.MEMPOOL.AUDIT) { @@ -597,6 +611,17 @@ class WebsocketHandler { client.send(JSON.stringify(response)); }); } + + private printLogs(): void { + if (this.wss) { + const count = this.wss?.clients?.size || 0; + const diff = count - this.numClients; + this.numClients = count; + logger.debug(`${count} websocket clients | ${this.numConnected} connected | ${this.numDisconnected} disconnected | (${diff >= 0 ? '+' : ''}${diff})`); + this.numConnected = 0; + this.numDisconnected = 0; + } + } } export default new WebsocketHandler(); From 3691ba824262b85790530902d0c8958989387f8e Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 1 May 2023 18:01:07 -0600 Subject: [PATCH 013/113] Increase client websocket timeout --- frontend/src/app/services/websocket.service.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index d58ab58c9..4e87d4999 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -9,8 +9,8 @@ import { take } from 'rxjs/operators'; import { TransferState, makeStateKey } from '@angular/platform-browser'; import { BlockExtended } from '../interfaces/node-api.interface'; -const OFFLINE_RETRY_AFTER_MS = 1000; -const OFFLINE_PING_CHECK_AFTER_MS = 10000; +const OFFLINE_RETRY_AFTER_MS = 2000; +const OFFLINE_PING_CHECK_AFTER_MS = 30000; const EXPECT_PING_RESPONSE_AFTER_MS = 5000; const initData = makeStateKey('/api/v1/init-data'); @@ -118,7 +118,7 @@ export class WebsocketService { }, (err: Error) => { console.log(err); - console.log(`WebSocket error, trying to reconnect in ${OFFLINE_RETRY_AFTER_MS} seconds`); + console.log(`WebSocket error`); this.goOffline(); }); } @@ -197,11 +197,13 @@ export class WebsocketService { } goOffline() { + const retryDelay = OFFLINE_RETRY_AFTER_MS + (Math.random() * OFFLINE_RETRY_AFTER_MS); + console.log(`trying to reconnect websocket in ${retryDelay} seconds`); this.goneOffline = true; this.stateService.connectionState$.next(0); window.setTimeout(() => { this.startSubscription(true); - }, OFFLINE_RETRY_AFTER_MS); + }, retryDelay); } startOnlineCheck() { @@ -212,7 +214,7 @@ export class WebsocketService { this.websocketSubject.next({action: 'ping'}); this.onlineCheckTimeoutTwo = window.setTimeout(() => { if (!this.goneOffline) { - console.log('WebSocket response timeout, force closing, trying to reconnect in 10 seconds'); + console.log('WebSocket response timeout, force closing'); this.websocketSubject.complete(); this.subscription.unsubscribe(); this.goOffline(); From c659adb4bed635e433bd483fa7c73be4384b727c Mon Sep 17 00:00:00 2001 From: softsimon Date: Tue, 2 May 2023 15:40:16 +0400 Subject: [PATCH 014/113] Removing dead code causing slowdown --- backend/src/api/audit.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/backend/src/api/audit.ts b/backend/src/api/audit.ts index 6e1cb3787..7435e3b99 100644 --- a/backend/src/api/audit.ts +++ b/backend/src/api/audit.ts @@ -93,17 +93,7 @@ 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; From 565aa9616bef2fa31e0c952d93d2c264e077621b Mon Sep 17 00:00:00 2001 From: softsimon Date: Tue, 2 May 2023 17:39:02 +0400 Subject: [PATCH 015/113] Change forensic logging to debug --- backend/src/tasks/lightning/forensics.service.ts | 4 ++-- backend/src/tasks/lightning/network-sync.service.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/tasks/lightning/forensics.service.ts b/backend/src/tasks/lightning/forensics.service.ts index 7837cb4d5..65ea61dc1 100644 --- a/backend/src/tasks/lightning/forensics.service.ts +++ b/backend/src/tasks/lightning/forensics.service.ts @@ -152,7 +152,7 @@ class ForensicsService { ++progress; const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); if (elapsedSeconds > 10) { - logger.info(`Updating channel closed channel forensics ${progress}/${channels.length}`); + logger.debug(`Updating channel closed channel forensics ${progress}/${channels.length}`); this.loggerTimer = new Date().getTime() / 1000; } } @@ -257,7 +257,7 @@ class ForensicsService { ++progress; const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); if (elapsedSeconds > 10) { - logger.info(`Updating opened channel forensics ${progress}/${channels?.length}`); + logger.debug(`Updating opened channel forensics ${progress}/${channels?.length}`); this.loggerTimer = new Date().getTime() / 1000; this.truncateTempCache(); } diff --git a/backend/src/tasks/lightning/network-sync.service.ts b/backend/src/tasks/lightning/network-sync.service.ts index 28f60bbf9..aca3dbef8 100644 --- a/backend/src/tasks/lightning/network-sync.service.ts +++ b/backend/src/tasks/lightning/network-sync.service.ts @@ -300,7 +300,7 @@ class NetworkSyncService { ++progress; const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) { - logger.info(`Checking if channel has been closed ${progress}/${channels.length}`, logger.tags.ln); + logger.debug(`Checking if channel has been closed ${progress}/${channels.length}`, logger.tags.ln); this.loggerTimer = new Date().getTime() / 1000; } } From 03ee5c7c31d111b0987aaecacafd4426edaf2ef5 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 2 May 2023 16:57:10 -0600 Subject: [PATCH 016/113] skip unnecessary makeBlockTemplates --- backend/src/api/websocket-handler.ts | 18 +++++++++++------- backend/src/index.ts | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 25275f71c..865dfe9d6 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -439,13 +439,19 @@ class WebsocketHandler { if (config.MEMPOOL.AUDIT) { let projectedBlocks; + let auditMempool = _memPool; // template calculation functions have mempool side effects, so calculate audits using // a cloned copy of the mempool if we're running a different algorithm for mempool updates - const auditMempool = (config.MEMPOOL.ADVANCED_GBT_AUDIT === config.MEMPOOL.ADVANCED_GBT_MEMPOOL) ? _memPool : deepClone(_memPool); - if (config.MEMPOOL.ADVANCED_GBT_AUDIT) { - projectedBlocks = await mempoolBlocks.$makeBlockTemplates(auditMempool, false); + const separateAudit = config.MEMPOOL.ADVANCED_GBT_AUDIT !== config.MEMPOOL.ADVANCED_GBT_MEMPOOL; + if (separateAudit) { + auditMempool = deepClone(_memPool); + if (config.MEMPOOL.ADVANCED_GBT_AUDIT) { + projectedBlocks = await mempoolBlocks.$makeBlockTemplates(auditMempool, false); + } else { + projectedBlocks = mempoolBlocks.updateMempoolBlocks(auditMempool, false); + } } else { - projectedBlocks = mempoolBlocks.updateMempoolBlocks(auditMempool, false); + projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions(); } if (Common.indexingEnabled() && memPool.isInSync()) { @@ -491,16 +497,14 @@ class WebsocketHandler { } } - const removed: string[] = []; // Update mempool to remove transactions included in the new block for (const txId of txIds) { delete _memPool[txId]; - removed.push(txId); rbfCache.evict(txId); } if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) { - await mempoolBlocks.$updateBlockTemplates(_memPool, [], removed, true); + await mempoolBlocks.$makeBlockTemplates(_memPool, true); } else { mempoolBlocks.updateMempoolBlocks(_memPool, true); } diff --git a/backend/src/index.ts b/backend/src/index.ts index feddd30c3..1ec60c397 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -178,8 +178,8 @@ class Server { logger.debug(msg); } } - memPool.deleteExpiredTransactions(); await blocks.$updateBlocks(); + memPool.deleteExpiredTransactions(); await memPool.$updateMempool(); indexer.$run(); From dd68572603fc245e06c850fac8766ed0e0c2423b Mon Sep 17 00:00:00 2001 From: softsimon Date: Wed, 3 May 2023 10:11:44 +0400 Subject: [PATCH 017/113] Revert "Revert TCP socket fallback" --- backend/src/api/bitcoin/esplora-api.ts | 82 ++++++++++++++++++-------- backend/src/index.ts | 13 ++-- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index ff6219587..ee7fa4765 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -3,68 +3,102 @@ import axios, { AxiosRequestConfig } from 'axios'; import http from 'http'; import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory'; import { IEsploraApi } from './esplora-api.interface'; +import logger from '../../logger'; const axiosConnection = axios.create({ httpAgent: new http.Agent({ keepAlive: true, }) }); class ElectrsApi implements AbstractBitcoinApi { - axiosConfig: AxiosRequestConfig = config.ESPLORA.UNIX_SOCKET_PATH ? { + private axiosConfigWithUnixSocket: AxiosRequestConfig = config.ESPLORA.UNIX_SOCKET_PATH ? { socketPath: config.ESPLORA.UNIX_SOCKET_PATH, timeout: 10000, } : { timeout: 10000, }; + private axiosConfigTcpSocketOnly: AxiosRequestConfig = { + timeout: 10000, + }; - constructor() { } + unixSocketRetryTimeout; + activeAxiosConfig; + + constructor() { + this.activeAxiosConfig = this.axiosConfigWithUnixSocket; + } + + fallbackToTcpSocket() { + if (!this.unixSocketRetryTimeout) { + logger.err(`Unable to connect to esplora unix socket. Falling back to tcp socket. Retrying unix socket in ${config.ESPLORA.RETRY_UNIX_SOCKET_AFTER / 1000} seconds`); + // Retry the unix socket after a few seconds + this.unixSocketRetryTimeout = setTimeout(() => { + logger.info(`Retrying to use unix socket for esplora now (applied for the next query)`); + this.activeAxiosConfig = this.axiosConfigWithUnixSocket; + this.unixSocketRetryTimeout = undefined; + }, config.ESPLORA.RETRY_UNIX_SOCKET_AFTER); + } + + // Use the TCP socket (reach a different esplora instance through nginx) + this.activeAxiosConfig = this.axiosConfigTcpSocketOnly; + } + + $queryWrapper(url, responseType = 'json'): Promise { + return axiosConnection.get(url, { ...this.activeAxiosConfig, responseType: responseType }) + .then((response) => response.data) + .catch((e) => { + if (e?.code === 'ECONNREFUSED') { + this.fallbackToTcpSocket(); + // Retry immediately + return axiosConnection.get(url, this.activeAxiosConfig) + .then((response) => response.data) + .catch((e) => { + logger.warn(`Cannot query esplora through the unix socket nor the tcp socket. Exception ${e}`); + throw e; + }); + } else { + throw e; + } + }); + } $getRawMempool(): Promise { - return axiosConnection.get(config.ESPLORA.REST_API_URL + '/mempool/txids', this.axiosConfig) - .then((response) => response.data); + return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/mempool/txids'); } $getRawTransaction(txId: string): Promise { - return axiosConnection.get(config.ESPLORA.REST_API_URL + '/tx/' + txId, this.axiosConfig) - .then((response) => response.data); + return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/tx/' + txId); } $getTransactionHex(txId: string): Promise { - return axiosConnection.get(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex', this.axiosConfig) - .then((response) => response.data); + return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex'); } $getBlockHeightTip(): Promise { - return axiosConnection.get(config.ESPLORA.REST_API_URL + '/blocks/tip/height', this.axiosConfig) - .then((response) => response.data); + return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/blocks/tip/height'); } $getBlockHashTip(): Promise { - return axiosConnection.get(config.ESPLORA.REST_API_URL + '/blocks/tip/hash', this.axiosConfig) - .then((response) => response.data); + return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/blocks/tip/hash'); } $getTxIdsForBlock(hash: string): Promise { - return axiosConnection.get(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids', this.axiosConfig) - .then((response) => response.data); + return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids'); } $getBlockHash(height: number): Promise { - return axiosConnection.get(config.ESPLORA.REST_API_URL + '/block-height/' + height, this.axiosConfig) - .then((response) => response.data); + return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/block-height/' + height); } $getBlockHeader(hash: string): Promise { - return axiosConnection.get(config.ESPLORA.REST_API_URL + '/block/' + hash + '/header', this.axiosConfig) - .then((response) => response.data); + return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/block/' + hash + '/header'); } $getBlock(hash: string): Promise { - return axiosConnection.get(config.ESPLORA.REST_API_URL + '/block/' + hash, this.axiosConfig) - .then((response) => response.data); + return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/block/' + hash); } $getRawBlock(hash: string): Promise { - return axiosConnection.get(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", { ...this.axiosConfig, responseType: 'arraybuffer' }) + return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", 'arraybuffer') .then((response) => { return Buffer.from(response.data); }); } @@ -85,13 +119,11 @@ class ElectrsApi implements AbstractBitcoinApi { } $getOutspend(txId: string, vout: number): Promise { - return axiosConnection.get(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout, this.axiosConfig) - .then((response) => response.data); + return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout); } $getOutspends(txId: string): Promise { - return axiosConnection.get(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends', this.axiosConfig) - .then((response) => response.data); + return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends'); } async $getBatchedOutspends(txId: string[]): Promise { diff --git a/backend/src/index.ts b/backend/src/index.ts index feddd30c3..5225426b5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -45,7 +45,8 @@ class Server { private wss: WebSocket.Server | undefined; private server: http.Server | undefined; private app: Application; - private currentBackendRetryInterval = 5; + private currentBackendRetryInterval = 1; + private backendRetryCount = 0; private maxHeapSize: number = 0; private heapLogInterval: number = 60; @@ -184,17 +185,17 @@ class Server { indexer.$run(); setTimeout(this.runMainUpdateLoop.bind(this), config.MEMPOOL.POLL_RATE_MS); - this.currentBackendRetryInterval = 5; + this.backendRetryCount = 0; } catch (e: any) { - let loggerMsg = `Exception in runMainUpdateLoop(). Retrying in ${this.currentBackendRetryInterval} sec.`; + this.backendRetryCount++; + let loggerMsg = `Exception in runMainUpdateLoop() (count: ${this.backendRetryCount}). Retrying in ${this.currentBackendRetryInterval} sec.`; loggerMsg += ` Reason: ${(e instanceof Error ? e.message : e)}.`; if (e?.stack) { loggerMsg += ` Stack trace: ${e.stack}`; } // When we get a first Exception, only `logger.debug` it and retry after 5 seconds // From the second Exception, `logger.warn` the Exception and increase the retry delay - // Maximum retry delay is 60 seconds - if (this.currentBackendRetryInterval > 5) { + if (this.backendRetryCount >= 5) { logger.warn(loggerMsg); mempool.setOutOfSync(); } else { @@ -204,8 +205,6 @@ class Server { logger.debug(`AxiosError: ${e?.message}`); } setTimeout(this.runMainUpdateLoop.bind(this), 1000 * this.currentBackendRetryInterval); - this.currentBackendRetryInterval *= 2; - this.currentBackendRetryInterval = Math.min(this.currentBackendRetryInterval, 60); } } From 3f49944c05f898a91721823f9ffaf3fd8f5ae582 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 3 May 2023 10:02:03 -0600 Subject: [PATCH 018/113] Fix transaction ETA calculation --- .../src/app/components/transaction/transaction.component.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 5f23633bf..4c2dbccb0 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -416,13 +416,15 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { const txFeePerVSize = this.tx.effectiveFeePerVsize || this.tx.fee / (this.tx.weight / 4); + let found = false; for (const block of mempoolBlocks) { - for (let i = 0; i < block.feeRange.length - 1; i++) { + for (let i = 0; i < block.feeRange.length - 1 && !found; i++) { if ( txFeePerVSize <= block.feeRange[i + 1] && txFeePerVSize >= block.feeRange[i] ) { this.txInBlockIndex = mempoolBlocks.indexOf(block); + found = true; } } } From 1b843da785d58073d6d111c2484997472af30d53 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 13 Dec 2022 17:11:37 -0600 Subject: [PATCH 019/113] Timeline of replacements for RBF-d transactions --- backend/src/api/bitcoin/bitcoin.routes.ts | 10 +- backend/src/api/mempool.ts | 2 +- backend/src/api/rbf-cache.ts | 58 ++++++-- .../rbf-timeline/rbf-timeline.component.html | 35 +++++ .../rbf-timeline/rbf-timeline.component.scss | 137 ++++++++++++++++++ .../rbf-timeline/rbf-timeline.component.ts | 36 +++++ .../transaction/transaction.component.html | 9 ++ .../transaction/transaction.component.ts | 11 +- .../src/app/interfaces/node-api.interface.ts | 9 ++ frontend/src/app/services/api.service.ts | 6 +- frontend/src/app/shared/shared.module.ts | 3 + 11 files changed, 295 insertions(+), 21 deletions(-) create mode 100644 frontend/src/app/components/rbf-timeline/rbf-timeline.component.html create mode 100644 frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss create mode 100644 frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 298ae3715..b8c86bbe2 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -32,7 +32,7 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'backend-info', this.getBackendInfo) .get(config.MEMPOOL.API_URL_PREFIX + 'init-data', this.getInitData) .get(config.MEMPOOL.API_URL_PREFIX + 'validate-address/:address', this.validateAddress) - .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/replaces', this.getRbfHistory) + .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/rbf', this.getRbfHistory) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/cached', this.getCachedTx) .post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', this.$postTransactionForm) .get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => { @@ -642,8 +642,12 @@ class BitcoinRoutes { private async getRbfHistory(req: Request, res: Response) { try { - const result = rbfCache.getReplaces(req.params.txId); - res.json(result || []); + const replacements = rbfCache.getRbfChain(req.params.txId) || []; + const replaces = rbfCache.getReplaces(req.params.txId) || null; + res.json({ + replacements, + replaces + }); } catch (e) { res.status(500).send(e instanceof Error ? e.message : e); } diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 0d593f1a3..8b2728c1c 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -269,7 +269,7 @@ class Mempool { for (const rbfTransaction in rbfTransactions) { if (this.mempoolCache[rbfTransaction]) { // Store replaced transactions - rbfCache.add(this.mempoolCache[rbfTransaction], rbfTransactions[rbfTransaction].txid); + rbfCache.add(this.mempoolCache[rbfTransaction], rbfTransactions[rbfTransaction]); // Erase the replaced transactions from the local mempool delete this.mempoolCache[rbfTransaction]; } diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index 410239e73..8557ec232 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -1,8 +1,15 @@ -import { TransactionExtended } from "../mempool.interfaces"; +import { TransactionExtended, TransactionStripped } from "../mempool.interfaces"; +import { Common } from "./common"; + +interface RbfTransaction extends TransactionStripped { + rbf?: boolean; +} class RbfCache { private replacedBy: { [txid: string]: string; } = {}; private replaces: { [txid: string]: string[] } = {}; + private rbfChains: { [root: string]: { tx: TransactionStripped, time: number, mined?: boolean }[] } = {}; // sequences of consecutive replacements + private chainMap: { [txid: string]: string } = {}; // map of txids to sequence ids private txs: { [txid: string]: TransactionExtended } = {}; private expiring: { [txid: string]: Date } = {}; @@ -10,13 +17,34 @@ class RbfCache { setInterval(this.cleanup.bind(this), 1000 * 60 * 60); } - public add(replacedTx: TransactionExtended, newTxId: string): void { - this.replacedBy[replacedTx.txid] = newTxId; - this.txs[replacedTx.txid] = replacedTx; - if (!this.replaces[newTxId]) { - this.replaces[newTxId] = []; + public add(replacedTxExtended: TransactionExtended, newTxExtended: TransactionExtended): void { + const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction; + replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe); + const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction; + newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe); + + this.replacedBy[replacedTx.txid] = newTx.txid; + this.txs[replacedTx.txid] = replacedTxExtended; + if (!this.replaces[newTx.txid]) { + this.replaces[newTx.txid] = []; + } + this.replaces[newTx.txid].push(replacedTx.txid); + + // maintain rbf chains + if (this.chainMap[replacedTx.txid]) { + // add to an existing chain + const chainRoot = this.chainMap[replacedTx.txid]; + this.rbfChains[chainRoot].push({ tx: newTx, time: newTxExtended.firstSeen || Date.now() }); + this.chainMap[newTx.txid] = chainRoot; + } else { + // start a new chain + this.rbfChains[replacedTx.txid] = [ + { tx: replacedTx, time: replacedTxExtended.firstSeen || Date.now() }, + { tx: newTx, time: newTxExtended.firstSeen || Date.now() }, + ]; + this.chainMap[replacedTx.txid] = replacedTx.txid; + this.chainMap[newTx.txid] = replacedTx.txid; } - this.replaces[newTxId].push(replacedTx.txid); } public getReplacedBy(txId: string): string | undefined { @@ -31,6 +59,10 @@ class RbfCache { return this.txs[txId]; } + public getRbfChain(txId: string): { tx: TransactionStripped, time: number }[] { + return this.rbfChains[this.chainMap[txId]] || []; + } + // flag a transaction as removed from the mempool public evict(txid): void { this.expiring[txid] = new Date(Date.now() + 1000 * 86400); // 24 hours @@ -48,14 +80,20 @@ class RbfCache { // remove a transaction & all previous versions from the cache private remove(txid): void { - // don't remove a transaction while a newer version remains in the mempool - if (this.replaces[txid] && !this.replacedBy[txid]) { + // don't remove a transaction if a newer version remains in the mempool + if (!this.replacedBy[txid]) { const replaces = this.replaces[txid]; delete this.replaces[txid]; + delete this.chainMap[txid]; + delete this.txs[txid]; + delete this.expiring[txid]; for (const tx of replaces) { // recursively remove prior versions from the cache delete this.replacedBy[tx]; - delete this.txs[tx]; + // if this is the root of a chain, remove that too + if (this.chainMap[tx] === tx) { + delete this.rbfChains[tx]; + } this.remove(tx); } } diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html new file mode 100644 index 000000000..a7b96f000 --- /dev/null +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html @@ -0,0 +1,35 @@ +
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ {{ replacement.tx.fee / (replacement.tx.vsize) | feeRounding }} sat/vB +
+ +
+
+ + +
diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss new file mode 100644 index 000000000..af0e75744 --- /dev/null +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss @@ -0,0 +1,137 @@ +.rbf-timeline { + position: relative; + width: 100%; + padding: 1em 0; + + &::after, &::before { + content: ''; + display: block; + position: absolute; + top: 0; + bottom: 0; + width: 2em; + z-index: 2; + } + + &::before { + left: 0; + background: linear-gradient(to right, #24273e, #24273e, transparent); + } + + &::after { + right: 0; + background: linear-gradient(to left, #24273e, #24273e, transparent); + } + + .timeline { + position: relative; + width: calc(100% - 2em); + margin: auto; + overflow-x: auto; + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + .intervals, .nodes { + min-width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + text-align: center; + + .node, .node-spacer { + width: 4em; + min-width: 4em; + flex-grow: 1; + } + + .interval, .interval-spacer { + width: 8em; + min-width: 4em; + max-width: 8em; + } + + .interval-time { + font-size: 12px; + } + } + + .node, .interval-spacer { + position: relative; + .track { + position: absolute; + height: 10px; + left: -5px; + right: -5px; + top: 0; + transform: translateY(-50%); + background: #105fb0; + border-radius: 5px; + } + &:first-child { + .track { + left: 50%; + } + } + &:last-child { + .track { + right: 50%; + } + } + } + + .nodes { + position: relative; + margin-top: 1em; + .node { + .shape-border { + display: block; + margin: auto; + height: calc(1em + 8px); + width: calc(1em + 8px); + margin-bottom: -8px; + transform: translateY(-50%); + border-radius: 10%; + cursor: pointer; + padding: 4px; + background: transparent; + transition: background-color 300ms, padding 300ms; + + .shape { + width: 100%; + height: 100%; + border-radius: 10%; + background: white; + transition: background-color 300ms; + } + + &.rbf, &.rbf .shape { + border-radius: 50%; + } + } + + .symbol::ng-deep { + display: block; + margin-top: -0.5em; + } + + &.selected { + .shape-border { + background: #9339f4; + } + } + + .shape-border:hover { + padding: 0px; + .shape { + background: #1bd8f4; + } + } + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts new file mode 100644 index 000000000..b053158b4 --- /dev/null +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts @@ -0,0 +1,36 @@ +import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID } from '@angular/core'; +import { Router } from '@angular/router'; +import { RbfInfo } from '../../interfaces/node-api.interface'; +import { StateService } from '../../services/state.service'; +import { ApiService } from '../../services/api.service'; + +@Component({ + selector: 'app-rbf-timeline', + templateUrl: './rbf-timeline.component.html', + styleUrls: ['./rbf-timeline.component.scss'], +}) +export class RbfTimelineComponent implements OnInit, OnChanges { + @Input() replacements: RbfInfo[]; + @Input() txid: string; + + dir: 'rtl' | 'ltr' = 'ltr'; + + constructor( + private router: Router, + private stateService: StateService, + private apiService: ApiService, + @Inject(LOCALE_ID) private locale: string, + ) { + if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) { + this.dir = 'rtl'; + } + } + + ngOnInit(): void { + + } + + ngOnChanges(): void { + + } +} diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 04d13b07a..1710b538f 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -197,6 +197,15 @@
+ +
+

Replacements

+
+
+ +
+
+

Flow

diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 5f23633bf..d89bf4e2b 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 } from '../../interfaces/node-api.interface'; +import { BlockExtended, CpfpInfo, RbfInfo } 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'; @@ -53,6 +53,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { rbfTransaction: undefined | Transaction; replaced: boolean = false; rbfReplaces: string[]; + rbfInfo: RbfInfo[]; cpfpInfo: CpfpInfo | null; showCpfpDetails = false; fetchCpfp$ = new Subject(); @@ -183,10 +184,11 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { .getRbfHistory$(txId) ), catchError(() => { - return of([]); + return of(null); }) - ).subscribe((replaces) => { - this.rbfReplaces = replaces; + ).subscribe((rbfResponse) => { + this.rbfInfo = rbfResponse?.replacements || []; + this.rbfReplaces = rbfResponse?.replaces || null; }); this.fetchCachedTxSubscription = this.fetchCachedTx$ @@ -460,6 +462,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.replaced = false; this.transactionTime = -1; this.cpfpInfo = null; + this.rbfInfo = []; this.rbfReplaces = []; this.showCpfpDetails = false; document.body.scrollTo(0, 0); diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 46654a3b7..442fb73ce 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -26,6 +26,11 @@ export interface CpfpInfo { bestDescendant?: BestDescendant | null; } +export interface RbfInfo { + tx: RbfTransaction, + time: number +} + export interface DifficultyAdjustment { progressPercent: number; difficultyChange: number; @@ -146,6 +151,10 @@ export interface TransactionStripped { status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; } +interface RbfTransaction extends TransactionStripped { + rbf?: boolean; +} + export interface RewardStats { startBlock: number; endBlock: number; diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 2b4e460a2..fda957a8a 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 } from '../interfaces/node-api.interface'; + PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfInfo } from '../interfaces/node-api.interface'; import { Observable } from 'rxjs'; import { StateService } from './state.service'; import { WebsocketResponse } from '../interfaces/websocket.interface'; @@ -124,8 +124,8 @@ export class ApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/validate-address/' + address); } - getRbfHistory$(txid: string): Observable { - return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/replaces'); + getRbfHistory$(txid: string): Observable<{ replacements: RbfInfo[], replaces: string[] }> { + return this.httpClient.get<{ replacements: RbfInfo[], replaces: string[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/rbf'); } getRbfCachedTx$(txid: string): Observable { diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index e276f79f8..7313ec8e3 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -61,6 +61,7 @@ import { DifficultyComponent } from '../components/difficulty/difficulty.compone import { DifficultyTooltipComponent } from '../components/difficulty/difficulty-tooltip.component'; import { DifficultyMiningComponent } from '../components/difficulty-mining/difficulty-mining.component'; import { TermsOfServiceComponent } from '../components/terms-of-service/terms-of-service.component'; +import { RbfTimelineComponent } from '../components/rbf-timeline/rbf-timeline.component'; import { TxBowtieGraphComponent } from '../components/tx-bowtie-graph/tx-bowtie-graph.component'; import { TxBowtieGraphTooltipComponent } from '../components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component'; import { PrivacyPolicyComponent } from '../components/privacy-policy/privacy-policy.component'; @@ -138,6 +139,7 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert. DifficultyComponent, DifficultyMiningComponent, DifficultyTooltipComponent, + RbfTimelineComponent, TxBowtieGraphComponent, TxBowtieGraphTooltipComponent, TermsOfServiceComponent, @@ -242,6 +244,7 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert. DifficultyComponent, DifficultyMiningComponent, DifficultyTooltipComponent, + RbfTimelineComponent, TxBowtieGraphComponent, TxBowtieGraphTooltipComponent, TermsOfServiceComponent, From 7b2a1cfd10ef0ebbe1394e6f8d815e4c166a7719 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 14 Dec 2022 08:49:35 -0600 Subject: [PATCH 020/113] update RBF timeline over websocket --- backend/src/api/rbf-cache.ts | 104 +++++++++++------- backend/src/api/websocket-handler.ts | 6 + .../transaction/transaction.component.ts | 43 +++++--- .../src/app/interfaces/websocket.interface.ts | 3 +- frontend/src/app/services/state.service.ts | 3 +- .../src/app/services/websocket.service.ts | 4 + 6 files changed, 110 insertions(+), 53 deletions(-) diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index 8557ec232..1a0e0f7d5 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -5,13 +5,20 @@ interface RbfTransaction extends TransactionStripped { rbf?: boolean; } +type RbfChain = { + tx: RbfTransaction, + time: number, + mined?: boolean, +}[]; + class RbfCache { - private replacedBy: { [txid: string]: string; } = {}; - private replaces: { [txid: string]: string[] } = {}; - private rbfChains: { [root: string]: { tx: TransactionStripped, time: number, mined?: boolean }[] } = {}; // sequences of consecutive replacements - private chainMap: { [txid: string]: string } = {}; // map of txids to sequence ids - private txs: { [txid: string]: TransactionExtended } = {}; - private expiring: { [txid: string]: Date } = {}; + private replacedBy: Map = new Map(); + private replaces: Map = new Map(); + private rbfChains: Map = new Map(); // sequences of consecutive replacements + private dirtyChains: Set = new Set(); + private chainMap: Map = new Map(); // map of txids to sequence ids + private txs: Map = new Map(); + private expiring: Map = new Map(); constructor() { setInterval(this.cleanup.bind(this), 1000 * 60 * 60); @@ -23,56 +30,79 @@ class RbfCache { const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction; newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe); - this.replacedBy[replacedTx.txid] = newTx.txid; - this.txs[replacedTx.txid] = replacedTxExtended; - if (!this.replaces[newTx.txid]) { - this.replaces[newTx.txid] = []; + this.replacedBy.set(replacedTx.txid, newTx.txid); + this.txs.set(replacedTx.txid, replacedTxExtended); + this.txs.set(newTx.txid, newTxExtended); + if (!this.replaces.has(newTx.txid)) { + this.replaces.set(newTx.txid, []); } - this.replaces[newTx.txid].push(replacedTx.txid); + this.replaces.get(newTx.txid)?.push(replacedTx.txid); // maintain rbf chains - if (this.chainMap[replacedTx.txid]) { + if (this.chainMap.has(replacedTx.txid)) { // add to an existing chain - const chainRoot = this.chainMap[replacedTx.txid]; - this.rbfChains[chainRoot].push({ tx: newTx, time: newTxExtended.firstSeen || Date.now() }); - this.chainMap[newTx.txid] = chainRoot; + const chainRoot = this.chainMap.get(replacedTx.txid) || ''; + this.rbfChains.get(chainRoot)?.push({ tx: newTx, time: newTxExtended.firstSeen || Date.now() }); + this.chainMap.set(newTx.txid, chainRoot); + this.dirtyChains.add(chainRoot); } else { // start a new chain - this.rbfChains[replacedTx.txid] = [ + this.rbfChains.set(replacedTx.txid, [ { tx: replacedTx, time: replacedTxExtended.firstSeen || Date.now() }, { tx: newTx, time: newTxExtended.firstSeen || Date.now() }, - ]; - this.chainMap[replacedTx.txid] = replacedTx.txid; - this.chainMap[newTx.txid] = replacedTx.txid; + ]); + this.chainMap.set(replacedTx.txid, replacedTx.txid); + this.chainMap.set(newTx.txid, replacedTx.txid); + this.dirtyChains.add(replacedTx.txid); } } public getReplacedBy(txId: string): string | undefined { - return this.replacedBy[txId]; + return this.replacedBy.get(txId); } public getReplaces(txId: string): string[] | undefined { - return this.replaces[txId]; + return this.replaces.get(txId); } public getTx(txId: string): TransactionExtended | undefined { - return this.txs[txId]; + return this.txs.get(txId); } - public getRbfChain(txId: string): { tx: TransactionStripped, time: number }[] { - return this.rbfChains[this.chainMap[txId]] || []; + public getRbfChain(txId: string): RbfChain { + return this.rbfChains.get(this.chainMap.get(txId) || '') || []; } + // get map of rbf chains that have been updated since the last call + public getRbfChanges(): { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} { + const changes: { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} = { + chains: {}, + map: {}, + }; + this.dirtyChains.forEach(root => { + const chain = this.rbfChains.get(root); + if (chain) { + changes.chains[root] = chain; + chain.forEach(entry => { + changes.map[entry.tx.txid] = root; + }); + } + }); + this.dirtyChains = new Set(); + return changes; + } + + // flag a transaction as removed from the mempool public evict(txid): void { - this.expiring[txid] = new Date(Date.now() + 1000 * 86400); // 24 hours + this.expiring.set(txid, new Date(Date.now() + 1000 * 86400)); // 24 hours } private cleanup(): void { const currentDate = new Date(); for (const txid in this.expiring) { - if (this.expiring[txid] < currentDate) { - delete this.expiring[txid]; + if ((this.expiring.get(txid) || 0) < currentDate) { + this.expiring.delete(txid); this.remove(txid); } } @@ -81,18 +111,18 @@ class RbfCache { // remove a transaction & all previous versions from the cache private remove(txid): void { // don't remove a transaction if a newer version remains in the mempool - if (!this.replacedBy[txid]) { - const replaces = this.replaces[txid]; - delete this.replaces[txid]; - delete this.chainMap[txid]; - delete this.txs[txid]; - delete this.expiring[txid]; - for (const tx of replaces) { + if (!this.replacedBy.has(txid)) { + const replaces = this.replaces.get(txid); + this.replaces.delete(txid); + this.chainMap.delete(txid); + this.txs.delete(txid); + this.expiring.delete(txid); + for (const tx of (replaces || [])) { // recursively remove prior versions from the cache - delete this.replacedBy[tx]; + this.replacedBy.delete(tx); // if this is the root of a chain, remove that too - if (this.chainMap[tx] === tx) { - delete this.rbfChains[tx]; + if (this.chainMap.get(tx) === tx) { + this.rbfChains.delete(tx); } this.remove(tx); } diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 865dfe9d6..695b79f2b 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -278,6 +278,7 @@ class WebsocketHandler { const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions); const da = difficultyAdjustment.getDifficultyAdjustment(); memPool.handleRbfTransactions(rbfTransactions); + const rbfChanges = rbfCache.getRbfChanges(); const recommendedFees = feeApi.getRecommendedFee(); this.wss.clients.forEach(async (client) => { @@ -410,6 +411,11 @@ class WebsocketHandler { } } } + + const rbfChange = rbfChanges.map[client['track-tx']]; + if (rbfChange) { + response['rbfInfo'] = rbfChanges.chains[rbfChange]; + } } if (client['track-mempool-block'] >= 0) { diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index d89bf4e2b..41dfe8bf0 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -46,6 +46,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { fetchRbfSubscription: Subscription; fetchCachedTxSubscription: Subscription; txReplacedSubscription: Subscription; + txRbfInfoSubscription: Subscription; blocksSubscription: Subscription; queryParamsSubscription: Subscription; urlFragmentSubscription: Subscription; @@ -205,21 +206,28 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { return; } - this.tx = tx; - this.setFeatures(); - this.isCached = true; - if (tx.fee === undefined) { - this.tx.fee = 0; - } - this.tx.feePerVsize = tx.fee / (tx.weight / 4); - this.isLoadingTx = false; - this.error = undefined; - this.waitingForTransaction = false; - this.graphExpanded = false; - this.setupGraph(); + if (!this.tx) { + this.tx = tx; + this.setFeatures(); + this.isCached = true; + if (tx.fee === undefined) { + this.tx.fee = 0; + } + this.tx.feePerVsize = tx.fee / (tx.weight / 4); + this.isLoadingTx = false; + this.error = undefined; + this.waitingForTransaction = false; + this.graphExpanded = false; + this.setupGraph(); - if (!this.tx?.status?.confirmed) { - this.fetchRbfHistory$.next(this.tx.txid); + if (!this.tx?.status?.confirmed) { + this.fetchRbfHistory$.next(this.tx.txid); + this.txRbfInfoSubscription = this.stateService.txRbfInfo$.subscribe((rbfInfo) => { + if (this.tx) { + this.rbfInfo = rbfInfo; + } + }); + } } }); @@ -382,6 +390,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } }); + this.txRbfInfoSubscription = this.stateService.txRbfInfo$.subscribe((rbfInfo) => { + if (this.tx) { + this.rbfInfo = rbfInfo; + } + }); + this.queryParamsSubscription = this.route.queryParams.subscribe((params) => { if (params.showFlow === 'false') { this.overrideFlowPreference = false; @@ -535,6 +549,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.fetchRbfSubscription.unsubscribe(); this.fetchCachedTxSubscription.unsubscribe(); this.txReplacedSubscription.unsubscribe(); + this.txRbfInfoSubscription.unsubscribe(); this.blocksSubscription.unsubscribe(); this.queryParamsSubscription.unsubscribe(); this.flowPrefSubscription.unsubscribe(); diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 46416857e..aa0834cf8 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -1,6 +1,6 @@ import { ILoadingIndicators } from '../services/state.service'; import { Transaction } from './electrs.interface'; -import { BlockExtended, DifficultyAdjustment } from './node-api.interface'; +import { BlockExtended, DifficultyAdjustment, RbfInfo } from './node-api.interface'; export interface WebsocketResponse { block?: BlockExtended; @@ -16,6 +16,7 @@ export interface WebsocketResponse { tx?: Transaction; rbfTransaction?: ReplacedTransaction; txReplaced?: ReplacedTransaction; + rbfInfo?: RbfInfo[]; utxoSpent?: object; transactions?: TransactionStripped[]; loadingIndicators?: ILoadingIndicators; diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index c56a5e79e..dbb269945 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core'; import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; import { Transaction } from '../interfaces/electrs.interface'; import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; -import { BlockExtended, DifficultyAdjustment, OptimizedMempoolStats } from '../interfaces/node-api.interface'; +import { BlockExtended, DifficultyAdjustment, OptimizedMempoolStats, RbfInfo } from '../interfaces/node-api.interface'; import { Router, NavigationStart } from '@angular/router'; import { isPlatformBrowser } from '@angular/common'; import { map, shareReplay } from 'rxjs/operators'; @@ -98,6 +98,7 @@ export class StateService { mempoolBlockTransactions$ = new Subject(); mempoolBlockDelta$ = new Subject(); txReplaced$ = new Subject(); + txRbfInfo$ = new Subject(); utxoSpent$ = new Subject(); difficultyAdjustment$ = new ReplaySubject(1); mempoolTransactions$ = new Subject(); diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index d58ab58c9..826716db2 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -257,6 +257,10 @@ export class WebsocketService { this.stateService.txReplaced$.next(response.rbfTransaction); } + if (response.rbfInfo) { + this.stateService.txRbfInfo$.next(response.rbfInfo); + } + if (response.txReplaced) { this.stateService.txReplaced$.next(response.txReplaced); } From f46296a2bba3b57bb8f7d14caf50a30a472846d0 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 14 Dec 2022 16:51:53 -0600 Subject: [PATCH 021/113] new page listing recent RBF events --- backend/src/api/bitcoin/bitcoin.routes.ts | 20 +++++ backend/src/api/rbf-cache.ts | 41 +++++++++ backend/src/api/websocket-handler.ts | 23 ++++- frontend/src/app/app-routing.module.ts | 13 +++ .../rbf-list/rbf-list.component.html | 61 +++++++++++++ .../rbf-list/rbf-list.component.scss | 51 +++++++++++ .../components/rbf-list/rbf-list.component.ts | 86 +++++++++++++++++++ .../rbf-timeline/rbf-timeline.component.html | 4 +- .../rbf-timeline/rbf-timeline.component.scss | 6 ++ .../rbf-timeline/rbf-timeline.component.ts | 5 +- .../src/app/interfaces/node-api.interface.ts | 3 +- .../src/app/interfaces/websocket.interface.ts | 2 + frontend/src/app/services/api.service.ts | 4 + frontend/src/app/services/state.service.ts | 1 + .../src/app/services/websocket.service.ts | 15 ++++ frontend/src/app/shared/shared.module.ts | 5 +- 16 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 frontend/src/app/components/rbf-list/rbf-list.component.html create mode 100644 frontend/src/app/components/rbf-list/rbf-list.component.scss create mode 100644 frontend/src/app/components/rbf-list/rbf-list.component.ts diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index b8c86bbe2..c01a6170f 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -34,6 +34,8 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'validate-address/:address', this.validateAddress) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/rbf', this.getRbfHistory) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/cached', this.getCachedTx) + .get(config.MEMPOOL.API_URL_PREFIX + 'replacements', this.getRbfReplacements) + .get(config.MEMPOOL.API_URL_PREFIX + 'fullrbf/replacements', this.getFullRbfReplacements) .post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', this.$postTransactionForm) .get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => { try { @@ -653,6 +655,24 @@ class BitcoinRoutes { } } + private async getRbfReplacements(req: Request, res: Response) { + try { + const result = rbfCache.getRbfChains(false); + res.json(result); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + private async getFullRbfReplacements(req: Request, res: Response) { + try { + const result = rbfCache.getRbfChains(true); + res.json(result); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + private async getCachedTx(req: Request, res: Response) { try { const result = rbfCache.getTx(req.params.txId); diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index 1a0e0f7d5..17eb53e12 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -73,6 +73,33 @@ class RbfCache { return this.rbfChains.get(this.chainMap.get(txId) || '') || []; } + // get a paginated list of RbfChains + // ordered by most recent replacement time + public getRbfChains(onlyFullRbf: boolean, after?: string): RbfChain[] { + const limit = 25; + const chains: RbfChain[] = []; + const used = new Set(); + const replacements: string[][] = Array.from(this.replacedBy).reverse(); + const afterChain = after ? this.chainMap.get(after) : null; + let ready = !afterChain; + for (let i = 0; i < replacements.length && chains.length <= limit - 1; i++) { + const txid = replacements[i][1]; + const chainRoot = this.chainMap.get(txid) || ''; + if (chainRoot === afterChain) { + ready = true; + } else if (ready) { + if (!used.has(chainRoot)) { + const chain = this.rbfChains.get(chainRoot); + used.add(chainRoot); + if (chain && (!onlyFullRbf || chain.slice(0, -1).some(entry => !entry.tx.rbf))) { + chains.push(chain); + } + } + } + } + return chains; + } + // get map of rbf chains that have been updated since the last call public getRbfChanges(): { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} { const changes: { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} = { @@ -92,6 +119,20 @@ class RbfCache { return changes; } + public mined(txid): void { + const chainRoot = this.chainMap.get(txid) + if (chainRoot && this.rbfChains.has(chainRoot)) { + const chain = this.rbfChains.get(chainRoot); + if (chain) { + const chainEntry = chain.find(entry => entry.tx.txid === txid); + if (chainEntry) { + chainEntry.mined = true; + } + this.dirtyChains.add(chainRoot); + } + } + this.evict(txid); + } // flag a transaction as removed from the mempool public evict(txid): void { diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 695b79f2b..71ed473a8 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -140,6 +140,14 @@ class WebsocketHandler { } } + if (parsedMessage && parsedMessage['track-rbf'] !== undefined) { + if (['all', 'fullRbf'].includes(parsedMessage['track-rbf'])) { + client['track-rbf'] = parsedMessage['track-rbf']; + } else { + client['track-rbf'] = false; + } + } + if (parsedMessage.action === 'init') { const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT); if (!_blocks) { @@ -279,6 +287,12 @@ class WebsocketHandler { const da = difficultyAdjustment.getDifficultyAdjustment(); memPool.handleRbfTransactions(rbfTransactions); const rbfChanges = rbfCache.getRbfChanges(); + let rbfReplacements; + let fullRbfReplacements; + if (Object.keys(rbfChanges.chains).length) { + rbfReplacements = rbfCache.getRbfChains(false); + fullRbfReplacements = rbfCache.getRbfChains(true); + } const recommendedFees = feeApi.getRecommendedFee(); this.wss.clients.forEach(async (client) => { @@ -428,6 +442,13 @@ class WebsocketHandler { } } + console.log(client['track-rbf']); + if (client['track-rbf'] === 'all' && rbfReplacements) { + response['rbfLatest'] = rbfReplacements; + } else if (client['track-rbf'] === 'fullRbf' && fullRbfReplacements) { + response['rbfLatest'] = fullRbfReplacements; + } + if (Object.keys(response).length) { client.send(JSON.stringify(response)); } @@ -506,7 +527,7 @@ class WebsocketHandler { // Update mempool to remove transactions included in the new block for (const txId of txIds) { delete _memPool[txId]; - rbfCache.evict(txId); + rbfCache.mined(txId); } if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) { diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 90ea84a82..06334c5b5 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -14,6 +14,7 @@ import { TrademarkPolicyComponent } from './components/trademark-policy/trademar import { BisqMasterPageComponent } from './components/bisq-master-page/bisq-master-page.component'; import { PushTransactionComponent } from './components/push-transaction/push-transaction.component'; import { BlocksList } from './components/blocks-list/blocks-list.component'; +import { RbfList } from './components/rbf-list/rbf-list.component'; import { LiquidMasterPageComponent } from './components/liquid-master-page/liquid-master-page.component'; import { AssetGroupComponent } from './components/assets/asset-group/asset-group.component'; import { AssetsFeaturedComponent } from './components/assets/assets-featured/assets-featured.component'; @@ -56,6 +57,10 @@ let routes: Routes = [ path: 'blocks', component: BlocksList, }, + { + path: 'rbf', + component: RbfList, + }, { path: 'terms-of-service', component: TermsOfServiceComponent @@ -162,6 +167,10 @@ let routes: Routes = [ path: 'blocks', component: BlocksList, }, + { + path: 'rbf', + component: RbfList, + }, { path: 'terms-of-service', component: TermsOfServiceComponent @@ -264,6 +273,10 @@ let routes: Routes = [ path: 'blocks', component: BlocksList, }, + { + path: 'rbf', + component: RbfList, + }, { path: 'terms-of-service', component: TermsOfServiceComponent diff --git a/frontend/src/app/components/rbf-list/rbf-list.component.html b/frontend/src/app/components/rbf-list/rbf-list.component.html new file mode 100644 index 000000000..427ab3acf --- /dev/null +++ b/frontend/src/app/components/rbf-list/rbf-list.component.html @@ -0,0 +1,61 @@ +
+

RBF Replacements

+
+ +
+
+
+ + +
+
+
+ +
+ +
+ + + +
+

there are no replacements in the mempool yet!

+
+
+ + +
+ +
diff --git a/frontend/src/app/components/rbf-list/rbf-list.component.scss b/frontend/src/app/components/rbf-list/rbf-list.component.scss new file mode 100644 index 000000000..fa8ebc1f1 --- /dev/null +++ b/frontend/src/app/components/rbf-list/rbf-list.component.scss @@ -0,0 +1,51 @@ +.spinner-border { + height: 25px; + width: 25px; + margin-top: 13px; +} + +.rbf-chains { + .info { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: baseline; + margin: 0; + + .type { + .badge { + margin-left: .5em; + } + } + } + + .chain { + margin-bottom: 1em; + } + + .txids { + display: flex; + flex-direction: row; + align-items: baseline; + justify-content: space-between; + margin-bottom: 2px; + + .txid { + flex-basis: 0; + flex-grow: 1; + + &.right { + text-align: right; + } + } + } + + .timeline-wrapper.mined { + border: solid 4px #1a9436; + } + + .no-replacements { + margin: 1em; + text-align: center; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/rbf-list/rbf-list.component.ts b/frontend/src/app/components/rbf-list/rbf-list.component.ts new file mode 100644 index 000000000..b40dbaf16 --- /dev/null +++ b/frontend/src/app/components/rbf-list/rbf-list.component.ts @@ -0,0 +1,86 @@ +import { Component, OnInit, ChangeDetectionStrategy, OnDestroy } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BehaviorSubject, EMPTY, merge, Observable, Subscription } from 'rxjs'; +import { catchError, switchMap, tap } from 'rxjs/operators'; +import { WebsocketService } from 'src/app/services/websocket.service'; +import { RbfInfo } from '../../interfaces/node-api.interface'; +import { ApiService } from '../../services/api.service'; +import { StateService } from '../../services/state.service'; + +@Component({ + selector: 'app-rbf-list', + templateUrl: './rbf-list.component.html', + styleUrls: ['./rbf-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RbfList implements OnInit, OnDestroy { + rbfChains$: Observable; + fromChainSubject = new BehaviorSubject(null); + urlFragmentSubscription: Subscription; + fullRbfEnabled: boolean; + fullRbf: boolean; + isLoading = true; + firstChainId: string; + lastChainId: string; + + constructor( + private route: ActivatedRoute, + private router: Router, + private apiService: ApiService, + public stateService: StateService, + private websocketService: WebsocketService, + ) { + this.fullRbfEnabled = stateService.env.FULL_RBF_ENABLED; + } + + ngOnInit(): void { + this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => { + this.fullRbf = (fragment === 'fullrbf'); + this.websocketService.startTrackRbf(this.fullRbf ? 'fullRbf' : 'all'); + this.fromChainSubject.next(this.firstChainId); + }); + + this.rbfChains$ = merge( + this.fromChainSubject.pipe( + switchMap((fromChainId) => { + return this.apiService.getRbfList$(this.fullRbf, fromChainId || undefined) + }), + catchError((e) => { + return EMPTY; + }) + ), + this.stateService.rbfLatest$ + ) + .pipe( + tap((result: RbfInfo[][]) => { + this.isLoading = false; + if (result && result.length && result[0].length) { + this.lastChainId = result[result.length - 1][0].tx.txid; + } + }) + ); + } + + toggleFullRbf(event) { + this.router.navigate([], { + relativeTo: this.route, + fragment: this.fullRbf ? null : 'fullrbf' + }); + } + + isFullRbf(chain: RbfInfo[]): boolean { + return chain.slice(0, -1).some(entry => !entry.tx.rbf); + } + + isMined(chain: RbfInfo[]): boolean { + return chain.some(entry => entry.mined); + } + + // pageChange(page: number) { + // this.fromChainSubject.next(this.lastChainId); + // } + + ngOnDestroy(): void { + this.websocketService.stopTrackRbf(); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html index a7b96f000..13f5a567c 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html @@ -1,4 +1,4 @@ -
+
@@ -15,7 +15,7 @@
-
+
diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss index af0e75744..1be6a7628 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss @@ -126,6 +126,12 @@ } } + &.mined { + .shape-border { + background: #1a9436; + } + } + .shape-border:hover { padding: 0px; .shape { diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts index b053158b4..0b65f703b 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts @@ -12,6 +12,7 @@ import { ApiService } from '../../services/api.service'; export class RbfTimelineComponent implements OnInit, OnChanges { @Input() replacements: RbfInfo[]; @Input() txid: string; + mined: boolean; dir: 'rtl' | 'ltr' = 'ltr'; @@ -27,10 +28,10 @@ export class RbfTimelineComponent implements OnInit, OnChanges { } ngOnInit(): void { - + this.mined = this.replacements.some(entry => entry.mined); } ngOnChanges(): void { - + this.mined = this.replacements.some(entry => entry.mined); } } diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 442fb73ce..420c8bdaf 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -28,7 +28,8 @@ export interface CpfpInfo { export interface RbfInfo { tx: RbfTransaction, - time: number + time: number, + mined?: boolean, } export interface DifficultyAdjustment { diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index aa0834cf8..c7e2f60fd 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -17,6 +17,7 @@ export interface WebsocketResponse { rbfTransaction?: ReplacedTransaction; txReplaced?: ReplacedTransaction; rbfInfo?: RbfInfo[]; + rbfLatest?: RbfInfo[][]; utxoSpent?: object; transactions?: TransactionStripped[]; loadingIndicators?: ILoadingIndicators; @@ -27,6 +28,7 @@ export interface WebsocketResponse { 'track-address'?: string; 'track-asset'?: string; 'track-mempool-block'?: number; + 'track-rbf'?: string; 'watch-mempool'?: boolean; 'track-bisq-market'?: string; } diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index fda957a8a..bdba538ef 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -132,6 +132,10 @@ export class ApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/cached'); } + getRbfList$(fullRbf: boolean, after?: string): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || '')); + } + listLiquidPegsMonth$(): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs/month'); } diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index dbb269945..1b0a65d95 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -99,6 +99,7 @@ export class StateService { mempoolBlockDelta$ = new Subject(); txReplaced$ = new Subject(); txRbfInfo$ = new Subject(); + rbfLatest$ = new Subject(); utxoSpent$ = new Subject(); difficultyAdjustment$ = new ReplaySubject(1); mempoolTransactions$ = new Subject(); diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index 826716db2..9e473d24c 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -28,6 +28,7 @@ export class WebsocketService { private isTrackingTx = false; private trackingTxId: string; private isTrackingMempoolBlock = false; + private isTrackingRbf = false; private trackingMempoolBlock: number; private latestGitCommit = ''; private onlineCheckTimeout: number; @@ -173,6 +174,16 @@ export class WebsocketService { this.isTrackingMempoolBlock = false } + startTrackRbf(mode: 'all' | 'fullRbf') { + this.websocketSubject.next({ 'track-rbf': mode }); + this.isTrackingRbf = true; + } + + stopTrackRbf() { + this.websocketSubject.next({ 'track-rbf': 'stop' }); + this.isTrackingRbf = false; + } + startTrackBisqMarket(market: string) { this.websocketSubject.next({ 'track-bisq-market': market }); } @@ -261,6 +272,10 @@ export class WebsocketService { this.stateService.txRbfInfo$.next(response.rbfInfo); } + if (response.rbfLatest) { + this.stateService.rbfLatest$.next(response.rbfLatest); + } + if (response.txReplaced) { this.stateService.txReplaced$.next(response.txReplaced); } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 7313ec8e3..ec601964a 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -4,7 +4,7 @@ import { NgbCollapseModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstra import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle, faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown, - faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft } from '@fortawesome/free-solid-svg-icons'; + faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowRight, faArrowsRotate, faCircleLeft } from '@fortawesome/free-solid-svg-icons'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { MasterPageComponent } from '../components/master-page/master-page.component'; import { PreviewTitleComponent } from '../components/master-page-preview/preview-title.component'; @@ -73,6 +73,7 @@ import { AssetCirculationComponent } from '../components/asset-circulation/asset import { AmountShortenerPipe } from '../shared/pipes/amount-shortener.pipe'; import { DifficultyAdjustmentsTable } from '../components/difficulty-adjustments-table/difficulty-adjustments-table.components'; import { BlocksList } from '../components/blocks-list/blocks-list.component'; +import { RbfList } from '../components/rbf-list/rbf-list.component'; import { RewardStatsComponent } from '../components/reward-stats/reward-stats.component'; import { DataCyDirective } from '../data-cy.directive'; import { LoadingIndicatorComponent } from '../components/loading-indicator/loading-indicator.component'; @@ -153,6 +154,7 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert. AmountShortenerPipe, DifficultyAdjustmentsTable, BlocksList, + RbfList, DataCyDirective, RewardStatsComponent, LoadingIndicatorComponent, @@ -313,6 +315,7 @@ export class SharedModule { library.addIcons(faDownload); library.addIcons(faQrcode); library.addIcons(faArrowRightArrowLeft); + library.addIcons(faArrowRight); library.addIcons(faExchangeAlt); } } From c064ef6acea30bc3f3408c86649b46731710caab Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 14 Dec 2022 17:03:02 -0600 Subject: [PATCH 022/113] remove 'replaces' alert on transaction page --- .../app/components/transaction/transaction.component.html | 7 ------- 1 file changed, 7 deletions(-) diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 1710b538f..ffd3c93bf 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -6,13 +6,6 @@ - -

Transaction

From 086b41d9582661433d9515bf4122e2d51ee3e0e3 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sat, 17 Dec 2022 09:39:06 -0600 Subject: [PATCH 023/113] support trees of RBF replacements --- backend/src/api/bitcoin/bitcoin.routes.ts | 6 +- backend/src/api/common.ts | 14 +- backend/src/api/mempool.ts | 10 +- backend/src/api/rbf-cache.ts | 197 +++++++++++------- backend/src/api/websocket-handler.ts | 20 +- .../rbf-list/rbf-list.component.html | 33 +-- .../rbf-list/rbf-list.component.scss | 22 +- .../components/rbf-list/rbf-list.component.ts | 33 ++- .../rbf-timeline/rbf-timeline.component.html | 73 ++++--- .../rbf-timeline/rbf-timeline.component.scss | 38 +++- .../rbf-timeline/rbf-timeline.component.ts | 138 +++++++++++- .../transaction/transaction.component.html | 2 +- .../transaction/transaction.component.ts | 8 +- .../src/app/interfaces/node-api.interface.ts | 13 +- .../src/app/interfaces/websocket.interface.ts | 6 +- frontend/src/app/services/api.service.ts | 10 +- frontend/src/app/services/state.service.ts | 6 +- frontend/src/app/shared/shared.module.ts | 3 +- 18 files changed, 413 insertions(+), 219 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index c01a6170f..18d688e9b 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -644,7 +644,7 @@ class BitcoinRoutes { private async getRbfHistory(req: Request, res: Response) { try { - const replacements = rbfCache.getRbfChain(req.params.txId) || []; + const replacements = rbfCache.getRbfTree(req.params.txId) || null; const replaces = rbfCache.getReplaces(req.params.txId) || null; res.json({ replacements, @@ -657,7 +657,7 @@ class BitcoinRoutes { private async getRbfReplacements(req: Request, res: Response) { try { - const result = rbfCache.getRbfChains(false); + const result = rbfCache.getRbfTrees(false); res.json(result); } catch (e) { res.status(500).send(e instanceof Error ? e.message : e); @@ -666,7 +666,7 @@ class BitcoinRoutes { private async getFullRbfReplacements(req: Request, res: Response) { try { - const result = rbfCache.getRbfChains(true); + const result = rbfCache.getRbfTrees(true); res.json(result); } catch (e) { res.status(500).send(e instanceof Error ? e.message : e); diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 1d3b11d66..8bae655e3 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -57,11 +57,11 @@ export class Common { return arr; } - static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended } { - const matches: { [txid: string]: TransactionExtended } = {}; - deleted - .forEach((deletedTx) => { - const foundMatches = added.find((addedTx) => { + static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended[] } { + const matches: { [txid: string]: TransactionExtended[] } = {}; + added + .forEach((addedTx) => { + const foundMatches = deleted.filter((deletedTx) => { // The new tx must, absolutely speaking, pay at least as much fee as the replaced tx. return addedTx.fee > deletedTx.fee // The new transaction must pay more fee per kB than the replaced tx. @@ -70,8 +70,8 @@ export class Common { && deletedTx.vin.some((deletedVin) => addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout)); }); - if (foundMatches) { - matches[deletedTx.txid] = foundMatches; + if (foundMatches?.length) { + matches[addedTx.txid] = foundMatches; } }); return matches; diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 8b2728c1c..d476d6bca 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -265,13 +265,15 @@ class Mempool { } } - public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) { + public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended[]; }): void { for (const rbfTransaction in rbfTransactions) { - if (this.mempoolCache[rbfTransaction]) { + if (this.mempoolCache[rbfTransaction] && rbfTransactions[rbfTransaction]?.length) { // Store replaced transactions - rbfCache.add(this.mempoolCache[rbfTransaction], rbfTransactions[rbfTransaction]); + rbfCache.add(rbfTransactions[rbfTransaction], this.mempoolCache[rbfTransaction]); // Erase the replaced transactions from the local mempool - delete this.mempoolCache[rbfTransaction]; + for (const replaced of rbfTransactions[rbfTransaction]) { + delete this.mempoolCache[replaced.txid]; + } } } } diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index 17eb53e12..3377999f8 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -1,22 +1,27 @@ +import { runInNewContext } from "vm"; import { TransactionExtended, TransactionStripped } from "../mempool.interfaces"; import { Common } from "./common"; interface RbfTransaction extends TransactionStripped { rbf?: boolean; + mined?: boolean; } -type RbfChain = { - tx: RbfTransaction, - time: number, - mined?: boolean, -}[]; +interface RbfTree { + tx: RbfTransaction; + time: number; + interval?: number; + mined?: boolean; + fullRbf: boolean; + replaces: RbfTree[]; +} class RbfCache { private replacedBy: Map = new Map(); private replaces: Map = new Map(); - private rbfChains: Map = new Map(); // sequences of consecutive replacements - private dirtyChains: Set = new Set(); - private chainMap: Map = new Map(); // map of txids to sequence ids + private rbfTrees: Map = new Map(); // sequences of consecutive replacements + private dirtyTrees: Set = new Set(); + private treeMap: Map = new Map(); // map of txids to sequence ids private txs: Map = new Map(); private expiring: Map = new Map(); @@ -24,37 +29,58 @@ class RbfCache { setInterval(this.cleanup.bind(this), 1000 * 60 * 60); } - public add(replacedTxExtended: TransactionExtended, newTxExtended: TransactionExtended): void { - const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction; - replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe); + public add(replaced: TransactionExtended[], newTxExtended: TransactionExtended): void { + if (!newTxExtended || !replaced?.length) { + return; + } + const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction; + const newTime = newTxExtended.firstSeen || Date.now(); newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe); - - this.replacedBy.set(replacedTx.txid, newTx.txid); - this.txs.set(replacedTx.txid, replacedTxExtended); this.txs.set(newTx.txid, newTxExtended); - if (!this.replaces.has(newTx.txid)) { - this.replaces.set(newTx.txid, []); - } - this.replaces.get(newTx.txid)?.push(replacedTx.txid); - // maintain rbf chains - if (this.chainMap.has(replacedTx.txid)) { - // add to an existing chain - const chainRoot = this.chainMap.get(replacedTx.txid) || ''; - this.rbfChains.get(chainRoot)?.push({ tx: newTx, time: newTxExtended.firstSeen || Date.now() }); - this.chainMap.set(newTx.txid, chainRoot); - this.dirtyChains.add(chainRoot); - } else { - // start a new chain - this.rbfChains.set(replacedTx.txid, [ - { tx: replacedTx, time: replacedTxExtended.firstSeen || Date.now() }, - { tx: newTx, time: newTxExtended.firstSeen || Date.now() }, - ]); - this.chainMap.set(replacedTx.txid, replacedTx.txid); - this.chainMap.set(newTx.txid, replacedTx.txid); - this.dirtyChains.add(replacedTx.txid); + // maintain rbf trees + let fullRbf = false; + const replacedTrees: RbfTree[] = []; + for (const replacedTxExtended of replaced) { + const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction; + replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe); + this.replacedBy.set(replacedTx.txid, newTx.txid); + if (this.treeMap.has(replacedTx.txid)) { + const treeId = this.treeMap.get(replacedTx.txid); + if (treeId) { + const tree = this.rbfTrees.get(treeId); + this.rbfTrees.delete(treeId); + if (tree) { + tree.interval = newTime - tree?.time; + replacedTrees.push(tree); + fullRbf = fullRbf || tree.fullRbf; + } + } + } else { + const replacedTime = replacedTxExtended.firstSeen || Date.now(); + replacedTrees.push({ + tx: replacedTx, + time: replacedTime, + interval: newTime - replacedTime, + fullRbf: !replacedTx.rbf, + replaces: [], + }); + fullRbf = fullRbf || !replacedTx.rbf; + this.txs.set(replacedTx.txid, replacedTxExtended); + } } + const treeId = replacedTrees[0].tx.txid; + const newTree = { + tx: newTx, + time: newTxExtended.firstSeen || Date.now(), + fullRbf, + replaces: replacedTrees + }; + this.rbfTrees.set(treeId, newTree); + this.updateTreeMap(treeId, newTree); + this.replaces.set(newTx.txid, replacedTrees.map(tree => tree.tx.txid)); + this.dirtyTrees.add(treeId); } public getReplacedBy(txId: string): string | undefined { @@ -69,66 +95,64 @@ class RbfCache { return this.txs.get(txId); } - public getRbfChain(txId: string): RbfChain { - return this.rbfChains.get(this.chainMap.get(txId) || '') || []; + public getRbfTree(txId: string): RbfTree | void { + return this.rbfTrees.get(this.treeMap.get(txId) || ''); } - // get a paginated list of RbfChains + // get a paginated list of RbfTrees // ordered by most recent replacement time - public getRbfChains(onlyFullRbf: boolean, after?: string): RbfChain[] { + public getRbfTrees(onlyFullRbf: boolean, after?: string): RbfTree[] { const limit = 25; - const chains: RbfChain[] = []; + const trees: RbfTree[] = []; const used = new Set(); const replacements: string[][] = Array.from(this.replacedBy).reverse(); - const afterChain = after ? this.chainMap.get(after) : null; - let ready = !afterChain; - for (let i = 0; i < replacements.length && chains.length <= limit - 1; i++) { + const afterTree = after ? this.treeMap.get(after) : null; + let ready = !afterTree; + for (let i = 0; i < replacements.length && trees.length <= limit - 1; i++) { const txid = replacements[i][1]; - const chainRoot = this.chainMap.get(txid) || ''; - if (chainRoot === afterChain) { + const treeId = this.treeMap.get(txid) || ''; + if (treeId === afterTree) { ready = true; } else if (ready) { - if (!used.has(chainRoot)) { - const chain = this.rbfChains.get(chainRoot); - used.add(chainRoot); - if (chain && (!onlyFullRbf || chain.slice(0, -1).some(entry => !entry.tx.rbf))) { - chains.push(chain); + if (!used.has(treeId)) { + const tree = this.rbfTrees.get(treeId); + used.add(treeId); + if (tree && (!onlyFullRbf || tree.fullRbf)) { + trees.push(tree); } } } } - return chains; + return trees; } - // get map of rbf chains that have been updated since the last call - public getRbfChanges(): { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} { - const changes: { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} = { - chains: {}, + // get map of rbf trees that have been updated since the last call + public getRbfChanges(): { trees: {[id: string]: RbfTree }, map: { [txid: string]: string }} { + const changes: { trees: {[id: string]: RbfTree }, map: { [txid: string]: string }} = { + trees: {}, map: {}, }; - this.dirtyChains.forEach(root => { - const chain = this.rbfChains.get(root); - if (chain) { - changes.chains[root] = chain; - chain.forEach(entry => { - changes.map[entry.tx.txid] = root; + this.dirtyTrees.forEach(id => { + const tree = this.rbfTrees.get(id); + if (tree) { + changes.trees[id] = tree; + this.getTransactionsInTree(tree).forEach(tx => { + changes.map[tx.txid] = id; }); } }); - this.dirtyChains = new Set(); + this.dirtyTrees = new Set(); return changes; } public mined(txid): void { - const chainRoot = this.chainMap.get(txid) - if (chainRoot && this.rbfChains.has(chainRoot)) { - const chain = this.rbfChains.get(chainRoot); - if (chain) { - const chainEntry = chain.find(entry => entry.tx.txid === txid); - if (chainEntry) { - chainEntry.mined = true; - } - this.dirtyChains.add(chainRoot); + const treeId = this.treeMap.get(txid); + if (treeId && this.rbfTrees.has(treeId)) { + const tree = this.rbfTrees.get(treeId); + if (tree) { + this.setTreeMined(tree, txid); + tree.mined = true; + this.dirtyTrees.add(treeId); } } this.evict(txid); @@ -155,20 +179,45 @@ class RbfCache { if (!this.replacedBy.has(txid)) { const replaces = this.replaces.get(txid); this.replaces.delete(txid); - this.chainMap.delete(txid); + this.treeMap.delete(txid); this.txs.delete(txid); this.expiring.delete(txid); for (const tx of (replaces || [])) { // recursively remove prior versions from the cache this.replacedBy.delete(tx); - // if this is the root of a chain, remove that too - if (this.chainMap.get(tx) === tx) { - this.rbfChains.delete(tx); + // if this is the id of a tree, remove that too + if (this.treeMap.get(tx) === tx) { + this.rbfTrees.delete(tx); } this.remove(tx); } } } + + private updateTreeMap(newId: string, tree: RbfTree): void { + this.treeMap.set(tree.tx.txid, newId); + tree.replaces.forEach(subtree => { + this.updateTreeMap(newId, subtree); + }); + } + + private getTransactionsInTree(tree: RbfTree, txs: RbfTransaction[] = []): RbfTransaction[] { + txs.push(tree.tx); + tree.replaces.forEach(subtree => { + this.getTransactionsInTree(subtree, txs); + }); + return txs; + } + + private setTreeMined(tree: RbfTree, txid: string): void { + if (tree.tx.txid === txid) { + tree.tx.mined = true; + } else { + tree.replaces.forEach(subtree => { + this.setTreeMined(subtree, txid); + }); + } + } } export default new RbfCache(); diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 71ed473a8..33649b5c2 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -289,9 +289,9 @@ class WebsocketHandler { const rbfChanges = rbfCache.getRbfChanges(); let rbfReplacements; let fullRbfReplacements; - if (Object.keys(rbfChanges.chains).length) { - rbfReplacements = rbfCache.getRbfChains(false); - fullRbfReplacements = rbfCache.getRbfChains(true); + if (Object.keys(rbfChanges.trees).length) { + rbfReplacements = rbfCache.getRbfTrees(false); + fullRbfReplacements = rbfCache.getRbfTrees(true); } const recommendedFees = feeApi.getRecommendedFee(); @@ -415,20 +415,16 @@ class WebsocketHandler { response['utxoSpent'] = outspends; } - if (rbfTransactions[client['track-tx']]) { - for (const rbfTransaction in rbfTransactions) { - if (client['track-tx'] === rbfTransaction) { - response['rbfTransaction'] = { - txid: rbfTransactions[rbfTransaction].txid, - }; - break; - } + const rbfReplacedBy = rbfCache.getReplacedBy(client['track-tx']); + if (rbfReplacedBy) { + response['rbfTransaction'] = { + txid: rbfReplacedBy, } } const rbfChange = rbfChanges.map[client['track-tx']]; if (rbfChange) { - response['rbfInfo'] = rbfChanges.chains[rbfChange]; + response['rbfInfo'] = rbfChanges.trees[rbfChange]; } } diff --git a/frontend/src/app/components/rbf-list/rbf-list.component.html b/frontend/src/app/components/rbf-list/rbf-list.component.html index 427ab3acf..eebb7e152 100644 --- a/frontend/src/app/components/rbf-list/rbf-list.component.html +++ b/frontend/src/app/components/rbf-list/rbf-list.component.html @@ -17,37 +17,22 @@
-
- -
+
+ +

- - Mined - Full RBF + Mined + Full RBF +

- -
- +
+
-
+

there are no replacements in the mempool yet!

diff --git a/frontend/src/app/components/rbf-list/rbf-list.component.scss b/frontend/src/app/components/rbf-list/rbf-list.component.scss index fa8ebc1f1..792bb8836 100644 --- a/frontend/src/app/components/rbf-list/rbf-list.component.scss +++ b/frontend/src/app/components/rbf-list/rbf-list.component.scss @@ -4,13 +4,14 @@ margin-top: 13px; } -.rbf-chains { +.rbf-trees { .info { display: flex; flex-direction: row; justify-content: space-between; align-items: baseline; margin: 0; + margin-bottom: 0.5em; .type { .badge { @@ -19,27 +20,10 @@ } } - .chain { + .tree { margin-bottom: 1em; } - .txids { - display: flex; - flex-direction: row; - align-items: baseline; - justify-content: space-between; - margin-bottom: 2px; - - .txid { - flex-basis: 0; - flex-grow: 1; - - &.right { - text-align: right; - } - } - } - .timeline-wrapper.mined { border: solid 4px #1a9436; } diff --git a/frontend/src/app/components/rbf-list/rbf-list.component.ts b/frontend/src/app/components/rbf-list/rbf-list.component.ts index b40dbaf16..a86dbcd1a 100644 --- a/frontend/src/app/components/rbf-list/rbf-list.component.ts +++ b/frontend/src/app/components/rbf-list/rbf-list.component.ts @@ -3,7 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { BehaviorSubject, EMPTY, merge, Observable, Subscription } from 'rxjs'; import { catchError, switchMap, tap } from 'rxjs/operators'; import { WebsocketService } from 'src/app/services/websocket.service'; -import { RbfInfo } from '../../interfaces/node-api.interface'; +import { RbfTree } from '../../interfaces/node-api.interface'; import { ApiService } from '../../services/api.service'; import { StateService } from '../../services/state.service'; @@ -14,14 +14,12 @@ import { StateService } from '../../services/state.service'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class RbfList implements OnInit, OnDestroy { - rbfChains$: Observable; - fromChainSubject = new BehaviorSubject(null); + rbfTrees$: Observable; + nextRbfSubject = new BehaviorSubject(null); urlFragmentSubscription: Subscription; fullRbfEnabled: boolean; fullRbf: boolean; isLoading = true; - firstChainId: string; - lastChainId: string; constructor( private route: ActivatedRoute, @@ -37,13 +35,13 @@ export class RbfList implements OnInit, OnDestroy { this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => { this.fullRbf = (fragment === 'fullrbf'); this.websocketService.startTrackRbf(this.fullRbf ? 'fullRbf' : 'all'); - this.fromChainSubject.next(this.firstChainId); + this.nextRbfSubject.next(null); }); - this.rbfChains$ = merge( - this.fromChainSubject.pipe( - switchMap((fromChainId) => { - return this.apiService.getRbfList$(this.fullRbf, fromChainId || undefined) + this.rbfTrees$ = merge( + this.nextRbfSubject.pipe( + switchMap(() => { + return this.apiService.getRbfList$(this.fullRbf); }), catchError((e) => { return EMPTY; @@ -52,11 +50,8 @@ export class RbfList implements OnInit, OnDestroy { this.stateService.rbfLatest$ ) .pipe( - tap((result: RbfInfo[][]) => { + tap(() => { this.isLoading = false; - if (result && result.length && result[0].length) { - this.lastChainId = result[result.length - 1][0].tx.txid; - } }) ); } @@ -68,16 +63,16 @@ export class RbfList implements OnInit, OnDestroy { }); } - isFullRbf(chain: RbfInfo[]): boolean { - return chain.slice(0, -1).some(entry => !entry.tx.rbf); + isFullRbf(tree: RbfTree): boolean { + return tree.fullRbf; } - isMined(chain: RbfInfo[]): boolean { - return chain.some(entry => entry.mined); + isMined(tree: RbfTree): boolean { + return tree.mined; } // pageChange(page: number) { - // this.fromChainSubject.next(this.lastChainId); + // this.fromTreeSubject.next(this.lastTreeId); // } ngOnDestroy(): void { diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html index 13f5a567c..069d63357 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html @@ -1,31 +1,54 @@ -
-
-
- -
-
- -
-
-
-
-
-
- -
-
-
-
-
- -
-
- {{ replacement.tx.fee / (replacement.tx.vsize) | feeRounding }} sat/vB -
-
+
+
+
+
+ +
+ +
+
+ +
+
+
+
+
+
+ + +
+
+ +
+
+ {{ cell.replacement.tx.fee / (cell.replacement.tx.vsize) | feeRounding }} sat/vB +
+
+ + +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+ + +
+
+ + + + + +
\ No newline at end of file diff --git a/frontend/src/app/components/clock-face/clock-face.component.scss b/frontend/src/app/components/clock-face/clock-face.component.scss index 60b2c4eba..d671341a6 100644 --- a/frontend/src/app/components/clock-face/clock-face.component.scss +++ b/frontend/src/app/components/clock-face/clock-face.component.scss @@ -17,4 +17,52 @@ fill: #11131f; } } + + .gnomon { + transform-origin: center; + stroke-linejoin: round; + + &.minute { + fill:#80C2E1; + stroke:#80C2E1; + stroke-width: 2px; + } + + &.hour { + fill: #105fb0; + stroke: #105fb0; + stroke-width: 6px; + } + } + + .tick { + transform-origin: center; + fill: none; + stroke: white; + stroke-width: 2px; + + &.minor { + stroke-opacity: 0.5; + } + + &.very.major { + stroke-width: 4px; + } + } + + .block-segment { + fill: none; + stroke: url(#dial-gradient); + stroke-width: 18px; + } + + .dial-segment { + fill: none; + stroke: white; + stroke-width: 2px; + } + + .dial-gradient-img { + transform-origin: center; + } } \ No newline at end of file diff --git a/frontend/src/app/components/clock-face/clock-face.component.ts b/frontend/src/app/components/clock-face/clock-face.component.ts index c63ea56ea..9c373a50d 100644 --- a/frontend/src/app/components/clock-face/clock-face.component.ts +++ b/frontend/src/app/components/clock-face/clock-face.component.ts @@ -1,15 +1,55 @@ -import { Component, Input, OnChanges } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit } from '@angular/core'; +import { Subscription, tap, timer } from 'rxjs'; +import { WebsocketService } from '../../services/websocket.service'; +import { StateService } from '../../services/state.service'; @Component({ selector: 'app-clock-face', templateUrl: './clock-face.component.html', styleUrls: ['./clock-face.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ClockFaceComponent implements OnChanges { +export class ClockFaceComponent implements OnInit, OnChanges, OnDestroy { @Input() size: number = 300; - faceStyle; - constructor() {} + blocksSubscription: Subscription; + timeSubscription: Subscription; + + faceStyle; + dialPath; + blockTimes = []; + segments = []; + hours: number = 0; + minutes: number = 0; + minorTicks: number[] = []; + majorTicks: number[] = []; + + constructor( + public stateService: StateService, + private websocketService: WebsocketService, + private cd: ChangeDetectorRef + ) { + this.updateTime(); + this.makeTicks(); + } + + ngOnInit(): void { + this.timeSubscription = timer(0, 250).pipe( + tap(() => { + this.updateTime(); + }) + ).subscribe(); + this.websocketService.want(['blocks']); + this.blocksSubscription = this.stateService.blocks$ + .subscribe(([block]) => { + if (block) { + this.blockTimes.push([block.height, new Date(block.timestamp * 1000)]); + // using block-reported times, so ensure they are sorted chronologically + this.blockTimes = this.blockTimes.sort((a, b) => a[1].getTime() - b[1].getTime()); + this.updateSegments(); + } + }); + } ngOnChanges(): void { this.faceStyle = { @@ -17,4 +57,93 @@ export class ClockFaceComponent implements OnChanges { height: `${this.size}px`, }; } + + ngOnDestroy(): void { + this.timeSubscription.unsubscribe(); + } + + updateTime(): void { + const now = new Date(); + const seconds = now.getSeconds() + (now.getMilliseconds() / 1000); + this.minutes = (now.getMinutes() + (seconds / 60)) % 60; + this.hours = now.getHours() + (this.minutes / 60); + this.updateSegments(); + } + + updateSegments(): void { + const now = new Date(); + this.blockTimes = this.blockTimes.filter(time => (now.getTime() - time[1].getTime()) <= 3600000); + const tail = new Date(now.getTime() - 3600000); + const hourStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours()); + + const times = [ + ['start', tail], + ...this.blockTimes, + ['end', now], + ]; + const minuteTimes = times.map(time => { + return [time[0], (time[1].getTime() - hourStart.getTime()) / 60000]; + }); + this.segments = []; + const r = 174; + const cx = 192; + const cy = cx; + for (let i = 1; i < minuteTimes.length; i++) { + const arc = this.getArc(minuteTimes[i-1][1], minuteTimes[i][1], r, cx, cy); + if (arc) { + arc.id = minuteTimes[i][0]; + this.segments.push(arc); + } + } + const arc = this.getArc(minuteTimes[0][1], minuteTimes[1][1], r, cx, cy); + if (arc) { + this.dialPath = arc.path; + } + + this.cd.markForCheck(); + } + + getArc(startTime, endTime, r, cx, cy): any { + const startDegrees = (startTime + 0.2) * 6; + const endDegrees = (endTime - 0.2) * 6; + const start = this.getPointOnCircle(startDegrees, r, cx, cy); + const end = this.getPointOnCircle(endDegrees, r, cx, cy); + const arcLength = endDegrees - startDegrees; + // merge gaps and omit lines shorter than 1 degree + if (arcLength >= 1) { + const path = `M ${start.x} ${start.y} A ${r} ${r} 0 ${arcLength > 180 ? 1 : 0} 1 ${end.x} ${end.y}`; + return { + path, + start, + end + }; + } else { + return null; + } + } + + getPointOnCircle(deg, r, cx, cy) { + const modDeg = ((deg % 360) + 360) % 360; + const rad = (modDeg * Math.PI) / 180; + return { + x: cx + (r * Math.sin(rad)), + y: cy - (r * Math.cos(rad)), + }; + } + + makeTicks() { + this.minorTicks = []; + this.majorTicks = []; + for (let i = 1; i < 60; i++) { + if (i % 5 === 0) { + this.majorTicks.push(i * 6); + } else { + this.minorTicks.push(i * 6); + } + } + } + + trackBySegment(index: number, segment) { + return segment.id; + } } diff --git a/frontend/src/app/components/clock/clock.component.scss b/frontend/src/app/components/clock/clock.component.scss index e5904b4f1..a27c62499 100644 --- a/frontend/src/app/components/clock/clock.component.scss +++ b/frontend/src/app/components/clock/clock.component.scss @@ -84,7 +84,7 @@ right: 0; top: 0; bottom: 0; - background: radial-gradient(transparent 0%, transparent 48%, #11131f 62%, #11131f 100%); + background: radial-gradient(transparent 0%, transparent 44%, #11131f 58%, #11131f 100%); } .block-cube { diff --git a/frontend/src/app/components/clock/clock.component.ts b/frontend/src/app/components/clock/clock.component.ts index 7aa875695..c804860af 100644 --- a/frontend/src/app/components/clock/clock.component.ts +++ b/frontend/src/app/components/clock/clock.component.ts @@ -66,7 +66,7 @@ export class ClockComponent implements OnInit { resizeCanvas(): void { this.chainWidth = window.innerWidth; this.chainHeight = Math.max(60, window.innerHeight / 8); - this.clockSize = Math.min(500, window.innerWidth, window.innerHeight - (1.4 * this.chainHeight)); + this.clockSize = Math.min(800, window.innerWidth, window.innerHeight - (1.4 * this.chainHeight)); const size = Math.ceil(this.clockSize / 75) * 75; const margin = (this.clockSize - size) / 2; this.blockSizerStyle = { diff --git a/frontend/src/app/components/clockchain/clockchain.component.scss b/frontend/src/app/components/clockchain/clockchain.component.scss index 0b01adc26..acff1e725 100644 --- a/frontend/src/app/components/clockchain/clockchain.component.scss +++ b/frontend/src/app/components/clockchain/clockchain.component.scss @@ -1,6 +1,6 @@ .divider { position: absolute; - left: -1px; + left: -0.5px; top: 0; .divider-line { stroke: white; diff --git a/frontend/src/app/components/clockchain/clockchain.component.ts b/frontend/src/app/components/clockchain/clockchain.component.ts index addc22948..ab9220c54 100644 --- a/frontend/src/app/components/clockchain/clockchain.component.ts +++ b/frontend/src/app/components/clockchain/clockchain.component.ts @@ -39,8 +39,8 @@ export class ClockchainComponent implements OnInit, OnChanges, OnDestroy { }); this.connectionStateSubscription = this.stateService.connectionState$.subscribe(state => { this.connected = (state === 2); - }) - firstValueFrom(this.stateService.chainTip$).then(tip => { + }); + firstValueFrom(this.stateService.chainTip$).then(() => { this.loadingTip = false; }); } 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 6877823f5..6267eed21 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -27,7 +27,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { @Input() minimal: boolean = false; @Input() blockWidth: number = 125; @Input() count: number = null; - + specialBlocks = specialBlocks; mempoolBlocks: MempoolBlock[] = []; mempoolEmptyBlocks: MempoolBlock[] = this.mountEmptyBlocks(); diff --git a/frontend/src/resources/clock/gradient.png b/frontend/src/resources/clock/gradient.png new file mode 100644 index 0000000000000000000000000000000000000000..372105fbd128b7428d4e31f393bc70587d0b44d3 GIT binary patch literal 38328 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

141E;Qoq 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 literal 0 HcmV?d00001 From d3a7950e785801577edf5b22dc3f8b8cb4378590 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 19 Apr 2023 09:41:53 +0900 Subject: [PATCH 070/113] Add clock statistics --- .../clock-face/clock-face.component.scss | 1 + .../app/components/clock/clock.component.html | 28 ++++++++++++++++ .../app/components/clock/clock.component.scss | 33 +++++++++++++++++++ .../app/components/clock/clock.component.ts | 12 +++++-- frontend/src/styles.scss | 4 +++ 5 files changed, 75 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/clock-face/clock-face.component.scss b/frontend/src/app/components/clock-face/clock-face.component.scss index d671341a6..1ca2ce914 100644 --- a/frontend/src/app/components/clock-face/clock-face.component.scss +++ b/frontend/src/app/components/clock-face/clock-face.component.scss @@ -40,6 +40,7 @@ fill: none; stroke: white; stroke-width: 2px; + stroke-linecap: butt; &.minor { stroke-opacity: 0.5; diff --git a/frontend/src/app/components/clock/clock.component.html b/frontend/src/app/components/clock/clock.component.html index 74e06418d..ee62dd521 100644 --- a/frontend/src/app/components/clock/clock.component.html +++ b/frontend/src/app/components/clock/clock.component.html @@ -31,4 +31,32 @@

+
+

fiat price

+

+ +

+
+
+

priority rate

+

{{ recommendedFees.fastestFee }} sat/vB

+
+
+

+

block size

+
+
+

{{ block.tx_count | number }}

+

transactions

+
+ +
+

+

memory usage

+
+
+

{{ mempoolInfo.size | number }}

+

unconfirmed

+
+
\ No newline at end of file diff --git a/frontend/src/app/components/clock/clock.component.scss b/frontend/src/app/components/clock/clock.component.scss index a27c62499..3ccf6c0df 100644 --- a/frontend/src/app/components/clock/clock.component.scss +++ b/frontend/src/app/components/clock/clock.component.scss @@ -10,6 +10,7 @@ flex-direction: column; justify-content: flex-start; + --chain-height: 60px; --clock-width: 300px; .clockchain-bar, .clock-face { @@ -37,6 +38,38 @@ align-items: center; z-index: 1; } + + .stats { + position: absolute; + z-index: 3; + + p { + margin: 0; + font-size: calc(0.05 * var(--clock-width)); + line-height: calc(0.07 * var(--clock-width)); + opacity: 0.8; + + ::ng-deep .symbol { + font-size: inherit; + color: white; + } + } + + &.top { + top: calc(var(--chain-height) + 2%); + } + &.bottom { + bottom: 2%; + } + &.left { + left: 5%; + } + &.right { + right: 5%; + text-align: end; + text-align: right; + } + } } .title-wrapper { diff --git a/frontend/src/app/components/clock/clock.component.ts b/frontend/src/app/components/clock/clock.component.ts index c804860af..bc4e5625e 100644 --- a/frontend/src/app/components/clock/clock.component.ts +++ b/frontend/src/app/components/clock/clock.component.ts @@ -1,8 +1,9 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, Input, OnInit } from '@angular/core'; -import { Subscription } from 'rxjs'; +import { Observable, Subscription } from 'rxjs'; import { StateService } from '../../services/state.service'; import { BlockExtended } from '../../interfaces/node-api.interface'; import { WebsocketService } from '../../services/websocket.service'; +import { MempoolInfo, Recommendedfees } from '../../interfaces/websocket.interface'; @Component({ selector: 'app-clock', @@ -11,8 +12,10 @@ import { WebsocketService } from '../../services/websocket.service'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ClockComponent implements OnInit { - @Input() mode: string = 'block'; + @Input() mode: 'block' | 'mempool' = 'block'; blocksSubscription: Subscription; + recommendedFees$: Observable; + mempoolInfo$: Observable; block: BlockExtended; clockSize: number = 300; chainWidth: number = 384; @@ -47,6 +50,8 @@ export class ClockComponent implements OnInit { this.cd.markForCheck(); } }); + this.recommendedFees$ = this.stateService.recommendedFees$; + this.mempoolInfo$ = this.stateService.mempoolInfo$; } getStyleForBlock(block: BlockExtended) { @@ -75,7 +80,8 @@ export class ClockComponent implements OnInit { height: `${size}px`, }; this.wrapperStyle = { - '--clock-width': `${this.clockSize}px` + '--clock-width': `${this.clockSize}px`, + '--chain-height': `${this.chainHeight}px` }; this.cd.markForCheck(); } diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index fbaaa5ed2..e58bcdc6a 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -285,6 +285,10 @@ body { color: #fff; } +.white-color { + color: white; +} + .green-color { color: #3bcc49; } From 056d61a28d47a9008793b22409fe03ebc46c1b69 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 19 Apr 2023 10:09:44 +0900 Subject: [PATCH 071/113] clock i18n --- .../app/components/clock/clock.component.html | 19 +++++++++++-------- .../app/components/clock/clock.component.scss | 4 ++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/components/clock/clock.component.html b/frontend/src/app/components/clock/clock.component.html index ee62dd521..c47133495 100644 --- a/frontend/src/app/components/clock/clock.component.html +++ b/frontend/src/app/components/clock/clock.component.html @@ -32,31 +32,34 @@
-

fiat price

+

fiat price

-

priority rate

-

{{ recommendedFees.fastestFee }} sat/vB

+

priority rate

+

{{ recommendedFees.fastestFee }} sat/vB

-

block size

+

block size

-

{{ block.tx_count | number }}

-

transactions

+

+ + {{ i }} transaction + {{ i }} transactions +

-

memory usage

+

memory usage

{{ mempoolInfo.size | number }}

-

unconfirmed

+

unconfirmed

\ No newline at end of file diff --git a/frontend/src/app/components/clock/clock.component.scss b/frontend/src/app/components/clock/clock.component.scss index 3ccf6c0df..cffe3ee69 100644 --- a/frontend/src/app/components/clock/clock.component.scss +++ b/frontend/src/app/components/clock/clock.component.scss @@ -49,6 +49,10 @@ line-height: calc(0.07 * var(--clock-width)); opacity: 0.8; + &.force-wrap { + word-spacing: 1000px; + } + ::ng-deep .symbol { font-size: inherit; color: white; From fdb0cf509d99b851d8739d421935db051ce6534f Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 20 Apr 2023 00:30:55 +0900 Subject: [PATCH 072/113] query param toggle for clock stats --- .../app/components/clock/clock.component.html | 58 ++++++++++--------- .../app/components/clock/clock.component.ts | 9 +++ 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/frontend/src/app/components/clock/clock.component.html b/frontend/src/app/components/clock/clock.component.html index c47133495..e444664c9 100644 --- a/frontend/src/app/components/clock/clock.component.html +++ b/frontend/src/app/components/clock/clock.component.html @@ -31,35 +31,37 @@
-
-

fiat price

-

- -

-
-
-

priority rate

-

{{ recommendedFees.fastestFee }} sat/vB

-
-
-

-

block size

-
-
-

- - {{ i }} transaction - {{ i }} transactions -

-
- -
-

-

memory usage

+ +
+

fiat price

+

+ +

-
-

{{ mempoolInfo.size | number }}

-

unconfirmed

+
+

priority rate

+

{{ recommendedFees.fastestFee }} sat/vB

+
+

+

block size

+
+
+

+ + {{ i }} transaction + {{ i }} transactions +

+
+ +
+

+

memory usage

+
+
+

{{ mempoolInfo.size | number }}

+

unconfirmed

+
+
\ No newline at end of file diff --git a/frontend/src/app/components/clock/clock.component.ts b/frontend/src/app/components/clock/clock.component.ts index bc4e5625e..f66ba5c15 100644 --- a/frontend/src/app/components/clock/clock.component.ts +++ b/frontend/src/app/components/clock/clock.component.ts @@ -4,6 +4,7 @@ import { StateService } from '../../services/state.service'; import { BlockExtended } from '../../interfaces/node-api.interface'; import { WebsocketService } from '../../services/websocket.service'; import { MempoolInfo, Recommendedfees } from '../../interfaces/websocket.interface'; +import { ActivatedRoute } from '@angular/router'; @Component({ selector: 'app-clock', @@ -13,6 +14,7 @@ import { MempoolInfo, Recommendedfees } from '../../interfaces/websocket.interfa }) export class ClockComponent implements OnInit { @Input() mode: 'block' | 'mempool' = 'block'; + hideStats: boolean = false; blocksSubscription: Subscription; recommendedFees$: Observable; mempoolInfo$: Observable; @@ -36,12 +38,18 @@ export class ClockComponent implements OnInit { constructor( public stateService: StateService, private websocketService: WebsocketService, + private route: ActivatedRoute, private cd: ChangeDetectorRef, ) {} ngOnInit(): void { this.resizeCanvas(); this.websocketService.want(['blocks']); + + this.route.queryParams.subscribe((params) => { + this.hideStats = params && params.stats === 'false'; + }); + this.blocksSubscription = this.stateService.blocks$ .subscribe(([block]) => { if (block) { @@ -50,6 +58,7 @@ export class ClockComponent implements OnInit { this.cd.markForCheck(); } }); + this.recommendedFees$ = this.stateService.recommendedFees$; this.mempoolInfo$ = this.stateService.mempoolInfo$; } From 1fccd70379efb0919147ed23b63f4a8f36e61443 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 20 Apr 2023 05:30:24 +0900 Subject: [PATCH 073/113] clock size query params --- .../app/components/clock/clock.component.ts | 26 ++++++++++++------- .../clockchain/clockchain.component.html | 5 +++- .../clockchain/clockchain.component.scss | 3 +-- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/components/clock/clock.component.ts b/frontend/src/app/components/clock/clock.component.ts index f66ba5c15..dea2de4c8 100644 --- a/frontend/src/app/components/clock/clock.component.ts +++ b/frontend/src/app/components/clock/clock.component.ts @@ -25,6 +25,8 @@ export class ClockComponent implements OnInit { blockStyle; blockSizerStyle; wrapperStyle; + limitWidth: number; + limitHeight: number; gradientColors = { '': ['#9339f4', '#105fb0'], @@ -40,16 +42,18 @@ export class ClockComponent implements OnInit { private websocketService: WebsocketService, private route: ActivatedRoute, private cd: ChangeDetectorRef, - ) {} + ) { + this.route.queryParams.subscribe((params) => { + this.hideStats = params && params.stats === 'false'; + this.limitWidth = Number.parseInt(params.width) || null; + this.limitHeight = Number.parseInt(params.height) || null; + }); + } ngOnInit(): void { this.resizeCanvas(); this.websocketService.want(['blocks']); - this.route.queryParams.subscribe((params) => { - this.hideStats = params && params.stats === 'false'; - }); - this.blocksSubscription = this.stateService.blocks$ .subscribe(([block]) => { if (block) { @@ -78,9 +82,11 @@ export class ClockComponent implements OnInit { @HostListener('window:resize', ['$event']) resizeCanvas(): void { - this.chainWidth = window.innerWidth; - this.chainHeight = Math.max(60, window.innerHeight / 8); - this.clockSize = Math.min(800, window.innerWidth, window.innerHeight - (1.4 * this.chainHeight)); + const windowWidth = this.limitWidth || window.innerWidth; + const windowHeight = this.limitHeight || window.innerHeight; + this.chainWidth = windowWidth; + this.chainHeight = Math.max(60, windowHeight / 8); + this.clockSize = Math.min(800, windowWidth, windowHeight - (1.4 * this.chainHeight)); const size = Math.ceil(this.clockSize / 75) * 75; const margin = (this.clockSize - size) / 2; this.blockSizerStyle = { @@ -90,7 +96,9 @@ export class ClockComponent implements OnInit { }; this.wrapperStyle = { '--clock-width': `${this.clockSize}px`, - '--chain-height': `${this.chainHeight}px` + '--chain-height': `${this.chainHeight}px`, + 'width': this.limitWidth ? `${this.limitWidth}px` : undefined, + 'height': this.limitHeight ? `${this.limitHeight}px` : undefined, }; this.cd.markForCheck(); } diff --git a/frontend/src/app/components/clockchain/clockchain.component.html b/frontend/src/app/components/clockchain/clockchain.component.html index 3a28296ca..7ef320333 100644 --- a/frontend/src/app/components/clockchain/clockchain.component.html +++ b/frontend/src/app/components/clockchain/clockchain.component.html @@ -1,4 +1,7 @@ -
+
diff --git a/frontend/src/app/components/clockchain/clockchain.component.scss b/frontend/src/app/components/clockchain/clockchain.component.scss index acff1e725..6ffc144e9 100644 --- a/frontend/src/app/components/clockchain/clockchain.component.scss +++ b/frontend/src/app/components/clockchain/clockchain.component.scss @@ -21,9 +21,8 @@ .position-container { position: absolute; - left: 0; + left: 50%; top: 0; - transform: translateX(50vw); } .black-background { From 19353fc1d05b087f333a73bc63f56e749e80321c Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 21 Apr 2023 23:13:19 +0900 Subject: [PATCH 074/113] rename clock components --- frontend/src/app/app-routing.module.ts | 12 ++++++------ .../components/clock-face/clock-face.component.ts | 1 - .../src/app/components/clock/clock-a.component.ts | 7 ------- .../src/app/components/clock/clock-b.component.ts | 7 ------- ...b.component.html => clock-mempool.component.html} | 0 .../app/components/clock/clock-mempool.component.ts | 7 +++++++ ...k-a.component.html => clock-mined.component.html} | 0 .../app/components/clock/clock-mined.component.ts | 7 +++++++ .../src/app/components/clock/clock.component.html | 4 ++-- .../src/app/components/clock/clock.component.scss | 2 +- frontend/src/app/shared/shared.module.ts | 12 ++++++------ 11 files changed, 29 insertions(+), 30 deletions(-) delete mode 100644 frontend/src/app/components/clock/clock-a.component.ts delete mode 100644 frontend/src/app/components/clock/clock-b.component.ts rename frontend/src/app/components/clock/{clock-b.component.html => clock-mempool.component.html} (100%) create mode 100644 frontend/src/app/components/clock/clock-mempool.component.ts rename frontend/src/app/components/clock/{clock-a.component.html => clock-mined.component.html} (100%) create mode 100644 frontend/src/app/components/clock/clock-mined.component.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 0146fb535..0fe496d3e 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -4,8 +4,8 @@ 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 { ClockAComponent } from './components/clock/clock-a.component'; -import { ClockBComponent } from './components/clock/clock-b.component'; +import { ClockMinedComponent as ClockMinedComponent } from './components/clock/clock-mined.component'; +import { ClockMempoolComponent as ClockMempoolComponent } from './components/clock/clock-mempool.component'; import { AddressComponent } from './components/address/address.component'; import { MasterPageComponent } from './components/master-page/master-page.component'; import { AboutComponent } from './components/about/about.component'; @@ -358,12 +358,12 @@ let routes: Routes = [ ], }, { - path: 'clock-face-a', - component: ClockAComponent, + path: 'clock-mined', + component: ClockMinedComponent, }, { - path: 'clock-face-b', - component: ClockBComponent, + path: 'clock-mempool', + component: ClockMempoolComponent, }, { path: 'status', diff --git a/frontend/src/app/components/clock-face/clock-face.component.ts b/frontend/src/app/components/clock-face/clock-face.component.ts index 9c373a50d..01e439e8e 100644 --- a/frontend/src/app/components/clock-face/clock-face.component.ts +++ b/frontend/src/app/components/clock-face/clock-face.component.ts @@ -39,7 +39,6 @@ export class ClockFaceComponent implements OnInit, OnChanges, OnDestroy { this.updateTime(); }) ).subscribe(); - this.websocketService.want(['blocks']); this.blocksSubscription = this.stateService.blocks$ .subscribe(([block]) => { if (block) { diff --git a/frontend/src/app/components/clock/clock-a.component.ts b/frontend/src/app/components/clock/clock-a.component.ts deleted file mode 100644 index 50f834bad..000000000 --- a/frontend/src/app/components/clock/clock-a.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-clock-a', - templateUrl: './clock-a.component.html', -}) -export class ClockAComponent {} diff --git a/frontend/src/app/components/clock/clock-b.component.ts b/frontend/src/app/components/clock/clock-b.component.ts deleted file mode 100644 index b47c9dba3..000000000 --- a/frontend/src/app/components/clock/clock-b.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-clock-b', - templateUrl: './clock-b.component.html', -}) -export class ClockBComponent {} diff --git a/frontend/src/app/components/clock/clock-b.component.html b/frontend/src/app/components/clock/clock-mempool.component.html similarity index 100% rename from frontend/src/app/components/clock/clock-b.component.html rename to frontend/src/app/components/clock/clock-mempool.component.html diff --git a/frontend/src/app/components/clock/clock-mempool.component.ts b/frontend/src/app/components/clock/clock-mempool.component.ts new file mode 100644 index 000000000..7e99cc08b --- /dev/null +++ b/frontend/src/app/components/clock/clock-mempool.component.ts @@ -0,0 +1,7 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-clock-mempool', + templateUrl: './clock-mempool.component.html', +}) +export class ClockMempoolComponent {} diff --git a/frontend/src/app/components/clock/clock-a.component.html b/frontend/src/app/components/clock/clock-mined.component.html similarity index 100% rename from frontend/src/app/components/clock/clock-a.component.html rename to frontend/src/app/components/clock/clock-mined.component.html diff --git a/frontend/src/app/components/clock/clock-mined.component.ts b/frontend/src/app/components/clock/clock-mined.component.ts new file mode 100644 index 000000000..b26815ac6 --- /dev/null +++ b/frontend/src/app/components/clock/clock-mined.component.ts @@ -0,0 +1,7 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-clock-mined', + templateUrl: './clock-mined.component.html', +}) +export class ClockMinedComponent {} diff --git a/frontend/src/app/components/clock/clock.component.html b/frontend/src/app/components/clock/clock.component.html index e444664c9..8da274a5c 100644 --- a/frontend/src/app/components/clock/clock.component.html +++ b/frontend/src/app/components/clock/clock.component.html @@ -42,11 +42,11 @@

priority rate

{{ recommendedFees.fastestFee }} sat/vB

-
+

block size

-
+

{{ i }} transaction diff --git a/frontend/src/app/components/clock/clock.component.scss b/frontend/src/app/components/clock/clock.component.scss index cffe3ee69..2af5317a3 100644 --- a/frontend/src/app/components/clock/clock.component.scss +++ b/frontend/src/app/components/clock/clock.component.scss @@ -50,7 +50,7 @@ opacity: 0.8; &.force-wrap { - word-spacing: 1000px; + word-spacing: 10000px; } ::ng-deep .symbol { diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 21cbb17a8..6e8d8d0f2 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -94,8 +94,8 @@ import { MempoolBlockOverviewComponent } from '../components/mempool-block-overv import { ClockchainComponent } from '../components/clockchain/clockchain.component'; import { ClockFaceComponent } from '../components/clock-face/clock-face.component'; import { ClockComponent } from '../components/clock/clock.component'; -import { ClockAComponent } from '../components/clock/clock-a.component'; -import { ClockBComponent } from '../components/clock/clock-b.component'; +import { ClockMinedComponent } from '../components/clock/clock-mined.component'; +import { ClockMempoolComponent } from '../components/clock/clock-mempool.component'; @NgModule({ declarations: [ @@ -183,8 +183,8 @@ import { ClockBComponent } from '../components/clock/clock-b.component'; MempoolBlockOverviewComponent, ClockchainComponent, ClockComponent, - ClockAComponent, - ClockBComponent, + ClockMinedComponent, + ClockMempoolComponent, ClockFaceComponent, ], imports: [ @@ -297,8 +297,8 @@ import { ClockBComponent } from '../components/clock/clock-b.component'; MempoolBlockOverviewComponent, ClockchainComponent, ClockComponent, - ClockAComponent, - ClockBComponent, + ClockMinedComponent, + ClockMempoolComponent, ClockFaceComponent, ] }) From 07dddd857bc3840fedfe8f24754ba7a177a6e098 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 4 May 2023 17:49:46 -0400 Subject: [PATCH 075/113] resize clock labels --- .../app/components/clock/clock.component.html | 16 ++++++++-------- .../app/components/clock/clock.component.scss | 9 +++++++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/components/clock/clock.component.html b/frontend/src/app/components/clock/clock.component.html index 8da274a5c..b3ca53c60 100644 --- a/frontend/src/app/components/clock/clock.component.html +++ b/frontend/src/app/components/clock/clock.component.html @@ -33,34 +33,34 @@

-

fiat price

+

fiat price

-

priority rate

-

{{ recommendedFees.fastestFee }} sat/vB

+

priority rate

+

{{ recommendedFees.fastestFee + 300 }} sat/vB

-

block size

+

block size

- {{ i }} transaction - {{ i }} transactions + {{ i }} transaction + {{ i }} transactions

-

memory usage

+

memory usage

{{ mempoolInfo.size | number }}

-

unconfirmed

+

unconfirmed

diff --git a/frontend/src/app/components/clock/clock.component.scss b/frontend/src/app/components/clock/clock.component.scss index 2af5317a3..f1d70dcd8 100644 --- a/frontend/src/app/components/clock/clock.component.scss +++ b/frontend/src/app/components/clock/clock.component.scss @@ -45,8 +45,8 @@ p { margin: 0; - font-size: calc(0.05 * var(--clock-width)); - line-height: calc(0.07 * var(--clock-width)); + font-size: calc(0.055 * var(--clock-width)); + line-height: calc(0.05 * var(--clock-width)); opacity: 0.8; &.force-wrap { @@ -59,6 +59,11 @@ } } + .label { + font-size: calc(0.04 * var(--clock-width)); + line-height: calc(0.05 * var(--clock-width)); + } + &.top { top: calc(var(--chain-height) + 2%); } From 9671259f5cee8718a18ac961f695dab005fddabb Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 4 May 2023 17:50:27 -0400 Subject: [PATCH 076/113] clock selected block arrow --- .../blockchain-blocks.component.html | 5 +++++ .../blockchain-blocks.component.scss | 12 ++++++++++++ .../blockchain-blocks/blockchain-blocks.component.ts | 1 + .../src/app/components/clock/clock.component.html | 2 +- .../src/app/components/clock/clock.component.scss | 2 +- .../components/clockchain/clockchain.component.html | 4 ++-- .../components/clockchain/clockchain.component.ts | 1 + .../mempool-blocks/mempool-blocks.component.html | 5 +++++ .../mempool-blocks/mempool-blocks.component.scss | 12 ++++++++++++ .../mempool-blocks/mempool-blocks.component.ts | 1 + 10 files changed, 41 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html index 0a2f0decb..8ea5acef6 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html @@ -3,6 +3,11 @@ *ngIf="static || (loadingBlocks$ | async) === false; else loadingBlocksTemplate">
+
- +
diff --git a/frontend/src/app/components/clock/clock.component.scss b/frontend/src/app/components/clock/clock.component.scss index f1d70dcd8..20baf02ee 100644 --- a/frontend/src/app/components/clock/clock.component.scss +++ b/frontend/src/app/components/clock/clock.component.scss @@ -23,7 +23,7 @@ width: 100%; height: 15.625%; z-index: 2; - overflow: hidden; + // overflow: hidden; // background: #1d1f31; // box-shadow: 0 0 15px #000; } diff --git a/frontend/src/app/components/clockchain/clockchain.component.html b/frontend/src/app/components/clockchain/clockchain.component.html index 7ef320333..169de58d4 100644 --- a/frontend/src/app/components/clockchain/clockchain.component.html +++ b/frontend/src/app/components/clockchain/clockchain.component.html @@ -5,8 +5,8 @@
- - + +
+
  diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.scss b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.scss index 6d1ec326e..40f43a015 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.scss +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.scss @@ -157,4 +157,16 @@ #arrow-up { transform: translateX(70px); } +} + +.spotlight-bottom { + position: absolute; + width: calc(0.6 * var(--block-size)); + height: calc(0.25 * var(--block-size)); + border-left: solid calc(0.3 * var(--block-size)) transparent; + border-bottom: solid calc(0.3 * var(--block-size)) white; + border-right: solid calc(0.3 * var(--block-size)) transparent; + transform: translate(calc(-0.2 * var(--block-size)), calc(1.1 * var(--block-size))); + border-radius: 2px; + z-index: -1; } \ No newline at end of file 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 6267eed21..93498d535 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -27,6 +27,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { @Input() minimal: boolean = false; @Input() blockWidth: number = 125; @Input() count: number = null; + @Input() spotlight: number = 0; specialBlocks = specialBlocks; mempoolBlocks: MempoolBlock[] = []; From f20bfb025be25eba8f37ef095667b8ccc9013dac Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 8 May 2023 08:57:24 -0600 Subject: [PATCH 077/113] fix clock merge conflicts --- .../block-overview-graph.component.ts | 4 ++- .../block-overview-graph/block-scene.ts | 28 +++++++++++++------ .../app/components/clock/clock.component.html | 2 +- .../mempool-block-overview.component.html | 1 + .../mempool-block-overview.component.ts | 1 + .../mempool-blocks.component.html | 26 ----------------- frontend/src/app/graphs/graphs.module.ts | 2 -- frontend/src/index.mempool.html | 2 +- 8 files changed, 27 insertions(+), 39 deletions(-) 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 940939470..15e41f1a7 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 @@ -23,6 +23,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On @Input() unavailable: boolean = false; @Input() auditHighlighting: boolean = false; @Input() blockConversion: Price; + @Input() pixelAlign: boolean = false; @Output() txClickEvent = new EventEmitter<{ tx: TransactionStripped, keyModifier: boolean}>(); @Output() txHoverEvent = new EventEmitter(); @Output() readyEvent = new EventEmitter(); @@ -201,7 +202,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On this.start(); } else { this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, - blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray, highlighting: this.auditHighlighting }); + blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray, + highlighting: this.auditHighlighting, pixelAlign: this.pixelAlign }); this.start(); } } diff --git a/frontend/src/app/components/block-overview-graph/block-scene.ts b/frontend/src/app/components/block-overview-graph/block-scene.ts index 1c0072e31..0cd5c9391 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -15,6 +15,7 @@ export default class BlockScene { gridWidth: number; gridHeight: number; gridSize: number; + pixelAlign: boolean; vbytesPerUnit: number; unitPadding: number; unitWidth: number; @@ -23,19 +24,24 @@ export default class BlockScene { animateUntil = 0; dirty: boolean; - constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting }: + constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting, pixelAlign }: { width: number, height: number, resolution: number, blockLimit: number, - orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean } + orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, pixelAlign: boolean } ) { - this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting }); + this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting, pixelAlign }); } resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void { this.width = width; this.height = height; this.gridSize = this.width / this.gridWidth; - this.unitPadding = Math.max(1, Math.floor(this.gridSize / 2.5)); - this.unitWidth = this.gridSize - (this.unitPadding); + if (this.pixelAlign) { + this.unitPadding = Math.max(1, Math.floor(this.gridSize / 2.5)); + this.unitWidth = this.gridSize - (this.unitPadding); + } else { + this.unitPadding = width / 500; + this.unitWidth = this.gridSize - (this.unitPadding * 2); + } this.dirty = true; if (this.initialised && this.scene) { @@ -209,14 +215,15 @@ export default class BlockScene { this.animateUntil = Math.max(this.animateUntil, tx.setHover(value)); } - private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting }: + private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting, pixelAlign }: { width: number, height: number, resolution: number, blockLimit: number, - orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean } + orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, pixelAlign: boolean } ): void { this.orientation = orientation; this.flip = flip; this.vertexArray = vertexArray; this.highlightingEnabled = highlighting; + this.pixelAlign = pixelAlign; this.scene = { count: 0, @@ -342,7 +349,12 @@ export default class BlockScene { private gridToScreen(position: Square | void): Square { if (position) { const slotSize = (position.s * this.gridSize); - const squareSize = slotSize - (this.unitPadding); + let squareSize; + if (this.pixelAlign) { + squareSize = slotSize - (this.unitPadding); + } else { + squareSize = slotSize - (this.unitPadding * 2); + } // The grid is laid out notionally left-to-right, bottom-to-top, // so we rotate and/or flip the y axis to match the target configuration. diff --git a/frontend/src/app/components/clock/clock.component.html b/frontend/src/app/components/clock/clock.component.html index e54626aa4..914450a79 100644 --- a/frontend/src/app/components/clock/clock.component.html +++ b/frontend/src/app/components/clock/clock.component.html @@ -20,7 +20,7 @@
- +
diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html index 3cb4ff3e8..37c82afad 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html @@ -5,5 +5,6 @@ [blockLimit]="stateService.blockVSize" [orientation]="timeLtr ? 'right' : 'left'" [flip]="true" + [pixelAlign]="pixelAlign" (txClickEvent)="onTxClick($event)" > diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts index 30632a862..540046e13 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts @@ -16,6 +16,7 @@ import { Router } from '@angular/router'; }) export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit { @Input() index: number; + @Input() pixelAlign: boolean = false; @Output() txPreviewEvent = new EventEmitter(); @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html index e405c3cfd..11dc28ad9 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html @@ -40,32 +40,6 @@ () {{ i }} blocks
-
- {{ projectedBlock.feeRange[0] | number:feeRounding }} - {{ projectedBlock.feeRange[projectedBlock.feeRange.length - 1] | number:feeRounding }} sat/vB -
-
- -
-
-
- - {{ i }} transaction - {{ i }} transactions -
-
- - - - - - -
- -
- () - {{ i }} blocks -
-
diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index a7e627736..a4e4f5bfc 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -14,7 +14,6 @@ import { LbtcPegsGraphComponent } from '../components/lbtc-pegs-graph/lbtc-pegs- import { GraphsComponent } from '../components/graphs/graphs.component'; import { StatisticsComponent } from '../components/statistics/statistics.component'; import { MempoolBlockComponent } from '../components/mempool-block/mempool-block.component'; -// import { MempoolBlockOverviewComponent } from '../components/mempool-block-overview/mempool-block-overview.component'; import { PoolRankingComponent } from '../components/pool-ranking/pool-ranking.component'; import { PoolComponent } from '../components/pool/pool.component'; import { TelevisionComponent } from '../components/television/television.component'; @@ -42,7 +41,6 @@ import { CommonModule } from '@angular/common'; BlockFeeRatesGraphComponent, BlockSizesWeightsGraphComponent, FeeDistributionGraphComponent, - // MempoolBlockOverviewComponent, IncomingTransactionsGraphComponent, MempoolGraphComponent, LbtcPegsGraphComponent, diff --git a/frontend/src/index.mempool.html b/frontend/src/index.mempool.html index 02765c0ba..60f1b4421 100644 --- a/frontend/src/index.mempool.html +++ b/frontend/src/index.mempool.html @@ -32,7 +32,7 @@ - + From 47b95af8aed83b5b1cd89b1c3ac3da4ab3be658c Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 8 May 2023 12:44:14 -0600 Subject: [PATCH 078/113] increase range of fee colors --- frontend/src/app/app.constants.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/app/app.constants.ts b/frontend/src/app/app.constants.ts index 8a091706a..f510c6480 100644 --- a/frontend/src/app/app.constants.ts +++ b/frontend/src/app/app.constants.ts @@ -29,6 +29,14 @@ export const mempoolFeeColors = [ 'ba3243', 'b92b48', 'b9254b', + 'b8214d', + 'b71d4f', + 'b61951', + 'b41453', + 'b30e55', + 'b10857', + 'b00259', + 'ae005b', ]; export const chartColors = [ @@ -69,6 +77,7 @@ export const chartColors = [ "#3E2723", "#212121", "#263238", + "#801313", ]; export const poolsColor = { From 5257716e1a528f3e1b959ecc6ad90ab87a999e64 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 8 May 2023 12:53:37 -0600 Subject: [PATCH 079/113] Dynamic fee ranges & legend in mempool graph --- .../mempool-graph/mempool-graph.component.ts | 35 +++++++++++-------- .../statistics/statistics.component.html | 3 +- .../television/television.component.html | 1 - .../app/dashboard/dashboard.component.html | 3 +- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/frontend/src/app/components/mempool-graph/mempool-graph.component.ts b/frontend/src/app/components/mempool-graph/mempool-graph.component.ts index 989fa141e..cc53f425d 100644 --- a/frontend/src/app/components/mempool-graph/mempool-graph.component.ts +++ b/frontend/src/app/components/mempool-graph/mempool-graph.component.ts @@ -23,8 +23,7 @@ import { download, formatterXAxis, formatterXAxisLabel } from '../../shared/grap }) export class MempoolGraphComponent implements OnInit, OnChanges { @Input() data: any[]; - @Input() limitFee = 350; - @Input() limitFilterFee = 1; + @Input() filterSize = 100000; @Input() height: number | string = 200; @Input() top: number | string = 20; @Input() right: number | string = 10; @@ -99,16 +98,20 @@ export class MempoolGraphComponent implements OnInit, OnChanges { } generateArray(mempoolStats: OptimizedMempoolStats[]) { - const finalArray: number[][][] = []; + let finalArray: number[][][] = []; let feesArray: number[][] = []; - const limitFeesTemplate = this.template === 'advanced' ? 26 : 20; - for (let index = limitFeesTemplate; index > -1; index--) { + let maxTier = 0; + for (let index = 37; index > -1; index--) { feesArray = []; mempoolStats.forEach((stats) => { + if (stats.vsizes[index] >= this.filterSize) { + maxTier = Math.max(maxTier, index); + } feesArray.push([stats.added * 1000, stats.vsizes[index] ? stats.vsizes[index] : 0]); }); finalArray.push(feesArray); } + this.feeLimitIndex = maxTier; finalArray.reverse(); return finalArray; } @@ -121,7 +124,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges { const newColors = []; for (let index = 0; index < series.length; index++) { const value = series[index]; - if (index >= this.feeLimitIndex) { + if (index < this.feeLimitIndex) { newColors.push(this.chartColorsOrdered[index]); seriesGraph.push({ zlevel: 0, @@ -371,17 +374,21 @@ export class MempoolGraphComponent implements OnInit, OnChanges { orderLevels() { this.feeLevelsOrdered = []; - for (let i = 0; i < feeLevels.length; i++) { - if (feeLevels[i] === this.limitFilterFee) { - this.feeLimitIndex = i; - } - if (feeLevels[i] <= this.limitFee) { + let maxIndex = Math.min(feeLevels.length, this.feeLimitIndex); + for (let i = 0; i < maxIndex; i++) { if (this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') { - this.feeLevelsOrdered.push(`${(feeLevels[i] / 10).toFixed(1)} - ${(feeLevels[i + 1] / 10).toFixed(1)}`); + if (i === maxIndex - 1) { + this.feeLevelsOrdered.push(`${(feeLevels[i] / 10).toFixed(1)}+`); + } else { + this.feeLevelsOrdered.push(`${(feeLevels[i] / 10).toFixed(1)} - ${(feeLevels[i + 1] / 10).toFixed(1)}`); + } } else { - this.feeLevelsOrdered.push(`${feeLevels[i]} - ${feeLevels[i + 1]}`); + if (i === maxIndex - 1) { + this.feeLevelsOrdered.push(`${feeLevels[i]}+`); + } else { + this.feeLevelsOrdered.push(`${feeLevels[i]} - ${feeLevels[i + 1]}`); + } } - } } this.chartColorsOrdered = chartColors.slice(0, this.feeLevelsOrdered.length); } diff --git a/frontend/src/app/components/statistics/statistics.component.html b/frontend/src/app/components/statistics/statistics.component.html index 30738f591..2133b2615 100644 --- a/frontend/src/app/components/statistics/statistics.component.html +++ b/frontend/src/app/components/statistics/statistics.component.html @@ -84,8 +84,7 @@
-
diff --git a/frontend/src/app/components/television/television.component.html b/frontend/src/app/components/television/television.component.html index 89cf8e5bb..23dd18389 100644 --- a/frontend/src/app/components/television/television.component.html +++ b/frontend/src/app/components/television/television.component.html @@ -3,7 +3,6 @@
From 033e78c0a7d44098e19f764db512ad65ec6c82b9 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 8 May 2023 19:03:39 -0600 Subject: [PATCH 080/113] Optimize main thread processing of GBT updates --- backend/src/api/mempool-blocks.ts | 219 ++++++++++++++----------- backend/src/api/tx-selection-worker.ts | 39 +++-- backend/src/mempool.interfaces.ts | 1 + 3 files changed, 144 insertions(+), 115 deletions(-) diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index af23a6376..62717ed7e 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -1,5 +1,5 @@ import logger from '../logger'; -import { MempoolBlock, TransactionExtended, ThreadTransaction, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction } from '../mempool.interfaces'; +import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction } from '../mempool.interfaces'; import { Common } from './common'; import config from '../config'; import { Worker } from 'worker_threads'; @@ -104,8 +104,12 @@ class MempoolBlocks { private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlockWithTransactions[] { const mempoolBlocks: MempoolBlockWithTransactions[] = []; + let blockSize = 0; let blockWeight = 0; let blockVsize = 0; + let blockFees = 0; + const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2; + let transactionIds: string[] = []; let transactions: TransactionExtended[] = []; transactionsSorted.forEach((tx) => { if (blockWeight + tx.weight <= config.MEMPOOL.BLOCK_WEIGHT_UNITS @@ -116,9 +120,14 @@ class MempoolBlocks { }; blockWeight += tx.weight; blockVsize += tx.vsize; - transactions.push(tx); + blockSize += tx.size; + blockFees += tx.fee; + if (blockVsize <= sizeLimit) { + transactions.push(tx); + } + transactionIds.push(tx.txid); } else { - mempoolBlocks.push(this.dataToMempoolBlocks(transactions)); + mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees)); blockVsize = 0; tx.position = { block: mempoolBlocks.length, @@ -126,11 +135,14 @@ class MempoolBlocks { }; blockVsize += tx.vsize; blockWeight = tx.weight; + blockSize = tx.size; + blockFees = tx.fee; + transactionIds = [tx.txid]; transactions = [tx]; } }); if (transactions.length) { - mempoolBlocks.push(this.dataToMempoolBlocks(transactions)); + mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees)); } return mempoolBlocks; @@ -178,6 +190,8 @@ class MempoolBlocks { } public async $makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): Promise { + const start = Date.now(); + // reset mempool short ids this.resetUids(); for (const tx of Object.values(newMempool)) { @@ -194,7 +208,7 @@ class MempoolBlocks { fee: entry.fee, weight: entry.weight, feePerVsize: entry.fee / (entry.weight / 4), - effectiveFeePerVsize: entry.fee / (entry.weight / 4), + effectiveFeePerVsize: entry.effectiveFeePerVsize || (entry.fee / (entry.weight / 4)), inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => uid != null) as number[], }); } @@ -216,7 +230,7 @@ class MempoolBlocks { // run the block construction algorithm in a separate thread, and wait for a result let threadErrorListener; try { - const workerResultPromise = new Promise<{ blocks: CompactThreadTransaction[][], clusters: Map }>((resolve, reject) => { + const workerResultPromise = new Promise<{ blocks: number[][], rates: Map, clusters: Map }>((resolve, reject) => { threadErrorListener = reject; this.txSelectionWorker?.once('message', (result): void => { resolve(result); @@ -224,19 +238,14 @@ class MempoolBlocks { this.txSelectionWorker?.once('error', reject); }); this.txSelectionWorker.postMessage({ type: 'set', mempool: strippedMempool }); - let { blocks, clusters } = this.convertResultTxids(await workerResultPromise); - // filter out stale transactions - const unfilteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0); - blocks = blocks.map(block => block.filter(tx => (tx.txid && tx.txid in newMempool))); - const filteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0); - if (filteredCount < unfilteredCount) { - logger.warn(`tx selection worker thread returned ${unfilteredCount - filteredCount} stale transactions from makeBlockTemplates`); - } + const { blocks, rates, clusters } = this.convertResultTxids(await workerResultPromise); // clean up thread error listener this.txSelectionWorker?.removeListener('error', threadErrorListener); - return this.processBlockTemplates(newMempool, blocks, clusters, saveResults); + const processed = this.processBlockTemplates(newMempool, blocks, rates, clusters, saveResults); + logger.debug(`makeBlockTemplates completed in ${(Date.now() - start)/1000} seconds`); + return processed; } catch (e) { logger.err('makeBlockTemplates failed. ' + (e instanceof Error ? e.message : e)); } @@ -250,6 +259,8 @@ class MempoolBlocks { return; } + const start = Date.now(); + for (const tx of Object.values(added)) { this.setUid(tx); } @@ -262,7 +273,7 @@ class MempoolBlocks { fee: entry.fee, weight: entry.weight, feePerVsize: entry.fee / (entry.weight / 4), - effectiveFeePerVsize: entry.fee / (entry.weight / 4), + effectiveFeePerVsize: entry.effectiveFeePerVsize || (entry.fee / (entry.weight / 4)), inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => uid != null) as number[], }; }); @@ -270,7 +281,7 @@ class MempoolBlocks { // run the block construction algorithm in a separate thread, and wait for a result let threadErrorListener; try { - const workerResultPromise = new Promise<{ blocks: CompactThreadTransaction[][], clusters: Map }>((resolve, reject) => { + const workerResultPromise = new Promise<{ blocks: number[][], rates: Map, clusters: Map }>((resolve, reject) => { threadErrorListener = reject; this.txSelectionWorker?.once('message', (result): void => { resolve(result); @@ -278,84 +289,100 @@ class MempoolBlocks { this.txSelectionWorker?.once('error', reject); }); this.txSelectionWorker.postMessage({ type: 'update', added: addedStripped, removed: removedUids }); - let { blocks, clusters } = this.convertResultTxids(await workerResultPromise); - // filter out stale transactions - const unfilteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0); - blocks = blocks.map(block => block.filter(tx => (tx.txid && tx.txid in newMempool))); - const filteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0); - if (filteredCount < unfilteredCount) { - logger.warn(`tx selection worker thread returned ${unfilteredCount - filteredCount} stale transactions from updateBlockTemplates`); - } + const { blocks, rates, clusters } = this.convertResultTxids(await workerResultPromise); this.removeUids(removedUids); // clean up thread error listener this.txSelectionWorker?.removeListener('error', threadErrorListener); - this.processBlockTemplates(newMempool, blocks, clusters, saveResults); + this.processBlockTemplates(newMempool, blocks, rates, clusters, saveResults); + logger.debug(`updateBlockTemplates completed in ${(Date.now() - start) / 1000} seconds`); } catch (e) { logger.err('updateBlockTemplates failed. ' + (e instanceof Error ? e.message : e)); } } - private processBlockTemplates(mempool, blocks: ThreadTransaction[][], clusters, saveResults): MempoolBlockWithTransactions[] { + private processBlockTemplates(mempool, blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }, saveResults): MempoolBlockWithTransactions[] { + for (const txid of Object.keys(rates)) { + if (txid in mempool) { + mempool[txid].effectiveFeePerVsize = rates[txid]; + } + } + + const readyBlocks: { transactionIds, transactions, totalSize, totalWeight, totalFees }[] = []; + const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2; // update this thread's mempool with the results - blocks.forEach((block, blockIndex) => { - let runningVsize = 0; - block.forEach(tx => { - if (tx.txid && tx.txid in mempool) { + for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) { + const block: string[] = blocks[blockIndex]; + let txid: string; + let mempoolTx: TransactionExtended; + let totalSize = 0; + let totalVsize = 0; + let totalWeight = 0; + let totalFees = 0; + const transactions: TransactionExtended[] = []; + for (let txIndex = 0; txIndex < block.length; txIndex++) { + txid = block[txIndex]; + if (txid) { + mempoolTx = mempool[txid]; // save position in projected blocks - mempool[tx.txid].position = { + mempoolTx.position = { block: blockIndex, - vsize: runningVsize + (mempool[tx.txid].vsize / 2), + vsize: totalVsize + (mempoolTx.vsize / 2), }; - runningVsize += mempool[tx.txid].vsize; + mempoolTx.cpfpChecked = true; - if (tx.effectiveFeePerVsize != null) { - mempool[tx.txid].effectiveFeePerVsize = tx.effectiveFeePerVsize; + totalSize += mempoolTx.size; + totalVsize += mempoolTx.vsize; + totalWeight += mempoolTx.weight; + totalFees += mempoolTx.fee; + + if (totalVsize <= sizeLimit) { + transactions.push(mempoolTx); } - if (tx.cpfpRoot && tx.cpfpRoot in clusters) { - const ancestors: Ancestor[] = []; - const descendants: Ancestor[] = []; - const cluster = clusters[tx.cpfpRoot]; - let matched = false; - cluster.forEach(txid => { - if (!txid || !mempool[txid]) { - logger.warn('projected transaction ancestor missing from mempool cache'); - return; - } - if (txid === tx.txid) { - matched = true; - } else { - const relative = { - txid: txid, - fee: mempool[txid].fee, - weight: mempool[txid].weight, - }; - if (matched) { - descendants.push(relative); - } else { - ancestors.push(relative); - } - } - }); - mempool[tx.txid].ancestors = ancestors; - mempool[tx.txid].descendants = descendants; - mempool[tx.txid].bestDescendant = null; - } - mempool[tx.txid].cpfpChecked = tx.cpfpChecked; - } else { - logger.warn('projected transaction missing from mempool cache'); } + } + readyBlocks.push({ + transactionIds: block, + transactions, + totalSize, + totalWeight, + totalFees }); - }); + } - // unpack the condensed blocks into proper mempool blocks - const mempoolBlocks = blocks.map((transactions) => { - return this.dataToMempoolBlocks(transactions.map(tx => { - return mempool[tx.txid] || null; - }).filter(tx => !!tx)); - }); + for (const cluster of Object.values(clusters)) { + for (const memberTxid of cluster) { + if (memberTxid in mempool) { + const mempoolTx = mempool[memberTxid]; + const ancestors: Ancestor[] = []; + const descendants: Ancestor[] = []; + let matched = false; + cluster.forEach(txid => { + if (txid === memberTxid) { + matched = true; + } else { + const relative = { + txid: txid, + fee: mempool[txid].fee, + weight: mempool[txid].weight, + }; + if (matched) { + descendants.push(relative); + } else { + ancestors.push(relative); + } + } + }); + mempoolTx.ancestors = ancestors; + mempoolTx.descendants = descendants; + mempoolTx.bestDescendant = null; + } + } + } + + const mempoolBlocks = readyBlocks.map(b => this.dataToMempoolBlocks(b.transactionIds, b.transactions, b.totalSize, b.totalWeight, b.totalFees)); if (saveResults) { const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, mempoolBlocks); @@ -366,27 +393,17 @@ class MempoolBlocks { return mempoolBlocks; } - private dataToMempoolBlocks(transactions: TransactionExtended[]): MempoolBlockWithTransactions { - let totalSize = 0; - let totalWeight = 0; - const fitTransactions: TransactionExtended[] = []; - transactions.forEach(tx => { - totalSize += tx.size; - totalWeight += tx.weight; - if ((totalWeight + tx.weight) <= config.MEMPOOL.BLOCK_WEIGHT_UNITS * 1.2) { - fitTransactions.push(tx); - } - }); + private dataToMempoolBlocks(transactionIds: string[], transactions: TransactionExtended[], totalSize: number, totalWeight: number, totalFees: number): MempoolBlockWithTransactions { const feeStats = Common.calcEffectiveFeeStatistics(transactions); return { blockSize: totalSize, - blockVSize: totalWeight / 4, - nTx: transactions.length, - totalFees: transactions.reduce((acc, cur) => acc + cur.fee, 0), + blockVSize: (totalWeight / 4), // fractional vsize to avoid rounding errors + nTx: transactionIds.length, + totalFees: totalFees, medianFee: feeStats.medianFee, // Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE), feeRange: feeStats.feeRange, //Common.getFeesInRange(transactions, rangeLength), - transactionIds: transactions.map((tx) => tx.txid), - transactions: fitTransactions.map((tx) => Common.stripTransaction(tx)), + transactionIds: transactionIds, + transactions: transactions.map((tx) => Common.stripTransaction(tx)), }; } @@ -415,14 +432,16 @@ class MempoolBlocks { } } - private convertResultTxids({ blocks, clusters }: { blocks: any[][], clusters: Map}) - : { blocks: ThreadTransaction[][], clusters: { [root: string]: string[] }} { - for (const block of blocks) { - for (const tx of block) { - tx.txid = this.uidMap.get(tx.uid); - if (tx.cpfpRoot) { - tx.cpfpRoot = this.uidMap.get(tx.cpfpRoot); - } + private convertResultTxids({ blocks, rates, clusters }: { blocks: number[][], rates: Map, clusters: Map}) + : { blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }} { + const convertedBlocks: string[][] = blocks.map(block => block.map(uid => { + return this.uidMap.get(uid) || ''; + })); + const convertedRates = {}; + for (const rateUid of rates.keys()) { + const rateTxid = this.uidMap.get(rateUid); + if (rateTxid) { + convertedRates[rateTxid] = rates.get(rateUid); } } const convertedClusters = {}; @@ -435,7 +454,7 @@ class MempoolBlocks { convertedClusters[rootTxid] = members; } } - return { blocks, clusters: convertedClusters } as { blocks: ThreadTransaction[][], clusters: { [root: string]: string[] }}; + return { blocks: convertedBlocks, rates: convertedRates, clusters: convertedClusters } as { blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }}; } } diff --git a/backend/src/api/tx-selection-worker.ts b/backend/src/api/tx-selection-worker.ts index 1635acac4..b22f42823 100644 --- a/backend/src/api/tx-selection-worker.ts +++ b/backend/src/api/tx-selection-worker.ts @@ -1,6 +1,6 @@ import config from '../config'; import logger from '../logger'; -import { CompactThreadTransaction, MempoolBlockWithTransactions, AuditTransaction } from '../mempool.interfaces'; +import { CompactThreadTransaction, AuditTransaction } from '../mempool.interfaces'; import { PairingHeap } from '../utils/pairing-heap'; import { parentPort } from 'worker_threads'; @@ -19,11 +19,11 @@ if (parentPort) { }); } - const { blocks, clusters } = makeBlockTemplates(mempool); + const { blocks, rates, clusters } = makeBlockTemplates(mempool); // return the result to main thread. if (parentPort) { - parentPort.postMessage({ blocks, clusters }); + parentPort.postMessage({ blocks, rates, clusters }); } }); } @@ -33,14 +33,14 @@ if (parentPort) { * (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp) */ function makeBlockTemplates(mempool: Map) - : { blocks: CompactThreadTransaction[][], clusters: Map } { + : { blocks: number[][], rates: Map, clusters: Map } { const start = Date.now(); const auditPool: Map = new Map(); const mempoolArray: AuditTransaction[] = []; - const restOfArray: CompactThreadTransaction[] = []; const cpfpClusters: Map = new Map(); mempool.forEach(tx => { + tx.dirty = false; // initializing everything up front helps V8 optimize property access later auditPool.set(tx.uid, { uid: tx.uid, @@ -81,9 +81,8 @@ function makeBlockTemplates(mempool: Map) // Build blocks by greedily choosing the highest feerate package // (i.e. the package rooted in the transaction with the best ancestor score) - const blocks: CompactThreadTransaction[][] = []; + const blocks: number[][] = []; let blockWeight = 4000; - let blockSize = 0; let transactions: AuditTransaction[] = []; const modified: PairingHeap = new PairingHeap((a, b): boolean => { if (a.score === b.score) { @@ -139,13 +138,16 @@ function makeBlockTemplates(mempool: Map) ancestor.used = true; ancestor.usedBy = nextTx.uid; // update original copy of this tx with effective fee rate & relatives data - mempoolTx.effectiveFeePerVsize = effectiveFeeRate; - if (isCluster) { - mempoolTx.cpfpRoot = nextTx.uid; + if (mempoolTx.effectiveFeePerVsize !== effectiveFeeRate) { + mempoolTx.effectiveFeePerVsize = effectiveFeeRate; + mempoolTx.dirty = true; + } + if (mempoolTx.cpfpRoot !== nextTx.uid) { + mempoolTx.cpfpRoot = isCluster ? nextTx.uid : null; + mempoolTx.dirty; } mempoolTx.cpfpChecked = true; transactions.push(ancestor); - blockSize += ancestor.size; blockWeight += ancestor.weight; used.push(ancestor); } @@ -171,11 +173,10 @@ function makeBlockTemplates(mempool: Map) if ((exceededPackageTries || queueEmpty) && blocks.length < 7) { // construct this block if (transactions.length) { - blocks.push(transactions.map(t => mempool.get(t.uid) as CompactThreadTransaction)); + blocks.push(transactions.map(t => t.uid)); } // reset for the next block transactions = []; - blockSize = 0; blockWeight = 4000; // 'overflow' packages didn't fit in this block, but are valid candidates for the next @@ -196,14 +197,22 @@ function makeBlockTemplates(mempool: Map) } // add the final unbounded block if it contains any transactions if (transactions.length > 0) { - blocks.push(transactions.map(t => mempool.get(t.uid) as CompactThreadTransaction)); + blocks.push(transactions.map(t => t.uid)); + } + + // get map of dirty transactions + const rates = new Map(); + for (const tx of mempool.values()) { + if (tx?.dirty) { + rates.set(tx.uid, tx.effectiveFeePerVsize || tx.feePerVsize); + } } const end = Date.now(); const time = end - start; logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds'); - return { blocks, clusters: cpfpClusters }; + return { blocks, rates, clusters: cpfpClusters }; } // traverse in-mempool ancestors diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 53bd3ff33..ab4c4cd25 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -114,6 +114,7 @@ export interface CompactThreadTransaction { inputs: number[]; cpfpRoot?: string; cpfpChecked?: boolean; + dirty?: boolean; } export interface ThreadTransaction { From f8636d20c2e257b5783a0d8c58173a98016bbe54 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 9 May 2023 17:44:38 -0600 Subject: [PATCH 081/113] optimize batch client websocket updates --- backend/src/api/websocket-handler.ts | 143 +++++++++++++++++---------- 1 file changed, 92 insertions(+), 51 deletions(-) diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index bc0fb8ea5..eb099f229 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -210,11 +210,12 @@ class WebsocketHandler { throw new Error('WebSocket.Server is not set'); } + const response = JSON.stringify({ loadingIndicators: indicators }); this.wss.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } - client.send(JSON.stringify({ loadingIndicators: indicators })); + client.send(response); }); } @@ -223,11 +224,12 @@ class WebsocketHandler { throw new Error('WebSocket.Server is not set'); } + const response = JSON.stringify({ conversions: conversionRates }); this.wss.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; } - client.send(JSON.stringify({ conversions: conversionRates })); + client.send(response); }); } @@ -258,6 +260,10 @@ class WebsocketHandler { this.printLogs(); + const response = JSON.stringify({ + 'live-2h-chart': stats + }); + this.wss.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; @@ -267,9 +273,7 @@ class WebsocketHandler { return; } - client.send(JSON.stringify({ - 'live-2h-chart': stats - })); + client.send(response); }); } @@ -306,6 +310,38 @@ class WebsocketHandler { } const recommendedFees = feeApi.getRecommendedFee(); + // cache serialized objects to avoid stringify-ing the same thing for every client + const responseCache = {}; + function getCachedResponse(key: string, data): string { + if (!responseCache[key]) { + responseCache[key] = JSON.stringify(data); + } + return responseCache[key]; + } + + // pre-compute new tracked outspends + const outspendCache: { [txid: string]: { [vout: number]: { vin: number, txid: string } } } = {}; + const trackedTxs = new Set(); + this.wss.clients.forEach((client) => { + if (client['track-tx']) { + trackedTxs.add(client['track-tx']); + } + }); + if (trackedTxs.size > 0) { + for (const tx of newTransactions) { + for (let i = 0; i < tx.vin.length; i++) { + const vin = tx.vin[i]; + if (trackedTxs.has(vin.txid)) { + if (!outspendCache[vin.txid]) { + outspendCache[vin.txid] = { [vin.vout]: { vin: i, txid: tx.txid }}; + } else { + outspendCache[vin.txid][vin.vout] = { vin: i, txid: tx.txid }; + } + } + } + } + } + this.wss.clients.forEach(async (client) => { if (client.readyState !== WebSocket.OPEN) { return; @@ -314,17 +350,17 @@ class WebsocketHandler { const response = {}; if (client['want-stats']) { - response['mempoolInfo'] = mempoolInfo; - response['vBytesPerSecond'] = vBytesPerSecond; - response['transactions'] = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx)); + response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo); + response['vBytesPerSecond'] = getCachedResponse('vBytesPerSecond', vBytesPerSecond); + response['transactions'] = getCachedResponse('transactions', newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx))); if (da?.previousTime) { - response['da'] = da; + response['da'] = getCachedResponse('da', da); } - response['fees'] = recommendedFees; + response['fees'] = getCachedResponse('fees', recommendedFees); } if (client['want-mempool-blocks']) { - response['mempool-blocks'] = mBlocks; + response['mempool-blocks'] = getCachedResponse('mempool-blocks', mBlocks); } if (client['track-mempool-tx']) { @@ -333,12 +369,12 @@ class WebsocketHandler { if (config.MEMPOOL.BACKEND !== 'esplora') { try { const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true); - response['tx'] = fullTx; + response['tx'] = JSON.stringify(fullTx); } catch (e) { logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e)); } } else { - response['tx'] = tx; + response['tx'] = JSON.stringify(tx); } client['track-mempool-tx'] = null; } @@ -378,7 +414,7 @@ class WebsocketHandler { } if (foundTransactions.length) { - response['address-transactions'] = foundTransactions; + response['address-transactions'] = JSON.stringify(foundTransactions); } } @@ -407,65 +443,60 @@ class WebsocketHandler { }); if (foundTransactions.length) { - response['address-transactions'] = foundTransactions; + response['address-transactions'] = JSON.stringify(foundTransactions); } } if (client['track-tx']) { const trackTxid = client['track-tx']; - const outspends: object = {}; - newTransactions.forEach((tx) => tx.vin.forEach((vin, i) => { - if (vin.txid === trackTxid) { - outspends[vin.vout] = { - vin: i, - txid: tx.txid, - }; - } - })); + const outspends = outspendCache[trackTxid]; - if (Object.keys(outspends).length) { - response['utxoSpent'] = outspends; + if (outspends && Object.keys(outspends).length) { + response['utxoSpent'] = JSON.stringify(outspends); } const rbfReplacedBy = rbfCache.getReplacedBy(client['track-tx']); if (rbfReplacedBy) { - response['rbfTransaction'] = { + response['rbfTransaction'] = JSON.stringify({ txid: rbfReplacedBy, - } + }) } const rbfChange = rbfChanges.map[client['track-tx']]; if (rbfChange) { - response['rbfInfo'] = rbfChanges.trees[rbfChange]; + response['rbfInfo'] = JSON.stringify(rbfChanges.trees[rbfChange]); } const mempoolTx = newMempool[trackTxid]; if (mempoolTx && mempoolTx.position) { - response['txPosition'] = { + response['txPosition'] = JSON.stringify({ txid: trackTxid, position: mempoolTx.position, - }; + }); } } if (client['track-mempool-block'] >= 0) { const index = client['track-mempool-block']; if (mBlockDeltas[index]) { - response['projected-block-transactions'] = { + response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, { index: index, delta: mBlockDeltas[index], - }; + }); } } if (client['track-rbf'] === 'all' && rbfReplacements) { - response['rbfLatest'] = rbfReplacements; + response['rbfLatest'] = getCachedResponse('rbfLatest', rbfReplacements); } else if (client['track-rbf'] === 'fullRbf' && fullRbfReplacements) { - response['rbfLatest'] = fullRbfReplacements; + response['rbfLatest'] = getCachedResponse('fullrbfLatest', fullRbfReplacements); } if (Object.keys(response).length) { - client.send(JSON.stringify(response)); + const serializedResponse = '{' + + Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ') + + '}'; + client.send(serializedResponse); } }); } @@ -556,6 +587,14 @@ class WebsocketHandler { const da = difficultyAdjustment.getDifficultyAdjustment(); const fees = feeApi.getRecommendedFee(); + const responseCache = {}; + function getCachedResponse(key, data) { + if (!responseCache[key]) { + responseCache[key] = JSON.stringify(data); + } + return responseCache[key]; + } + this.wss.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; @@ -565,28 +604,27 @@ class WebsocketHandler { return; } - const response = { - 'block': block, - 'mempoolInfo': memPool.getMempoolInfo(), - 'da': da?.previousTime ? da : undefined, - 'fees': fees, - }; + const response = {}; + response['block'] = getCachedResponse('block', block); + response['mempoolInfo'] = getCachedResponse('mempoolInfo', memPool.getMempoolInfo(),); + response['da'] = getCachedResponse('da', da?.previousTime ? da : undefined); + response['fees'] = getCachedResponse('fees', fees); if (mBlocks && client['want-mempool-blocks']) { - response['mempool-blocks'] = mBlocks; + response['mempool-blocks'] = getCachedResponse('mempool-blocks', mBlocks); } if (client['track-tx']) { const trackTxid = client['track-tx']; if (txIds.indexOf(trackTxid) > -1) { - response['txConfirmed'] = true; + response['txConfirmed'] = 'true'; } else { const mempoolTx = _memPool[trackTxid]; if (mempoolTx && mempoolTx.position) { - response['txPosition'] = { + response['txPosition'] = JSON.stringify({ txid: trackTxid, position: mempoolTx.position, - }; + }); } } } @@ -614,7 +652,7 @@ class WebsocketHandler { }; }); - response['block-transactions'] = foundTransactions; + response['block-transactions'] = JSON.stringify(foundTransactions); } } @@ -651,21 +689,24 @@ class WebsocketHandler { }; }); - response['block-transactions'] = foundTransactions; + response['block-transactions'] = JSON.stringify(foundTransactions); } } if (client['track-mempool-block'] >= 0) { const index = client['track-mempool-block']; if (mBlockDeltas && mBlockDeltas[index]) { - response['projected-block-transactions'] = { + response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, { index: index, delta: mBlockDeltas[index], - }; + }); } } - client.send(JSON.stringify(response)); + const serializedResponse = '{' + + Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ') + + '}'; + client.send(serializedResponse); }); } From ffd7831efc93fb9d07bf8842c4d2c2fb0826a0b6 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 9 May 2023 19:48:02 -0600 Subject: [PATCH 082/113] optimize websocket init data --- backend/src/api/bitcoin/bitcoin.routes.ts | 5 +- backend/src/api/websocket-handler.ts | 90 ++++++++++++++++------- 2 files changed, 65 insertions(+), 30 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 18d688e9b..16533b68c 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -130,8 +130,9 @@ class BitcoinRoutes { private getInitData(req: Request, res: Response) { try { - const result = websocketHandler.getInitData(); - res.json(result); + const result = websocketHandler.getSerializedInitData(); + res.set('Content-Type', 'application/json'); + res.send(result); } 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 eb099f229..3fa7006fb 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -30,6 +30,9 @@ class WebsocketHandler { private numConnected = 0; private numDisconnected = 0; + private initData: { [key: string]: string } = {}; + private serializedInitData: string = '{}'; + constructor() { } setWebsocketServer(wss: WebSocket.Server) { @@ -38,6 +41,41 @@ class WebsocketHandler { setExtraInitProperties(property: string, value: any) { this.extraInitProperties[property] = value; + this.setInitDataFields(this.extraInitProperties); + } + + private setInitDataFields(data: { [property: string]: any }): void { + for (const property of Object.keys(data)) { + if (data[property] != null) { + this.initData[property] = JSON.stringify(data[property]); + } else { + delete this.initData[property]; + } + } + this.serializedInitData = '{' + + Object.keys(this.initData).map(key => `"${key}": ${this.initData[key]}`).join(', ') + + '}'; + } + + private updateInitData(): void { + const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT); + const da = difficultyAdjustment.getDifficultyAdjustment(); + this.setInitDataFields({ + 'mempoolInfo': memPool.getMempoolInfo(), + 'vBytesPerSecond': memPool.getVBytesPerSecond(), + 'blocks': _blocks, + 'conversions': priceUpdater.getLatestPrices(), + 'mempool-blocks': mempoolBlocks.getMempoolBlocks(), + 'transactions': memPool.getLatestTransactions(), + 'backendInfo': backendInfo.getBackendInfo(), + 'loadingIndicators': loadingIndicators.getLoadingIndicators(), + 'da': da?.previousTime ? da : undefined, + 'fees': feeApi.getRecommendedFee(), + }); + } + + public getSerializedInitData(): string { + return this.serializedInitData; } setupConnectionHandling() { @@ -157,11 +195,13 @@ class WebsocketHandler { } if (parsedMessage.action === 'init') { - const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT); - if (!_blocks) { + if (!this.initData['blocks']?.length || !this.initData['da']) { + this.updateInitData(); + } + if (!this.initData['blocks']?.length) { return; } - client.send(JSON.stringify(this.getInitData(_blocks))); + client.send(this.serializedInitData); } if (parsedMessage.action === 'ping') { @@ -210,6 +250,8 @@ class WebsocketHandler { throw new Error('WebSocket.Server is not set'); } + this.setInitDataFields({ 'loadingIndicators': indicators }); + const response = JSON.stringify({ loadingIndicators: indicators }); this.wss.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { @@ -224,6 +266,8 @@ class WebsocketHandler { throw new Error('WebSocket.Server is not set'); } + this.setInitDataFields({ 'conversions': conversionRates }); + const response = JSON.stringify({ conversions: conversionRates }); this.wss.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { @@ -233,26 +277,6 @@ class WebsocketHandler { }); } - getInitData(_blocks?: BlockExtended[]) { - if (!_blocks) { - _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT); - } - const da = difficultyAdjustment.getDifficultyAdjustment(); - return { - 'mempoolInfo': memPool.getMempoolInfo(), - 'vBytesPerSecond': memPool.getVBytesPerSecond(), - 'blocks': _blocks, - 'conversions': priceUpdater.getLatestPrices(), - 'mempool-blocks': mempoolBlocks.getMempoolBlocks(), - 'transactions': memPool.getLatestTransactions(), - 'backendInfo': backendInfo.getBackendInfo(), - 'loadingIndicators': loadingIndicators.getLoadingIndicators(), - 'da': da?.previousTime ? da : undefined, - 'fees': feeApi.getRecommendedFee(), - ...this.extraInitProperties - }; - } - handleNewStatistic(stats: OptimizedStatistic) { if (!this.wss) { throw new Error('WebSocket.Server is not set'); @@ -310,8 +334,11 @@ class WebsocketHandler { } const recommendedFees = feeApi.getRecommendedFee(); + // update init data + this.updateInitData(); + // cache serialized objects to avoid stringify-ing the same thing for every client - const responseCache = {}; + const responseCache = { ...this.initData }; function getCachedResponse(key: string, data): string { if (!responseCache[key]) { responseCache[key] = JSON.stringify(data); @@ -342,6 +369,8 @@ 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; @@ -352,7 +381,7 @@ class WebsocketHandler { if (client['want-stats']) { response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo); response['vBytesPerSecond'] = getCachedResponse('vBytesPerSecond', vBytesPerSecond); - response['transactions'] = getCachedResponse('transactions', newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx))); + response['transactions'] = getCachedResponse('transactions', latestTransactions); if (da?.previousTime) { response['da'] = getCachedResponse('da', da); } @@ -587,14 +616,19 @@ class WebsocketHandler { const da = difficultyAdjustment.getDifficultyAdjustment(); const fees = feeApi.getRecommendedFee(); - const responseCache = {}; - function getCachedResponse(key, data) { + // update init data + this.updateInitData(); + + const responseCache = { ...this.initData }; + function getCachedResponse(key, data): string { if (!responseCache[key]) { responseCache[key] = JSON.stringify(data); } return responseCache[key]; } + const mempoolInfo = memPool.getMempoolInfo(); + this.wss.clients.forEach((client) => { if (client.readyState !== WebSocket.OPEN) { return; @@ -606,7 +640,7 @@ class WebsocketHandler { const response = {}; response['block'] = getCachedResponse('block', block); - response['mempoolInfo'] = getCachedResponse('mempoolInfo', memPool.getMempoolInfo(),); + response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo); response['da'] = getCachedResponse('da', da?.previousTime ? da : undefined); response['fees'] = getCachedResponse('fees', fees); From 4b20ea7232b45fbe7bd029f42e8dde7ff33d8152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20Strnad?= <43024885+vostrnad@users.noreply.github.com> Date: Thu, 11 May 2023 00:10:57 +0200 Subject: [PATCH 083/113] Accept the CLA for @vostrnad --- contributors/vostrnad.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 contributors/vostrnad.txt diff --git a/contributors/vostrnad.txt b/contributors/vostrnad.txt new file mode 100644 index 000000000..6b295c715 --- /dev/null +++ b/contributors/vostrnad.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of January 25, 2022. + +Signed: vostrnad From be53cd8b4849579a1900394e1dda6b52962d1ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20Strnad?= <43024885+vostrnad@users.noreply.github.com> Date: Thu, 11 May 2023 00:11:23 +0200 Subject: [PATCH 084/113] Display empty witness items --- .../transactions-list/transactions-list.component.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index 71eeb4aa1..0d195f2ff 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -114,7 +114,12 @@

- {{ witness }} + + {{ witness }} + + + <empty> +

... From 3d1cd3193a35efae0d1ace86f8a3e48e065820a9 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 10 May 2023 12:59:05 -0600 Subject: [PATCH 085/113] online calculation of stack-of-n-blocks fee statistics --- backend/src/api/common.ts | 118 +++++++++++++++++++++++++++++- backend/src/api/mempool-blocks.ts | 53 +++++++++++--- backend/src/mempool.interfaces.ts | 5 ++ 3 files changed, 166 insertions(+), 10 deletions(-) diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index b044e2866..fc952d6a8 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -1,4 +1,4 @@ -import { Ancestor, CpfpInfo, CpfpSummary, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, TransactionStripped } from '../mempool.interfaces'; +import { Ancestor, CpfpInfo, CpfpSummary, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, TransactionStripped, WorkingEffectiveFeeStats } from '../mempool.interfaces'; import config from '../config'; import { NodeSocket } from '../repositories/NodesSocketsRepository'; import { isIP } from 'net'; @@ -442,3 +442,119 @@ export class Common { return sortedDistribution[Math.floor((sortedDistribution.length - 1) * (n / 100))]; } } + +/** + * Class to calculate average fee rates of a list of transactions + * at certain weight percentiles, in a single pass + * + * init with: + * maxWeight - the total weight to measure percentiles relative to (e.g. 4MW for a single block) + * percentileBandWidth - how many weight units to average over for each percentile (as a % of maxWeight) + * percentiles - an array of weight percentiles to compute, in % + * + * then call .processNext(tx) for each transaction, in descending order + * + * retrieve the final results with .getFeeStats() + */ +export class OnlineFeeStatsCalculator { + private maxWeight: number; + private percentiles = [10,25,50,75,90]; + + private bandWidthPercent = 2; + private bandWidth: number = 0; + private bandIndex = 0; + private leftBound = 0; + private rightBound = 0; + private inBand = false; + private totalBandFee = 0; + private totalBandWeight = 0; + private minBandRate = Infinity; + private maxBandRate = 0; + + private feeRange: { avg: number, min: number, max: number }[] = []; + private totalWeight: number = 0; + + constructor (maxWeight: number, percentileBandWidth?: number, percentiles?: number[]) { + this.maxWeight = maxWeight; + if (percentiles && percentiles.length) { + this.percentiles = percentiles; + } + if (percentileBandWidth != null) { + this.bandWidthPercent = percentileBandWidth; + } + this.bandWidth = this.maxWeight * (this.bandWidthPercent / 100); + // add min/max percentiles aligned to the ends of the range + this.percentiles.unshift(this.bandWidthPercent / 2); + this.percentiles.push(100 - (this.bandWidthPercent / 2)); + this.setNextBounds(); + } + + processNext(tx: { weight: number, fee: number, effectiveFeePerVsize?: number, feePerVsize?: number, rate?: number, txid: string }): void { + let left = this.totalWeight; + const right = this.totalWeight + tx.weight; + if (!this.inBand && right <= this.leftBound) { + this.totalWeight += tx.weight; + return; + } + + while (left < right) { + if (right > this.leftBound) { + this.inBand = true; + const txRate = (tx.rate || tx.effectiveFeePerVsize || tx.feePerVsize || 0); + const weight = Math.min(right, this.rightBound) - Math.max(left, this.leftBound); + this.totalBandFee += (txRate * weight); + this.totalBandWeight += weight; + this.maxBandRate = Math.max(this.maxBandRate, txRate); + this.minBandRate = Math.min(this.minBandRate, txRate); + } + left = Math.min(right, this.rightBound); + + if (left >= this.rightBound) { + this.inBand = false; + const avgBandFeeRate = this.totalBandWeight ? (this.totalBandFee / this.totalBandWeight) : 0; + this.feeRange.unshift({ avg: avgBandFeeRate, min: this.minBandRate, max: this.maxBandRate }); + this.bandIndex++; + this.setNextBounds(); + this.totalBandFee = 0; + this.totalBandWeight = 0; + this.minBandRate = Infinity; + this.maxBandRate = 0; + } + } + this.totalWeight += tx.weight; + } + + private setNextBounds(): void { + const nextPercentile = this.percentiles[this.bandIndex]; + if (nextPercentile != null) { + this.leftBound = ((nextPercentile / 100) * this.maxWeight) - (this.bandWidth / 2); + this.rightBound = this.leftBound + this.bandWidth; + } else { + this.leftBound = Infinity; + this.rightBound = Infinity; + } + } + + getRawFeeStats(): WorkingEffectiveFeeStats { + if (this.totalBandWeight > 0) { + const avgBandFeeRate = this.totalBandWeight ? (this.totalBandFee / this.totalBandWeight) : 0; + this.feeRange.unshift({ avg: avgBandFeeRate, min: this.minBandRate, max: this.maxBandRate }); + } + while (this.feeRange.length < this.percentiles.length) { + this.feeRange.unshift({ avg: 0, min: 0, max: 0 }); + } + return { + minFee: this.feeRange[0].min, + medianFee: this.feeRange[Math.floor(this.feeRange.length / 2)].avg, + maxFee: this.feeRange[this.feeRange.length - 1].max, + feeRange: this.feeRange.map(f => f.avg), + }; + } + + getFeeStats(): EffectiveFeeStats { + const stats = this.getRawFeeStats(); + stats.feeRange[0] = stats.minFee; + stats.feeRange[stats.feeRange.length - 1] = stats.maxFee; + return stats; + } +} diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index 62717ed7e..803b7e56e 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -1,6 +1,6 @@ import logger from '../logger'; -import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction } from '../mempool.interfaces'; -import { Common } from './common'; +import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction, EffectiveFeeStats } from '../mempool.interfaces'; +import { Common, OnlineFeeStatsCalculator } from './common'; import config from '../config'; import { Worker } from 'worker_threads'; import path from 'path'; @@ -104,6 +104,8 @@ class MempoolBlocks { private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlockWithTransactions[] { const mempoolBlocks: MempoolBlockWithTransactions[] = []; + let feeStatsCalculator: OnlineFeeStatsCalculator = new OnlineFeeStatsCalculator(config.MEMPOOL.BLOCK_WEIGHT_UNITS); + let onlineStats = false; let blockSize = 0; let blockWeight = 0; let blockVsize = 0; @@ -111,7 +113,7 @@ class MempoolBlocks { const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2; let transactionIds: string[] = []; let transactions: TransactionExtended[] = []; - transactionsSorted.forEach((tx) => { + transactionsSorted.forEach((tx, index) => { if (blockWeight + tx.weight <= config.MEMPOOL.BLOCK_WEIGHT_UNITS || mempoolBlocks.length === config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT - 1) { tx.position = { @@ -126,6 +128,9 @@ class MempoolBlocks { transactions.push(tx); } transactionIds.push(tx.txid); + if (onlineStats) { + feeStatsCalculator.processNext(tx); + } } else { mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees)); blockVsize = 0; @@ -133,6 +138,16 @@ class MempoolBlocks { block: mempoolBlocks.length, vsize: blockVsize + (tx.vsize / 2), }; + + if (mempoolBlocks.length === config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT - 1) { + const stackWeight = transactionsSorted.slice(index).reduce((total, tx) => total + (tx.weight || 0), 0); + if (stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS) { + onlineStats = true; + feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5); + feeStatsCalculator.processNext(tx); + } + } + blockVsize += tx.vsize; blockWeight = tx.weight; blockSize = tx.size; @@ -142,7 +157,8 @@ class MempoolBlocks { } }); if (transactions.length) { - mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees)); + const feeStats = onlineStats ? feeStatsCalculator.getRawFeeStats() : undefined; + mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees, feeStats)); } return mempoolBlocks; @@ -310,7 +326,16 @@ class MempoolBlocks { } } - const readyBlocks: { transactionIds, transactions, totalSize, totalWeight, totalFees }[] = []; + let hasBlockStack = blocks.length >= 8; + let stackWeight; + let feeStatsCalculator: OnlineFeeStatsCalculator | void; + if (hasBlockStack) { + stackWeight = blocks[blocks.length - 1].reduce((total, tx) => total + (mempool[tx]?.weight || 0), 0); + hasBlockStack = stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS; + feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5); + } + + const readyBlocks: { transactionIds, transactions, totalSize, totalWeight, totalFees, feeStats }[] = []; const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2; // update this thread's mempool with the results for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) { @@ -333,6 +358,11 @@ class MempoolBlocks { }; mempoolTx.cpfpChecked = true; + // online calculation of stack-of-blocks fee stats + if (hasBlockStack && blockIndex === blocks.length - 1 && feeStatsCalculator) { + feeStatsCalculator.processNext(mempoolTx); + } + totalSize += mempoolTx.size; totalVsize += mempoolTx.vsize; totalWeight += mempoolTx.weight; @@ -348,7 +378,8 @@ class MempoolBlocks { transactions, totalSize, totalWeight, - totalFees + totalFees, + feeStats: (hasBlockStack && blockIndex === blocks.length - 1 && feeStatsCalculator) ? feeStatsCalculator.getRawFeeStats() : undefined, }); } @@ -382,7 +413,9 @@ class MempoolBlocks { } } - const mempoolBlocks = readyBlocks.map(b => this.dataToMempoolBlocks(b.transactionIds, b.transactions, b.totalSize, b.totalWeight, b.totalFees)); + const mempoolBlocks = readyBlocks.map((b, index) => { + return this.dataToMempoolBlocks(b.transactionIds, b.transactions, b.totalSize, b.totalWeight, b.totalFees, b.feeStats); + }); if (saveResults) { const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, mempoolBlocks); @@ -393,8 +426,10 @@ class MempoolBlocks { return mempoolBlocks; } - private dataToMempoolBlocks(transactionIds: string[], transactions: TransactionExtended[], totalSize: number, totalWeight: number, totalFees: number): MempoolBlockWithTransactions { - const feeStats = Common.calcEffectiveFeeStatistics(transactions); + private dataToMempoolBlocks(transactionIds: string[], transactions: TransactionExtended[], totalSize: number, totalWeight: number, totalFees: number, feeStats?: EffectiveFeeStats ): MempoolBlockWithTransactions { + if (!feeStats) { + feeStats = Common.calcEffectiveFeeStatistics(transactions); + } return { blockSize: totalSize, blockVSize: (totalWeight / 4), // fractional vsize to avoid rounding errors diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index ab4c4cd25..7204c174e 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -237,6 +237,11 @@ export interface EffectiveFeeStats { feeRange: number[]; // 2nd, 10th, 25th, 50th, 75th, 90th, 98th percentiles } +export interface WorkingEffectiveFeeStats extends EffectiveFeeStats { + minFee: number; + maxFee: number; +} + export interface CpfpSummary { transactions: TransactionExtended[]; clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[]; From abbaee0274a48edd8a96a9c30b3a966dbbbf03cd Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 10 May 2023 19:57:58 -0600 Subject: [PATCH 086/113] Fix txids interpreted as addresses in search --- .../src/app/components/search-form/search-form.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/components/search-form/search-form.component.ts b/frontend/src/app/components/search-form/search-form.component.ts index af5ccc654..422cb2f45 100644 --- a/frontend/src/app/components/search-form/search-form.component.ts +++ b/frontend/src/app/components/search-form/search-form.component.ts @@ -153,7 +153,7 @@ export class SearchFormComponent implements OnInit { const matchesBlockHeight = this.regexBlockheight.test(searchText); const matchesTxId = this.regexTransaction.test(searchText) && !this.regexBlockhash.test(searchText); const matchesBlockHash = this.regexBlockhash.test(searchText); - const matchesAddress = this.regexAddress.test(searchText); + const matchesAddress = !matchesTxId && this.regexAddress.test(searchText); if (matchesAddress && this.network === 'bisq') { searchText = 'B' + searchText; @@ -198,7 +198,7 @@ export class SearchFormComponent implements OnInit { const searchText = result || this.searchForm.value.searchText.trim(); if (searchText) { this.isSearching = true; - if (this.regexAddress.test(searchText)) { + if (!this.regexTransaction.test(searchText) && this.regexAddress.test(searchText)) { this.navigate('/address/', searchText); } else if (this.regexBlockhash.test(searchText) || this.regexBlockheight.test(searchText)) { this.navigate('/block/', searchText); From 7bb34fe090a395779c8d62f305e9d1bd62b3758e Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Thu, 11 May 2023 14:30:57 +0200 Subject: [PATCH 087/113] [mempool graph] show horizontal guide line --- .../mempool-graph/mempool-graph.component.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/mempool-graph/mempool-graph.component.ts b/frontend/src/app/components/mempool-graph/mempool-graph.component.ts index cc53f425d..416b8f538 100644 --- a/frontend/src/app/components/mempool-graph/mempool-graph.component.ts +++ b/frontend/src/app/components/mempool-graph/mempool-graph.component.ts @@ -181,7 +181,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges { alwaysShowContent: false, position: (pos, params, el, elRect, size) => { const positions = { top: (this.template === 'advanced') ? 0 : -30 }; - positions[['left', 'right'][+(pos[0] < size.viewSize[0] / 2)]] = 60; + positions[['left', 'right'][+(pos[0] < size.viewSize[0] / 2)]] = 100; return positions; }, extraCssText: `width: ${(this.template === 'advanced') ? '275px' : '200px'}; @@ -189,10 +189,19 @@ export class MempoolGraphComponent implements OnInit, OnChanges { border: none; box-shadow: none;`, axisPointer: { - type: 'line', + type: 'cross', + label: { + formatter: (params: any) => { + if (params.axisDimension === 'y') { + return this.vbytesPipe.transform(params.value, 2, 'vB', 'MvB', true) + } else { + return formatterXAxis(this.locale, this.windowPreference, params.value); + } + } + } }, formatter: (params: any) => { - const axisValueLabel: string = formatterXAxis(this.locale, this.windowPreference, params[0].axisValue); + const axisValueLabel: string = formatterXAxis(this.locale, this.windowPreference, params[0].axisValue); const { totalValue, totalValueArray } = this.getTotalValues(params); const itemFormatted = []; let totalParcial = 0; From e5bef55d479b6fd46a8bdc4f8887cd7f6780aae1 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 11 May 2023 08:57:12 -0600 Subject: [PATCH 088/113] Fix RBF timestamps to always use seconds --- backend/src/api/rbf-cache.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index 6c5afc146..d8fb8656c 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -36,7 +36,7 @@ class RbfCache { } const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction; - const newTime = newTxExtended.firstSeen || Date.now(); + const newTime = newTxExtended.firstSeen || (Date.now() / 1000); newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe); this.txs.set(newTx.txid, newTxExtended); @@ -59,7 +59,7 @@ class RbfCache { } } } else { - const replacedTime = replacedTxExtended.firstSeen || Date.now(); + const replacedTime = replacedTxExtended.firstSeen || (Date.now() / 1000); replacedTrees.push({ tx: replacedTx, time: replacedTime, @@ -74,7 +74,7 @@ class RbfCache { const treeId = replacedTrees[0].tx.txid; const newTree = { tx: newTx, - time: newTxExtended.firstSeen || Date.now(), + time: newTime, fullRbf, replaces: replacedTrees }; From 107746feecb473c6d99a1f853f4dc3ada98a0f01 Mon Sep 17 00:00:00 2001 From: softsimon Date: Thu, 11 May 2023 11:38:57 -0500 Subject: [PATCH 089/113] Global footer fixes --- .../bisq-main-dashboard.component.html | 2 -- .../master-page/master-page.component.html | 4 ++-- .../master-page/master-page.component.ts | 11 +++++++++-- .../global-footer.component.html | 19 +++++++++---------- .../global-footer/global-footer.component.ts | 4 ---- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/bisq/bisq-main-dashboard/bisq-main-dashboard.component.html b/frontend/src/app/bisq/bisq-main-dashboard/bisq-main-dashboard.component.html index eab0537c7..4b2dc59c6 100644 --- a/frontend/src/app/bisq/bisq-main-dashboard/bisq-main-dashboard.component.html +++ b/frontend/src/app/bisq/bisq-main-dashboard/bisq-main-dashboard.component.html @@ -105,8 +105,6 @@
- -
diff --git a/frontend/src/app/components/master-page/master-page.component.html b/frontend/src/app/components/master-page/master-page.component.html index 6da59197c..0258a8e07 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -17,7 +17,7 @@ -