From 5f5dfcab62f35e345f8fd188fecbf3fc1cfd0ff2 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 4 Apr 2023 08:25:40 +0900 Subject: [PATCH 01/54] Disable blockchain drag for middle/right click --- frontend/src/app/components/start/start.component.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/start/start.component.ts b/frontend/src/app/components/start/start.component.ts index dde7bc234..7e83fb516 100644 --- a/frontend/src/app/components/start/start.component.ts +++ b/frontend/src/app/components/start/start.component.ts @@ -137,9 +137,11 @@ export class StartComponent implements OnInit, OnDestroy { } onMouseDown(event: MouseEvent) { - this.mouseDragStartX = event.clientX; - this.resetMomentum(event.clientX); - this.blockchainScrollLeftInit = this.blockchainContainer.nativeElement.scrollLeft; + if (!(event.which > 1 || event.button > 0)) { + this.mouseDragStartX = event.clientX; + this.resetMomentum(event.clientX); + this.blockchainScrollLeftInit = this.blockchainContainer.nativeElement.scrollLeft; + } } onPointerDown(event: PointerEvent) { if (this.isiOS) { From 0d5f6138d0908380891ea2211016b6f0bcc19765 Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Wed, 5 Apr 2023 16:27:13 +0900 Subject: [PATCH 02/54] [esplora] fallback to tcp socket if unix socket fails --- backend/src/api/bitcoin/esplora-api.ts | 76 +++++++++++++++++--------- backend/src/config.ts | 2 + 2 files changed, 53 insertions(+), 25 deletions(-) diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index ff6219587..73e810a5c 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -3,68 +3,96 @@ 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; + + 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} seconds`); + // Retry the unix socket after a few seconds + this.unixSocketRetryTimeout = setTimeout(() => { + this.activeAxiosConfig = this.axiosConfigWithUnixSocket; + }, 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' || e?.code === 'ETIMEDOUT') { + 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 +113,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/config.ts b/backend/src/config.ts index 81db16081..7c0e4e950 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -38,6 +38,7 @@ interface IConfig { ESPLORA: { REST_API_URL: string; UNIX_SOCKET_PATH: string | void | null; + RETRY_UNIX_SOCKET_AFTER: number; }; LIGHTNING: { ENABLED: boolean; @@ -165,6 +166,7 @@ const defaults: IConfig = { 'ESPLORA': { 'REST_API_URL': 'http://127.0.0.1:3000', 'UNIX_SOCKET_PATH': null, + 'RETRY_UNIX_SOCKET_AFTER': 30000, }, 'ELECTRUM': { 'HOST': '127.0.0.1', From 37a245a626ee2b101f0bd4a1526ce3993f7b157f Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Wed, 5 Apr 2023 16:38:37 +0900 Subject: [PATCH 03/54] [esplora] initialize default socket config to axiosConfigWithUnixSocket --- backend/src/api/bitcoin/esplora-api.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index 73e810a5c..15a6a6844 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -23,6 +23,10 @@ class ElectrsApi implements AbstractBitcoinApi { 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} seconds`); From 4e6083e7431bbd25ba998bab6dc10e35565476de Mon Sep 17 00:00:00 2001 From: wiz Date: Wed, 5 Apr 2023 16:48:26 +0900 Subject: [PATCH 04/54] ops: Enable unix socket for esplora on mainnet-lightning --- production/mempool-config.mainnet-lightning.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/production/mempool-config.mainnet-lightning.json b/production/mempool-config.mainnet-lightning.json index 546c58163..37e407aeb 100644 --- a/production/mempool-config.mainnet-lightning.json +++ b/production/mempool-config.mainnet-lightning.json @@ -15,7 +15,7 @@ "PASSWORD": "__BITCOIN_RPC_PASS__" }, "ESPLORA": { - "REST_API_URL": "http://127.0.0.1:4000" + "UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-mainnet" }, "LIGHTNING": { "ENABLED": true, From 07dc8b1a1a7380607b76839851d813feb362d015 Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Wed, 5 Apr 2023 17:00:41 +0900 Subject: [PATCH 05/54] [esplora] print log when retrying unix socket - don't fallback to tcp socket on ETIMEDOUT --- backend/src/api/bitcoin/esplora-api.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index 15a6a6844..e471e4cde 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -29,9 +29,10 @@ class ElectrsApi implements AbstractBitcoinApi { 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} seconds`); + 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; }, config.ESPLORA.RETRY_UNIX_SOCKET_AFTER); } @@ -44,7 +45,7 @@ class ElectrsApi implements AbstractBitcoinApi { return axiosConnection.get(url, { ...this.activeAxiosConfig, responseType: responseType }) .then((response) => response.data) .catch((e) => { - if (e?.code === 'ECONNREFUSED' || e?.code === 'ETIMEDOUT') { + if (e?.code === 'ECONNREFUSED') { this.fallbackToTcpSocket(); // Retry immediately return axiosConnection.get(url, this.activeAxiosConfig) From ad0c7a3fe88e438276cb3ee9da0463d74f41d1b4 Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Wed, 5 Apr 2023 17:05:23 +0900 Subject: [PATCH 06/54] [esplora] reset timeout variable when retrying unix socket --- backend/src/api/bitcoin/esplora-api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index e471e4cde..ee7fa4765 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -34,6 +34,7 @@ class ElectrsApi implements AbstractBitcoinApi { 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); } From cba53887cd4346ab5860fe32d0b5c0b1abed359b Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Wed, 5 Apr 2023 22:44:01 +0900 Subject: [PATCH 07/54] [main loop] retry every seconds upon exception - warn after 5 attempts --- backend/src/index.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index a7f805313..abaec9cef 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 f4c3e77c71ca30677d3bc63b04690f31993e850c Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Thu, 6 Apr 2023 11:54:22 +0900 Subject: [PATCH 08/54] [indexing] fix typescript issue, reading invalid field --- backend/src/repositories/BlocksRepository.ts | 52 ++++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index 69d597e1f..45b1499ae 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -13,6 +13,48 @@ import chainTips from '../api/chain-tips'; import blocks from '../api/blocks'; import BlocksAuditsRepository from './BlocksAuditsRepository'; +interface DatabaseBlock { + id: string; + height: number; + version: number; + timestamp: number; + bits: number; + nonce: number; + difficulty: number; + merkle_root: string; + tx_count: number; + size: number; + weight: number; + previousblockhash: string; + mediantime: number; + totalFees: number; + medianFee: number; + feeRange: string; + reward: number; + poolId: number; + poolName: string; + poolSlug: string; + avgFee: number; + avgFeeRate: number; + coinbaseRaw: string; + coinbaseAddress: string; + coinbaseSignature: string; + coinbaseSignatureAscii: string; + avgTxSize: number; + totalInputs: number; + totalOutputs: number; + totalOutputAmt: number; + medianFeeAmt: number; + feePercentiles: string; + segwitTotalTxs: number; + segwitTotalSize: number; + segwitTotalWeight: number; + header: string; + utxoSetChange: number; + utxoSetSize: number; + totalInputAmt: number; +} + const BLOCK_DB_FIELDS = ` blocks.hash AS id, blocks.height, @@ -52,7 +94,7 @@ const BLOCK_DB_FIELDS = ` blocks.header, blocks.utxoset_change AS utxoSetChange, blocks.utxoset_size AS utxoSetSize, - blocks.total_input_amt AS totalInputAmts + blocks.total_input_amt AS totalInputAmt `; class BlocksRepository { @@ -432,7 +474,7 @@ class BlocksRepository { const blocks: BlockExtended[] = []; for (const block of rows) { - blocks.push(await this.formatDbBlockIntoExtendedBlock(block)); + blocks.push(await this.formatDbBlockIntoExtendedBlock(block as DatabaseBlock)); } return blocks; @@ -459,7 +501,7 @@ class BlocksRepository { return null; } - return await this.formatDbBlockIntoExtendedBlock(rows[0]); + return await this.formatDbBlockIntoExtendedBlock(rows[0] as DatabaseBlock); } catch (e) { logger.err(`Cannot get indexed block ${height}. Reason: ` + (e instanceof Error ? e.message : e)); throw e; @@ -908,7 +950,7 @@ class BlocksRepository { * * @param dbBlk */ - private async formatDbBlockIntoExtendedBlock(dbBlk: any): Promise { + private async formatDbBlockIntoExtendedBlock(dbBlk: DatabaseBlock): Promise { const blk: Partial = {}; const extras: Partial = {}; @@ -980,7 +1022,7 @@ class BlocksRepository { if (extras.feePercentiles === null) { const block = await bitcoinClient.getBlock(dbBlk.id, 2); const summary = blocks.summarizeBlock(block); - await BlocksSummariesRepository.$saveTransactions(dbBlk.height, dbBlk.hash, summary.transactions); + await BlocksSummariesRepository.$saveTransactions(dbBlk.height, dbBlk.id, summary.transactions); extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(dbBlk.id); } if (extras.feePercentiles !== null) { From ad5562a54961803ef85f1f53b30d4c587e8bb18d Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Thu, 6 Apr 2023 11:55:17 +0900 Subject: [PATCH 09/54] [indexing] save missing `fee_percentiles` and `median_fee_amt` when indexing on the fly --- backend/src/api/blocks.ts | 1 + backend/src/repositories/BlocksRepository.ts | 27 ++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 13232cc8e..15a218e24 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -857,6 +857,7 @@ class Blocks { } if (cleanBlock.fee_amt_percentiles !== null) { cleanBlock.median_fee_amt = cleanBlock.fee_amt_percentiles[3]; + await blocksRepository.$updateFeeAmounts(cleanBlock.hash, cleanBlock.fee_amt_percentiles, cleanBlock.median_fee_amt); } } diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index 45b1499ae..a014e317e 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -213,6 +213,32 @@ class BlocksRepository { } } + /** + * Update missing fee amounts fields + * + * @param blockHash + * @param feeAmtPercentiles + * @param medianFeeAmt + */ + public async $updateFeeAmounts(blockHash: string, feeAmtPercentiles, medianFeeAmt) : Promise { + try { + const query = ` + UPDATE blocks + SET fee_percentiles = ?, median_fee_amt = ? + WHERE hash = ? + `; + const params: any[] = [ + JSON.stringify(feeAmtPercentiles), + medianFeeAmt, + blockHash + ]; + await DB.query(query, params); + } catch (e: any) { + logger.err(`Cannot update fee amounts for block ${blockHash}. Reason: ' + ${e instanceof Error ? e.message : e}`); + throw e; + } + } + /** * Get all block height that have not been indexed between [startHeight, endHeight] */ @@ -1027,6 +1053,7 @@ class BlocksRepository { } if (extras.feePercentiles !== null) { extras.medianFeeAmt = extras.feePercentiles[3]; + await this.$updateFeeAmounts(dbBlk.id, extras.feePercentiles, extras.medianFeeAmt); } } From de8a69aa846c0197b8f333d22b6a8d48e9fdbc1f Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Thu, 6 Apr 2023 11:55:25 +0900 Subject: [PATCH 10/54] [indexing] delete dead code --- .../repositories/BlocksSummariesRepository.ts | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/backend/src/repositories/BlocksSummariesRepository.ts b/backend/src/repositories/BlocksSummariesRepository.ts index de70322bd..f2560fbe7 100644 --- a/backend/src/repositories/BlocksSummariesRepository.ts +++ b/backend/src/repositories/BlocksSummariesRepository.ts @@ -17,26 +17,6 @@ class BlocksSummariesRepository { return undefined; } - public async $saveSummary(params: { height: number, mined?: BlockSummary}): Promise { - const blockId = params.mined?.id; - try { - const transactions = JSON.stringify(params.mined?.transactions || []); - await DB.query(` - INSERT INTO blocks_summaries (height, id, transactions, template) - VALUE (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - transactions = ? - `, [params.height, blockId, transactions, '[]', transactions]); - } catch (e: any) { - if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart - logger.debug(`Cannot save block summary for ${blockId} because it has already been indexed, ignoring`); - } else { - logger.debug(`Cannot save block summary for ${blockId}. Reason: ${e instanceof Error ? e.message : e}`); - throw e; - } - } - } - public async $saveTransactions(blockHeight: number, blockId: string, transactions: TransactionStripped[]): Promise { try { const transactionsStr = JSON.stringify(transactions); From ba55fda06249240de8c056f69eaacabe967a8ada Mon Sep 17 00:00:00 2001 From: wiz Date: Thu, 6 Apr 2023 17:52:17 +0900 Subject: [PATCH 11/54] ops: Add fallback TCP socket for esplora backends --- production/mempool-config.bisq.json | 3 ++- production/mempool-config.liquid.json | 1 + production/mempool-config.liquidtestnet.json | 1 + production/mempool-config.mainnet-lightning.json | 1 + production/mempool-config.mainnet.json | 1 + production/mempool-config.signet-lightning.json | 3 ++- production/mempool-config.signet.json | 1 + production/mempool-config.testnet-lightning.json | 3 ++- production/mempool-config.testnet.json | 1 + 9 files changed, 12 insertions(+), 3 deletions(-) diff --git a/production/mempool-config.bisq.json b/production/mempool-config.bisq.json index 124773aeb..26024f8a3 100644 --- a/production/mempool-config.bisq.json +++ b/production/mempool-config.bisq.json @@ -15,7 +15,8 @@ "PASSWORD": "__BITCOIN_RPC_PASS__" }, "ESPLORA": { - "REST_API_URL": "http://127.0.0.1:4000" + "REST_API_URL": "http://127.0.0.1:5000", + "UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-mainnet" }, "DATABASE": { "ENABLED": false, diff --git a/production/mempool-config.liquid.json b/production/mempool-config.liquid.json index 72c469da5..29223fa07 100644 --- a/production/mempool-config.liquid.json +++ b/production/mempool-config.liquid.json @@ -23,6 +23,7 @@ "PASSWORD": "__BITCOIN_RPC_PASS__" }, "ESPLORA": { + "REST_API_URL": "http://127.0.0.1:5001", "UNIX_SOCKET_PATH": "/elements/socket/esplora-liquid-mainnet" }, "DATABASE": { diff --git a/production/mempool-config.liquidtestnet.json b/production/mempool-config.liquidtestnet.json index 3a6cafe0d..82b41e07f 100644 --- a/production/mempool-config.liquidtestnet.json +++ b/production/mempool-config.liquidtestnet.json @@ -23,6 +23,7 @@ "PASSWORD": "__BITCOIN_RPC_PASS__" }, "ESPLORA": { + "REST_API_URL": "http://127.0.0.1:5004", "UNIX_SOCKET_PATH": "/elements/socket/esplora-liquid-testnet" }, "DATABASE": { diff --git a/production/mempool-config.mainnet-lightning.json b/production/mempool-config.mainnet-lightning.json index 37e407aeb..21e7109e9 100644 --- a/production/mempool-config.mainnet-lightning.json +++ b/production/mempool-config.mainnet-lightning.json @@ -15,6 +15,7 @@ "PASSWORD": "__BITCOIN_RPC_PASS__" }, "ESPLORA": { + "REST_API_URL": "http://127.0.0.1:5000", "UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-mainnet" }, "LIGHTNING": { diff --git a/production/mempool-config.mainnet.json b/production/mempool-config.mainnet.json index 6eb09df8d..1cbed5119 100644 --- a/production/mempool-config.mainnet.json +++ b/production/mempool-config.mainnet.json @@ -31,6 +31,7 @@ "PASSWORD": "__BITCOIN_RPC_PASS__" }, "ESPLORA": { + "REST_API_URL": "http://127.0.0.1:5000", "UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-mainnet" }, "DATABASE": { diff --git a/production/mempool-config.signet-lightning.json b/production/mempool-config.signet-lightning.json index a787b831b..7751d8f0e 100644 --- a/production/mempool-config.signet-lightning.json +++ b/production/mempool-config.signet-lightning.json @@ -15,7 +15,8 @@ "PASSWORD": "__BITCOIN_RPC_PASS__" }, "ESPLORA": { - "REST_API_URL": "http://127.0.0.1:4003" + "REST_API_URL": "http://127.0.0.1:5003", + "UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-signet" }, "LIGHTNING": { "ENABLED": true, diff --git a/production/mempool-config.signet.json b/production/mempool-config.signet.json index 7b8667d11..8cd21047d 100644 --- a/production/mempool-config.signet.json +++ b/production/mempool-config.signet.json @@ -22,6 +22,7 @@ "PASSWORD": "__BITCOIN_RPC_PASS__" }, "ESPLORA": { + "REST_API_URL": "http://127.0.0.1:5003", "UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-signet" }, "DATABASE": { diff --git a/production/mempool-config.testnet-lightning.json b/production/mempool-config.testnet-lightning.json index c2d0a257e..d8283b779 100644 --- a/production/mempool-config.testnet-lightning.json +++ b/production/mempool-config.testnet-lightning.json @@ -15,7 +15,8 @@ "PASSWORD": "__BITCOIN_RPC_PASS__" }, "ESPLORA": { - "REST_API_URL": "http://127.0.0.1:4002" + "REST_API_URL": "http://127.0.0.1:5002", + "UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-testnet" }, "LIGHTNING": { "ENABLED": true, diff --git a/production/mempool-config.testnet.json b/production/mempool-config.testnet.json index c68162a9d..9f33e7aa7 100644 --- a/production/mempool-config.testnet.json +++ b/production/mempool-config.testnet.json @@ -22,6 +22,7 @@ "PASSWORD": "__BITCOIN_RPC_PASS__" }, "ESPLORA": { + "REST_API_URL": "http://127.0.0.1:5002", "UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-testnet" }, "DATABASE": { From 9f375c47e9aff8c65027d83eea6b81424566f5ee Mon Sep 17 00:00:00 2001 From: wiz Date: Thu, 6 Apr 2023 19:18:38 +0900 Subject: [PATCH 12/54] ops: Tweak boot-time delays for all daemons --- production/bitcoin.crontab | 10 +++++----- production/elements.crontab | 8 ++++---- production/install | 8 ++++---- production/mempool.crontab | 2 +- production/minfee.crontab | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/production/bitcoin.crontab b/production/bitcoin.crontab index 1f2518da4..cca0443c7 100644 --- a/production/bitcoin.crontab +++ b/production/bitcoin.crontab @@ -1,5 +1,5 @@ -@reboot sleep 30 ; screen -dmS mainnet /bitcoin/electrs/electrs-start-mainnet -@reboot sleep 60 ; /usr/local/bin/bitcoind -testnet >/dev/null 2>&1 -@reboot sleep 70 ; screen -dmS testnet /bitcoin/electrs/electrs-start-testnet -@reboot sleep 80 ; /usr/local/bin/bitcoind -signet >/dev/null 2>&1 -@reboot sleep 90 ; screen -dmS signet /bitcoin/electrs/electrs-start-signet +@reboot screen -dmS mainnet /bitcoin/electrs/electrs-start-mainnet +@reboot /usr/local/bin/bitcoind -testnet >/dev/null 2>&1 +@reboot screen -dmS testnet /bitcoin/electrs/electrs-start-testnet +@reboot /usr/local/bin/bitcoind -signet >/dev/null 2>&1 +@reboot screen -dmS signet /bitcoin/electrs/electrs-start-signet diff --git a/production/elements.crontab b/production/elements.crontab index 5ba8151a3..c0194ac4f 100644 --- a/production/elements.crontab +++ b/production/elements.crontab @@ -1,10 +1,10 @@ # start elements on reboot -@reboot sleep 60 ; /usr/local/bin/elementsd -chain=liquidv1 >/dev/null 2>&1 -@reboot sleep 60 ; /usr/local/bin/elementsd -chain=liquidtestnet >/dev/null 2>&1 +@reboot /usr/local/bin/elementsd -chain=liquidv1 >/dev/null 2>&1 +@reboot /usr/local/bin/elementsd -chain=liquidtestnet >/dev/null 2>&1 # start electrs on reboot -@reboot sleep 90 ; screen -dmS liquidv1 /elements/electrs/electrs-start-liquid -@reboot sleep 90 ; screen -dmS liquidtestnet /elements/electrs/electrs-start-liquidtestnet +@reboot screen -dmS liquidv1 /elements/electrs/electrs-start-liquid +@reboot screen -dmS liquidtestnet /elements/electrs/electrs-start-liquidtestnet # hourly asset update and electrs restart 6 * * * * cd $HOME/asset_registry_db && git pull --quiet origin master && cd $HOME/asset_registry_testnet_db && git pull --quiet origin master && killall electrs diff --git a/production/install b/production/install index 27c4dbd14..b50fa5f11 100755 --- a/production/install +++ b/production/install @@ -1325,10 +1325,10 @@ case $OS in public_ipv4=$( ifconfig | grep 'inet ' | awk -F ' ' '{ print $2 }' | grep -v '^103\.165\.192\.' | grep -v '^127\.0\.0\.1' ) public_ipv6=$( ifconfig | grep 'inet6' | awk -F ' ' '{ print $2 }' | grep -v '^2001:df6:7280::' | grep -v '^fe80::' | grep -v '^::1' ) - crontab_cln+="@reboot sleep 60 ; screen -dmS main lightningd --rpc-file-mode 0660 --alias `hostname` --disable-ip-discovery --autolisten false --bind-addr $public_ipv4 --announce-addr $public_ipv4 --bind-addr $public_ipv6 --announce-addr $public_ipv6\n" - crontab_cln+="@reboot sleep 90 ; screen -dmS tes lightningd --rpc-file-mode 0660 --alias `hostname` --network testnet --disable-ip-discovery --autolisten false --bind-addr $public_ipv4 --announce-addr $public_ipv4 --bind-addr $public_ipv6 --announce-addr $public_ipv6\n" - crontab_cln+="@reboot sleep 120 ; screen -dmS sig lightningd --rpc-file-mode 0660 --alias `hostname` --network signet --disable-ip-discovery --autolisten false --bind-addr $public_ipv4 --announce-addr $public_ipv4 --bind-addr $public_ipv6 --announce-addr $public_ipv6 \n" - crontab_cln+="@reboot sleep 180 ; /mempool/mempool.space/lightning-seeder >/dev/null 2>&1\n" + crontab_cln+="@reboot sleep 10 ; screen -dmS main lightningd --rpc-file-mode 0660 --alias `hostname` --disable-ip-discovery --autolisten false --bind-addr $public_ipv4 --announce-addr $public_ipv4 --bind-addr $public_ipv6 --announce-addr $public_ipv6\n" + crontab_cln+="@reboot sleep 10 ; screen -dmS tes lightningd --rpc-file-mode 0660 --alias `hostname` --network testnet --disable-ip-discovery --autolisten false --bind-addr $public_ipv4 --announce-addr $public_ipv4 --bind-addr $public_ipv6 --announce-addr $public_ipv6\n" + crontab_cln+="@reboot sleep 10 ; screen -dmS sig lightningd --rpc-file-mode 0660 --alias `hostname` --network signet --disable-ip-discovery --autolisten false --bind-addr $public_ipv4 --announce-addr $public_ipv4 --bind-addr $public_ipv6 --announce-addr $public_ipv6 \n" + crontab_cln+="@reboot sleep 20 ; /mempool/mempool.space/lightning-seeder >/dev/null 2>&1\n" crontab_cln+="1 * * * * /mempool/mempool.space/lightning-seeder >/dev/null 2>&1\n" echo "${crontab_cln}" | crontab -u "${CLN_USER}" - ;; diff --git a/production/mempool.crontab b/production/mempool.crontab index 0e7b6af6b..196c2566e 100644 --- a/production/mempool.crontab +++ b/production/mempool.crontab @@ -1,5 +1,5 @@ # start on reboot -@reboot sleep 10 ; $HOME/start +@reboot sleep 90 ; $HOME/start # daily backup 37 13 * * * sleep 30 ; /mempool/mempool.space/backup >/dev/null 2>&1 & diff --git a/production/minfee.crontab b/production/minfee.crontab index d5b882933..61b4d25a8 100644 --- a/production/minfee.crontab +++ b/production/minfee.crontab @@ -1 +1 @@ -@reboot sleep 120 ; /usr/local/bin/bitcoind >/dev/null 2>&1 +@reboot /usr/local/bin/bitcoind >/dev/null 2>&1 From 0c1a071aa0f387be3925c2c7f9635425c0d47df2 Mon Sep 17 00:00:00 2001 From: wiz Date: Thu, 6 Apr 2023 19:19:10 +0900 Subject: [PATCH 13/54] ops: Update hard-coded path for liquid asset icons --- backend/src/api/liquid/icons.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/api/liquid/icons.ts b/backend/src/api/liquid/icons.ts index ee08757d0..a963c703c 100644 --- a/backend/src/api/liquid/icons.ts +++ b/backend/src/api/liquid/icons.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import logger from '../../logger'; class Icons { - private static FILE_NAME = './icons.json'; + private static FILE_NAME = '/elements/asset_registry_db/icons.json'; private iconIds: string[] = []; private icons: { [assetId: string]: string; } = {}; From 9b9956138df4456753a7c797857885b4e552aee7 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 6 Apr 2023 03:27:13 +0900 Subject: [PATCH 14/54] Perform full cpfp calculations for the entire mempool --- backend/src/api/tx-selection-worker.ts | 34 ++++++-------------------- 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/backend/src/api/tx-selection-worker.ts b/backend/src/api/tx-selection-worker.ts index 7297cbe88..93060cd67 100644 --- a/backend/src/api/tx-selection-worker.ts +++ b/backend/src/api/tx-selection-worker.ts @@ -107,7 +107,7 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction }) if (nextTx && !nextTx?.used) { // Check if the package fits into this block - if (blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS) { + if (blocks.length >= 7 || (blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS)) { const ancestors: AuditTransaction[] = Array.from(nextTx.ancestorMap.values()); // sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count) const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx]; @@ -175,34 +175,14 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction }) overflow = []; } } - // pack any leftover transactions into the last block - for (const tx of overflow) { - if (!tx || tx?.used) { - continue; - } - blockWeight += tx.weight; - const mempoolTx = mempool[tx.txid]; - // update original copy of this tx with effective fee rate & relatives data - mempoolTx.effectiveFeePerVsize = tx.score; - if (tx.ancestorMap.size > 0) { - cpfpClusters[tx.txid] = Array.from(tx.ancestorMap?.values()).map(a => a.txid); - mempoolTx.cpfpRoot = tx.txid; - } - mempoolTx.cpfpChecked = true; - transactions.push(tx); - tx.used = true; + + if (overflow.length > 0) { + logger.warn('GBT overflow list unexpectedly non-empty after final block constructed'); } - const blockTransactions = transactions.map(t => mempool[t.txid]); - restOfArray.forEach(tx => { - blockWeight += tx.weight; - tx.effectiveFeePerVsize = tx.feePerVsize; - tx.cpfpChecked = false; - blockTransactions.push(tx); - }); - if (blockTransactions.length) { - blocks.push(blockTransactions); + // add the final unbounded block if it contains any transactions + if (transactions.length > 0) { + blocks.push(transactions.map(t => mempool[t.txid])); } - transactions = []; const end = Date.now(); const time = end - start; From 30bfea8c20303cf4fb31af376f36e267f127e9bb Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 7 Apr 2023 01:44:26 +0900 Subject: [PATCH 15/54] Prevent mempool block animations except when new block mined --- .../mempool-blocks/mempool-blocks.component.html | 2 +- .../mempool-blocks/mempool-blocks.component.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) 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 58d555657..0c0564603 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html @@ -2,7 +2,7 @@
-
+
 
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 d48d1f299..1b647fc53 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; +import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, HostListener } from '@angular/core'; import { Subscription, Observable, fromEvent, merge, of, combineLatest } from 'rxjs'; import { MempoolBlock } from '../../interfaces/websocket.interface'; import { StateService } from '../../services/state.service'; @@ -222,8 +222,13 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy { clearTimeout(this.resetTransitionTimeout); } + @HostListener('window:resize', ['$event']) + onResize(): void { + this.animateEntry = false; + } + trackByFn(index: number, block: MempoolBlock) { - return (block.isStack) ? 'stack' : block.index; + return (block.isStack) ? `stack-${block.index}` : block.index; } reduceMempoolBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] { From 545bef0cf993d46fc7d1d3e54131553518105a79 Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Fri, 7 Apr 2023 13:28:32 +0900 Subject: [PATCH 16/54] [config] add missing RETRY_UNIX_SOCKET_AFTER --- backend/mempool-config.sample.json | 3 ++- backend/src/__fixtures__/mempool-config.template.json | 3 ++- backend/src/__tests__/config.test.ts | 2 +- docker/README.md | 4 +++- docker/backend/mempool-config.json | 3 ++- docker/backend/start.sh | 2 ++ 6 files changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json index 99b69c208..1c7097de4 100644 --- a/backend/mempool-config.sample.json +++ b/backend/mempool-config.sample.json @@ -44,7 +44,8 @@ }, "ESPLORA": { "REST_API_URL": "http://127.0.0.1:3000", - "UNIX_SOCKET_PATH": "/tmp/esplora-bitcoin-mainnet" + "UNIX_SOCKET_PATH": "/tmp/esplora-bitcoin-mainnet", + "RETRY_UNIX_SOCKET_AFTER": 30000 }, "SECOND_CORE_RPC": { "HOST": "127.0.0.1", diff --git a/backend/src/__fixtures__/mempool-config.template.json b/backend/src/__fixtures__/mempool-config.template.json index 625f3d8a0..6330358b7 100644 --- a/backend/src/__fixtures__/mempool-config.template.json +++ b/backend/src/__fixtures__/mempool-config.template.json @@ -45,7 +45,8 @@ }, "ESPLORA": { "REST_API_URL": "__ESPLORA_REST_API_URL__", - "UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__" + "UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__", + "RETRY_UNIX_SOCKET_AFTER": "__RETRY_UNIX_SOCKET_AFTER__" }, "SECOND_CORE_RPC": { "HOST": "__SECOND_CORE_RPC_HOST__", diff --git a/backend/src/__tests__/config.test.ts b/backend/src/__tests__/config.test.ts index 75b87c6cf..e12f90a86 100644 --- a/backend/src/__tests__/config.test.ts +++ b/backend/src/__tests__/config.test.ts @@ -47,7 +47,7 @@ describe('Mempool Backend Config', () => { expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true }); - expect(config.ESPLORA).toStrictEqual({ REST_API_URL: 'http://127.0.0.1:3000', UNIX_SOCKET_PATH: null }); + expect(config.ESPLORA).toStrictEqual({ REST_API_URL: 'http://127.0.0.1:3000', UNIX_SOCKET_PATH: null, RETRY_UNIX_SOCKET_AFTER: 30000 }); expect(config.CORE_RPC).toStrictEqual({ HOST: '127.0.0.1', diff --git a/docker/README.md b/docker/README.md index e81e45b42..ee5ba11c5 100644 --- a/docker/README.md +++ b/docker/README.md @@ -205,7 +205,8 @@ Corresponding `docker-compose.yml` overrides: ```json "ESPLORA": { "REST_API_URL": "http://127.0.0.1:3000", - "UNIX_SOCKET_PATH": "/tmp/esplora-socket" + "UNIX_SOCKET_PATH": "/tmp/esplora-socket", + "RETRY_UNIX_SOCKET_AFTER": 30000 }, ``` @@ -215,6 +216,7 @@ Corresponding `docker-compose.yml` overrides: environment: ESPLORA_REST_API_URL: "" ESPLORA_UNIX_SOCKET_PATH: "" + ESPLORA_RETRY_UNIX_SOCKET_AFTER: "" ... ``` diff --git a/docker/backend/mempool-config.json b/docker/backend/mempool-config.json index 7d2737e9c..58e6b5629 100644 --- a/docker/backend/mempool-config.json +++ b/docker/backend/mempool-config.json @@ -43,7 +43,8 @@ }, "ESPLORA": { "REST_API_URL": "__ESPLORA_REST_API_URL__", - "UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__" + "UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__", + "RETRY_UNIX_SOCKET_AFTER": "__RETRY_UNIX_SOCKET_AFTER__" }, "SECOND_CORE_RPC": { "HOST": "__SECOND_CORE_RPC_HOST__", diff --git a/docker/backend/start.sh b/docker/backend/start.sh index 55da48464..c6ce8f1e7 100755 --- a/docker/backend/start.sh +++ b/docker/backend/start.sh @@ -47,6 +47,7 @@ __ELECTRUM_TLS_ENABLED__=${ELECTRUM_TLS_ENABLED:=false} # ESPLORA __ESPLORA_REST_API_URL__=${ESPLORA_REST_API_URL:=http://127.0.0.1:3000} __ESPLORA_UNIX_SOCKET_PATH__=${ESPLORA_UNIX_SOCKET_PATH:=null} +__ESPLORA_RETRY_UNIX_SOCKET_AFTER__=${ESPLORA_RETRY_UNIX_SOCKET_AFTER:=30000} # SECOND_CORE_RPC __SECOND_CORE_RPC_HOST__=${SECOND_CORE_RPC_HOST:=127.0.0.1} @@ -168,6 +169,7 @@ sed -i "s/__ELECTRUM_TLS_ENABLED__/${__ELECTRUM_TLS_ENABLED__}/g" mempool-config sed -i "s!__ESPLORA_REST_API_URL__!${__ESPLORA_REST_API_URL__}!g" mempool-config.json sed -i "s!__ESPLORA_UNIX_SOCKET_PATH__!${__ESPLORA_UNIX_SOCKET_PATH__}!g" mempool-config.json +sed -i "s!__ESPLORA_RETRY_UNIX_SOCKET_AFTER__!${__ESPLORA_RETRY_UNIX_SOCKET_AFTER__}!g" mempool-config.json sed -i "s/__SECOND_CORE_RPC_HOST__/${__SECOND_CORE_RPC_HOST__}/g" mempool-config.json sed -i "s/__SECOND_CORE_RPC_PORT__/${__SECOND_CORE_RPC_PORT__}/g" mempool-config.json From e8c4a2ca03f622f00f0ef60b8d1b567810628aa7 Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Sat, 8 Apr 2023 10:42:08 +0900 Subject: [PATCH 17/54] [config] fix docker esplora config and template --- backend/src/__fixtures__/mempool-config.template.json | 2 +- docker/backend/mempool-config.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/__fixtures__/mempool-config.template.json b/backend/src/__fixtures__/mempool-config.template.json index 6330358b7..6c2269a4f 100644 --- a/backend/src/__fixtures__/mempool-config.template.json +++ b/backend/src/__fixtures__/mempool-config.template.json @@ -46,7 +46,7 @@ "ESPLORA": { "REST_API_URL": "__ESPLORA_REST_API_URL__", "UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__", - "RETRY_UNIX_SOCKET_AFTER": "__RETRY_UNIX_SOCKET_AFTER__" + "RETRY_UNIX_SOCKET_AFTER": "__ESPLORA_RETRY_UNIX_SOCKET_AFTER__" }, "SECOND_CORE_RPC": { "HOST": "__SECOND_CORE_RPC_HOST__", diff --git a/docker/backend/mempool-config.json b/docker/backend/mempool-config.json index 58e6b5629..f4543bd2e 100644 --- a/docker/backend/mempool-config.json +++ b/docker/backend/mempool-config.json @@ -44,7 +44,7 @@ "ESPLORA": { "REST_API_URL": "__ESPLORA_REST_API_URL__", "UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__", - "RETRY_UNIX_SOCKET_AFTER": "__RETRY_UNIX_SOCKET_AFTER__" + "RETRY_UNIX_SOCKET_AFTER": __ESPLORA_RETRY_UNIX_SOCKET_AFTER__ }, "SECOND_CORE_RPC": { "HOST": "__SECOND_CORE_RPC_HOST__", From 036bf3de2c98e4ec0ef662cef893124b566fd752 Mon Sep 17 00:00:00 2001 From: softsimon Date: Wed, 26 Apr 2023 13:49:01 +0400 Subject: [PATCH 18/54] 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 1d42d93649430b91f1be7a1ca5cc5628aab44451 Mon Sep 17 00:00:00 2001 From: softsimon Date: Fri, 28 Apr 2023 12:06:49 +0400 Subject: [PATCH 19/54] 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 e63ca81de9c38c8207e472d4f59876c838786414 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 27 Apr 2023 10:19:12 +0900 Subject: [PATCH 20/54] 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 725e1cabb66988fb0e12f120f29a1b5bd2957c12 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 28 Apr 2023 19:05:49 -0600 Subject: [PATCH 21/54] 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 0552ff8c4bc0bae1469e2cfcefd9346dd1c5461a Mon Sep 17 00:00:00 2001 From: softsimon Date: Mon, 1 May 2023 00:16:23 +0400 Subject: [PATCH 22/54] 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 e6a23567e0eb15a9ab9f2a0d44bed4a9cc78ff58 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sun, 30 Apr 2023 13:59:29 -0600 Subject: [PATCH 23/54] 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 27c7b4be5f1d6ccbc88457aaa99f6f9b721aa640 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sun, 30 Apr 2023 15:28:34 -0600 Subject: [PATCH 24/54] 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 eac7c5023a1ff1dbd38f955554638ce065039ed5 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 1 May 2023 13:08:29 -0600 Subject: [PATCH 25/54] 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 490b7886d236e9b7c425bcf36cd09e745a4bb277 Mon Sep 17 00:00:00 2001 From: softsimon Date: Tue, 2 May 2023 15:40:16 +0400 Subject: [PATCH 26/54] 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 df995585fc31c5db7fef3742b1e580569271d8e9 Mon Sep 17 00:00:00 2001 From: softsimon Date: Tue, 2 May 2023 17:39:02 +0400 Subject: [PATCH 27/54] 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 5abdb700ad32b0046faa0d93d421edfc638dc830 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 2 May 2023 16:57:10 -0600 Subject: [PATCH 28/54] 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 268a05cc3790b29337be3fd3be28d1000ab001f1 Mon Sep 17 00:00:00 2001 From: softsimon Date: Wed, 3 May 2023 10:11:44 +0400 Subject: [PATCH 29/54] 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 7b26e094869d184b3bbd299fa63d1712de3beb14 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 3 May 2023 10:02:03 -0600 Subject: [PATCH 30/54] 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 1d5a029c8197be3b4ba72fbaac05d7446536b37d Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 13 Dec 2022 17:11:37 -0600 Subject: [PATCH 31/54] 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 22fde5276ac187b9bac80cc57b20b9ad8d996156 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 14 Dec 2022 08:49:35 -0600 Subject: [PATCH 32/54] 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 47bbfca1c077d79e26ad070a3b7a72ade755febb Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 14 Dec 2022 16:51:53 -0600 Subject: [PATCH 33/54] 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 9517d5fe08c688cd28de090f50b171d6327b2dd3 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 14 Dec 2022 17:03:02 -0600 Subject: [PATCH 34/54] 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 3e703ec13eb8988d645d10df22ab56b5c1d905c2 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sat, 17 Dec 2022 09:39:06 -0600 Subject: [PATCH 35/54] 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 +
+
+ + +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+ + +
+
+