From f968faeaf979fe1f5b96fb6e944f5984d116c400 Mon Sep 17 00:00:00 2001 From: hunicus <93150691+hunicus@users.noreply.github.com> Date: Thu, 2 Mar 2023 02:39:59 -0500 Subject: [PATCH 001/422] Specify manual deployment support for enterprise sponsors [faq] --- frontend/src/app/docs/api-docs/api-docs.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/docs/api-docs/api-docs.component.html b/frontend/src/app/docs/api-docs/api-docs.component.html index 8c8d6ac36..2e9a12687 100644 --- a/frontend/src/app/docs/api-docs/api-docs.component.html +++ b/frontend/src/app/docs/api-docs/api-docs.component.html @@ -267,7 +267,7 @@ - You can manually install Mempool on your own Linux server, but this requires advanced sysadmin skills since you will be manually configuring everything. We do not provide support for manual deployments. + You can manually install Mempool on your own Linux server, but this requires advanced sysadmin skills since you will be manually configuring everything. We only provide support for manual deployments to enterprise sponsors. From e7ad857cc94bbfef3c2d55c94d0cb04eb200f95b Mon Sep 17 00:00:00 2001 From: hunicus <93150691+hunicus@users.noreply.github.com> Date: Thu, 2 Mar 2023 03:12:56 -0500 Subject: [PATCH 002/422] Specify manual deployment support for enterprise sponsors [readme] --- backend/README.md | 2 +- production/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/README.md b/backend/README.md index be85d25af..ee934a14f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,7 +2,7 @@ These instructions are mostly intended for developers. -If you choose to use these instructions for a production setup, be aware that you will still probably need to do additional configuration for your specific OS, environment, use-case, etc. We do our best here to provide a good starting point, but only proceed if you know what you're doing. Mempool does not provide support for custom setups. +If you choose to use these instructions for a production setup, be aware that you will still probably need to do additional configuration for your specific OS, environment, use-case, etc. We do our best here to provide a good starting point, but only proceed if you know what you're doing. Mempool only provides support for custom setups to [enterprise sponsors](https://mempool.space/enterprise). See other ways to set up Mempool on [the main README](/../../#installation-methods). diff --git a/production/README.md b/production/README.md index 91b087ffa..87b8bb0a1 100644 --- a/production/README.md +++ b/production/README.md @@ -2,7 +2,7 @@ These instructions are for setting up a serious production Mempool website for Bitcoin (mainnet, testnet, signet), Liquid (mainnet, testnet), and Bisq. -Again, this setup is no joke—home users should use [one of the other installation methods](../#installation-methods). +Again, this setup is no joke—home users should use [one of the other installation methods](../#installation-methods). Support is only provided to [enterprise sponsors](https://mempool.space/enterprise). ### Server Hardware From f493da4eac1fafd867cd08fd46675bb22d106a36 Mon Sep 17 00:00:00 2001 From: hunicus <93150691+hunicus@users.noreply.github.com> Date: Sat, 4 Mar 2023 04:07:23 -0500 Subject: [PATCH 003/422] Generalize faq from linux to any server --- frontend/src/app/docs/api-docs/api-docs-data.ts | 4 ++-- frontend/src/app/docs/api-docs/api-docs.component.html | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/docs/api-docs/api-docs-data.ts b/frontend/src/app/docs/api-docs/api-docs-data.ts index 62d031613..9c5502bf1 100644 --- a/frontend/src/app/docs/api-docs/api-docs-data.ts +++ b/frontend/src/app/docs/api-docs/api-docs-data.ts @@ -8921,8 +8921,8 @@ export const faqData = [ type: "endpoint", category: "self-hosting", showConditions: bitcoinNetworks, - fragment: "host-my-own-instance-linux-server", - title: "How can I host my own instance on a Linux server?", + fragment: "host-my-own-instance-server", + title: "How can I host a Mempool instance on my own server?", }, { type: "endpoint", diff --git a/frontend/src/app/docs/api-docs/api-docs.component.html b/frontend/src/app/docs/api-docs/api-docs.component.html index 2e9a12687..313ff0b52 100644 --- a/frontend/src/app/docs/api-docs/api-docs.component.html +++ b/frontend/src/app/docs/api-docs/api-docs.component.html @@ -266,8 +266,9 @@ We support one-click installation on a number of Raspberry Pi full-node distros including Umbrel, RaspiBlitz, MyNode, RoninDojo, and Start9's Embassy. - - You can manually install Mempool on your own Linux server, but this requires advanced sysadmin skills since you will be manually configuring everything. We only provide support for manual deployments to enterprise sponsors. + +

You can manually install Mempool on your own server, but this requires advanced sysadmin skills since you will be manually configuring everything. You could also use our Docker images.

In any case, we only provide support for manual deployments to enterprise sponsors.

+

For casual users, we strongly suggest installing Mempool using one of the 1-click install methods.

From 6cc2f2063852f663b5f8699aceea9e49b9fddc63 Mon Sep 17 00:00:00 2001 From: hunicus <93150691+hunicus@users.noreply.github.com> Date: Sun, 12 Mar 2023 04:13:20 -0400 Subject: [PATCH 004/422] Use href for enterprise links --- frontend/src/app/docs/api-docs/api-docs.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/docs/api-docs/api-docs.component.html b/frontend/src/app/docs/api-docs/api-docs.component.html index e7e9a5403..7ef56ffaf 100644 --- a/frontend/src/app/docs/api-docs/api-docs.component.html +++ b/frontend/src/app/docs/api-docs/api-docs.component.html @@ -40,7 +40,7 @@

Below is a reference for the {{ network.val === '' ? 'Bitcoin' : network.val.charAt(0).toUpperCase() + network.val.slice(1) }} REST API service.

-

Note that we enforce rate limits. If you exceed these limits, you will get an HTTP 429 error. If you repeatedly exceed the limits, you may be banned from accessing the service altogether. Consider an enterprise sponsorship if you need higher API limits.

+

Note that we enforce rate limits. If you exceed these limits, you will get an HTTP 429 error. If you repeatedly exceed the limits, you may be banned from accessing the service altogether. Consider an enterprise sponsorship if you need higher API limits.

{{ item.title }}

@@ -268,7 +268,7 @@ -

You can manually install Mempool on your own server, but this requires advanced sysadmin skills since you will be manually configuring everything. You could also use our Docker images.

In any case, we only provide support for manual deployments to enterprise sponsors.

+

You can manually install Mempool on your own server, but this requires advanced sysadmin skills since you will be manually configuring everything. You could also use our Docker images.

In any case, we only provide support for manual deployments to enterprise sponsors.

For casual users, we strongly suggest installing Mempool using one of the 1-click install methods.

From c675d1c498d4e20b2645d20fc8fdb40cb9fb9e4e Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Tue, 28 Mar 2023 23:07:50 +0900 Subject: [PATCH 005/422] Make sure to scan closed channels even if config.MEMPOOL.ENABLE = false --- backend/src/tasks/lightning/network-sync.service.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/src/tasks/lightning/network-sync.service.ts b/backend/src/tasks/lightning/network-sync.service.ts index 3e5ae1366..fc04f5023 100644 --- a/backend/src/tasks/lightning/network-sync.service.ts +++ b/backend/src/tasks/lightning/network-sync.service.ts @@ -269,7 +269,11 @@ class NetworkSyncService { } private async $scanForClosedChannels(): Promise { - if (this.closedChannelsScanBlock === blocks.getCurrentBlockHeight()) { + let currentBlockHeight = blocks.getCurrentBlockHeight(); + if (config.MEMPOOL.ENABLED === false) { // https://github.com/mempool/mempool/issues/3582 + currentBlockHeight = await bitcoinApi.$getBlockHeightTip(); + } + if (this.closedChannelsScanBlock === currentBlockHeight) { logger.debug(`We've already scan closed channels for this block, skipping.`); return; } @@ -305,7 +309,7 @@ class NetworkSyncService { } } - this.closedChannelsScanBlock = blocks.getCurrentBlockHeight(); + this.closedChannelsScanBlock = currentBlockHeight; logger.debug(`Closed channels scan completed at block ${this.closedChannelsScanBlock}`, logger.tags.ln); } catch (e) { logger.err(`$scanForClosedChannels() error: ${e instanceof Error ? e.message : e}`, logger.tags.ln); From 7ab373ecac529d1f8f5efb921bf2a6f131f5e134 Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Wed, 29 Mar 2023 15:09:38 +0900 Subject: [PATCH 006/422] Clip overflowing labels in pool component on mobile --- .../app/components/pool/pool.component.html | 48 +++++++++---------- .../app/components/pool/pool.component.scss | 10 +++- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/frontend/src/app/components/pool/pool.component.html b/frontend/src/app/components/pool/pool.component.html index 898e50fc2..e1806086e 100644 --- a/frontend/src/app/components/pool/pool.component.html +++ b/frontend/src/app/components/pool/pool.component.html @@ -92,9 +92,9 @@ - - - + + + @@ -117,9 +117,9 @@
RewardHashrate (24h)Avg HealthRewardHashrate (24h)Avg Health
- - - + + + @@ -143,9 +143,9 @@
RewardHashrate (24h)Avg HealthRewardHashrate (24h)Avg Health
- - - + + + @@ -165,9 +165,9 @@
Blocks 24h1wAllBlocks 24h1wAll
- - - + + + @@ -382,9 +382,9 @@
Blocks 24h1wAllBlocks 24h1wAll
- - - + + + @@ -407,9 +407,9 @@
RewardHashrate (24h)Avg HealthRewardHashrate (24h)Avg Health
- - - + + + @@ -433,9 +433,9 @@
RewardHashrate (24h)Avg HealthRewardHashrate (24h)Avg Health
- - - + + + @@ -458,9 +458,9 @@
Blocks 24h1wAllBlocks 24h1wAll
- - - + + + diff --git a/frontend/src/app/components/pool/pool.component.scss b/frontend/src/app/components/pool/pool.component.scss index 21468773f..92fdc2ef3 100644 --- a/frontend/src/app/components/pool/pool.component.scss +++ b/frontend/src/app/components/pool/pool.component.scss @@ -188,11 +188,19 @@ div.scrollable { } } -.block-count-title { +.data-title { color: #4a68b9; font-size: 14px; } +.clip { + @media (max-width: 576px) { + max-width: 85px; + overflow: hidden; + text-overflow: ellipsis; + } +} + .table-data tr { background-color: transparent; } From aa882aa36a1f757f069a7c1f096713c86e3f7cb5 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sun, 2 Apr 2023 05:34:46 +0900 Subject: [PATCH 007/422] Fix RTL locale unfurls --- unfurler/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/unfurler/src/index.ts b/unfurler/src/index.ts index 0b423ff92..fedf32110 100644 --- a/unfurler/src/index.ts +++ b/unfurler/src/index.ts @@ -109,7 +109,10 @@ class Server { page.waitForSelector('meta[property="og:preview:fail"]', { timeout: config.PUPPETEER.RENDER_TIMEOUT || 3000 }).then(() => false) ]) if (success === true) { - const screenshot = await page.screenshot(); + const screenshot = await page.screenshot({ + captureBeyondViewport: false, + clip: { width: 1200, height: 600, x: 0, y: 0, scale: 1 }, + }); return screenshot; } else if (success === false) { logger.warn(`failed to render ${path} for ${action} due to client-side error, e.g. requested an invalid txid`); From bca35600ff2a18dc9f0c5a08093cab0349818627 Mon Sep 17 00:00:00 2001 From: wiz Date: Tue, 4 Apr 2023 20:18:46 +0900 Subject: [PATCH 008/422] ops: Fix installer creation of CLN folders --- production/install | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/production/install b/production/install index 27c4dbd14..b97ef16d1 100755 --- a/production/install +++ b/production/install @@ -1312,8 +1312,13 @@ case $OS in osSudo "${ROOT_USER}" pw usermod ${MEMPOOL_USER} -G "${CLN_GROUP}" osSudo "${ROOT_USER}" chsh -s `which zsh` "${CLN_USER}" echo "export PATH=$PATH:$HOME/.local/bin" >> "${CLN_HOME}/.zshrc" - osSudo "${ROOT_USER}" mkdir -p "${CLN_HOME}/.lightning/{bitcoin,signet,testnet}" - osSudo "${ROOT_USER}" chmod 750 "${CLN_HOME}" "${CLN_HOME}/.lightning" "${CLN_HOME}/.lightning/{bitcoin,signet,testnet}" + osSudo "${ROOT_USER}" mkdir -p "${CLN_HOME}/.lightning/bitcoin" + osSudo "${ROOT_USER}" mkdir -p "${CLN_HOME}/.lightning/signet" + osSudo "${ROOT_USER}" mkdir -p "${CLN_HOME}/.lightning/testnet" + osSudo "${ROOT_USER}" chmod 750 "${CLN_HOME}" "${CLN_HOME}/.lightning" + osSudo "${ROOT_USER}" chmod 750 "${CLN_HOME}" "${CLN_HOME}/.lightning/bitcoin" + osSudo "${ROOT_USER}" chmod 750 "${CLN_HOME}" "${CLN_HOME}/.lightning/signet" + osSudo "${ROOT_USER}" chmod 750 "${CLN_HOME}" "${CLN_HOME}/.lightning/testnet" osSudo "${ROOT_USER}" chown -R "${CLN_USER}:${CLN_GROUP}" "${CLN_HOME}" echo "[*] Creating symlink to .bitcoin folder" osSudo "${CLN_USER}" ln -s "${BITCOIN_HOME}/.bitcoin" "${CLN_HOME}/.bitcoin" From 3d5c156776ebac6194c60e8bdd1938d8755dc81e Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 14 Mar 2023 15:39:55 +0900 Subject: [PATCH 009/422] Use effective fee rates in mempool block visualizations & tooltips --- backend/src/api/common.ts | 1 + backend/src/mempool.interfaces.ts | 1 + .../src/app/components/block-overview-graph/tx-view.ts | 7 +++++-- .../block-overview-tooltip.component.html | 6 ++++++ .../block-overview-tooltip.component.ts | 2 ++ frontend/src/app/interfaces/websocket.interface.ts | 1 + 6 files changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 1d3b11d66..fed3f4fa2 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -83,6 +83,7 @@ export class Common { fee: tx.fee, vsize: tx.weight / 4, value: tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0), + rate: tx.effectiveFeePerVsize, }; } diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 16b856bcc..dd315f10c 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -145,6 +145,7 @@ export interface TransactionStripped { fee: number; vsize: number; value: number; + rate?: number; // effective fee rate } export interface BlockExtension { diff --git a/frontend/src/app/components/block-overview-graph/tx-view.ts b/frontend/src/app/components/block-overview-graph/tx-view.ts index fe224ebac..f2e67da5b 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -36,6 +36,7 @@ export default class TxView implements TransactionStripped { vsize: number; value: number; feerate: number; + rate?: number; status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; context?: 'projected' | 'actual'; scene?: BlockScene; @@ -58,7 +59,8 @@ export default class TxView implements TransactionStripped { this.fee = tx.fee; this.vsize = tx.vsize; this.value = tx.value; - this.feerate = tx.fee / tx.vsize; + this.feerate = tx.rate || (tx.fee / tx.vsize); // sort by effective fee rate where available + this.rate = tx.rate; this.status = tx.status; this.initialised = false; this.vertexArray = scene.vertexArray; @@ -157,7 +159,8 @@ export default class TxView implements TransactionStripped { } getColor(): Color { - const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1; + const rate = this.fee / this.vsize; // color by simple single-tx fee rate + const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, rate) < feeLvl) - 1; const feeLevelColor = feeColors[feeLevelIndex] || feeColors[mempoolFeeColors.length - 1]; // Normal mode if (!this.scene?.highlightingEnabled) { diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html index e841e291f..7e2de8d67 100644 --- a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html @@ -28,6 +28,12 @@ {{ feeRate | feeRounding }} sat/vB + + + + diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts index ea011d045..61c294263 100644 --- a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts @@ -20,6 +20,7 @@ export class BlockOverviewTooltipComponent implements OnChanges { value = 0; vsize = 1; feeRate = 0; + effectiveRate; tooltipPosition: Position = { x: 0, y: 0 }; @@ -51,6 +52,7 @@ export class BlockOverviewTooltipComponent implements OnChanges { this.value = tx.value || 0; this.vsize = tx.vsize || 1; this.feeRate = this.fee / this.vsize; + this.effectiveRate = tx.rate; } } } diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 46416857e..ac86971f0 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -71,6 +71,7 @@ export interface TransactionStripped { fee: number; vsize: number; value: number; + rate?: number; // effective fee rate status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; context?: 'projected' | 'actual'; } From 4c569c0dedf6901309ff0b8534418112c1e1b411 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 5 Apr 2023 08:40:43 +0900 Subject: [PATCH 010/422] Send mempool effective fee rate changes to frontend & apply --- backend/src/api/mempool-blocks.ts | 8 ++++++-- backend/src/mempool.interfaces.ts | 1 + .../block-overview-graph.component.ts | 4 ++-- .../components/block-overview-graph/block-scene.ts | 11 ++++++++++- .../mempool-block-overview.component.ts | 2 +- frontend/src/app/interfaces/websocket.interface.ts | 1 + 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index aa2804379..bf5c16955 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -112,6 +112,7 @@ class MempoolBlocks { for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) { let added: TransactionStripped[] = []; let removed: string[] = []; + const changed: { txid: string, rate: number | undefined }[] = []; if (mempoolBlocks[i] && !prevBlocks[i]) { added = mempoolBlocks[i].transactions; } else if (!mempoolBlocks[i] && prevBlocks[i]) { @@ -120,7 +121,7 @@ class MempoolBlocks { const prevIds = {}; const newIds = {}; prevBlocks[i].transactions.forEach(tx => { - prevIds[tx.txid] = true; + prevIds[tx.txid] = tx; }); mempoolBlocks[i].transactions.forEach(tx => { newIds[tx.txid] = true; @@ -133,12 +134,15 @@ class MempoolBlocks { mempoolBlocks[i].transactions.forEach(tx => { if (!prevIds[tx.txid]) { added.push(tx); + } else if (tx.rate !== prevIds[tx.txid].rate) { + changed.push({ txid: tx.txid, rate: tx.rate }); } }); } mempoolBlockDeltas.push({ added, - removed + removed, + changed, }); } return mempoolBlockDeltas; diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index dd315f10c..b43d05c13 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -58,6 +58,7 @@ export interface MempoolBlockWithTransactions extends MempoolBlock { export interface MempoolBlockDelta { added: TransactionStripped[]; removed: string[]; + changed: { txid: string, rate: number | undefined }[]; } interface VinStrippedToScriptsig { diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts index 3f82d63eb..940939470 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts @@ -132,9 +132,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } } - update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void { + update(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined }[], direction: string = 'left', resetLayout: boolean = false): void { if (this.scene) { - this.scene.update(add, remove, direction, resetLayout); + this.scene.update(add, remove, change, direction, resetLayout); this.start(); } } diff --git a/frontend/src/app/components/block-overview-graph/block-scene.ts b/frontend/src/app/components/block-overview-graph/block-scene.ts index e7853d5a1..7fb0a1e99 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -150,7 +150,7 @@ export default class BlockScene { this.updateAll(startTime, 200, direction); } - update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void { + update(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined }[], direction: string = 'left', resetLayout: boolean = false): void { const startTime = performance.now(); const removed = this.removeBatch(remove, startTime, direction); @@ -172,6 +172,15 @@ export default class BlockScene { this.place(tx); }); } else { + // update effective rates + change.forEach(tx => { + if (this.txs[tx.txid]) { + this.txs[tx.txid].feerate = tx.rate || (this.txs[tx.txid].fee / this.txs[tx.txid].vsize); + this.txs[tx.txid].rate = tx.rate; + this.txs[tx.txid].dirty = true; + } + }); + // try to insert new txs directly const remaining = []; add.map(tx => new TxView(tx, this)).sort(feeRateDescending).forEach(tx => { diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts index ed9f4ef75..30632a862 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts @@ -99,7 +99,7 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang const direction = (this.blockIndex == null || this.index < this.blockIndex) ? this.poolDirection : this.chainDirection; this.blockGraph.replace(delta.added, direction); } else { - this.blockGraph.update(delta.added, delta.removed, blockMined ? this.chainDirection : this.poolDirection, blockMined); + this.blockGraph.update(delta.added, delta.removed, delta.changed || [], blockMined ? this.chainDirection : this.poolDirection, blockMined); } this.lastBlockHeight = this.stateService.latestBlockHeight; diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index ac86971f0..50ffd4ebf 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -54,6 +54,7 @@ export interface MempoolBlockWithTransactions extends MempoolBlock { export interface MempoolBlockDelta { added: TransactionStripped[], removed: string[], + changed?: { txid: string, rate: number | undefined }[]; } export interface MempoolInfo { From 6602bddb2b8e040d313f5bd7a19433e315a1c993 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 7 Apr 2023 03:25:02 +0900 Subject: [PATCH 011/422] Fit mempool block skeleton loaders to screen --- .../mempool-blocks.component.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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 1b647fc53..ab0a7fd08 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -97,7 +97,7 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy { this.mempoolEmptyBlocks.forEach((b) => { this.mempoolEmptyBlockStyles.push(this.getStyleForMempoolEmptyBlock(b.index)); }); - this.reduceMempoolBlocksToFitScreen(this.mempoolEmptyBlocks); + this.reduceEmptyBlocksToFitScreen(this.mempoolEmptyBlocks); this.mempoolBlocks.map(() => { this.updateMempoolBlockStyles(); @@ -225,12 +225,33 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy { @HostListener('window:resize', ['$event']) onResize(): void { this.animateEntry = false; + this.reduceEmptyBlocksToFitScreen(this.mempoolEmptyBlocks); } trackByFn(index: number, block: MempoolBlock) { return (block.isStack) ? `stack-${block.index}` : block.index; } + reduceEmptyBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] { + const innerWidth = this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2; + const blocksAmount = Math.min(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT, Math.floor(innerWidth / (this.blockWidth + this.blockPadding))); + while (blocks.length < blocksAmount) { + blocks.push({ + blockSize: 0, + blockVSize: 0, + feeRange: [], + index: blocks.length, + medianFee: 0, + nTx: 0, + totalFees: 0 + }); + } + while (blocks.length > blocksAmount) { + blocks.pop(); + } + return blocks; + } + reduceMempoolBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] { const innerWidth = this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2; const blocksAmount = Math.min(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT, Math.floor(innerWidth / (this.blockWidth + this.blockPadding))); From bad73c180553bf830a407dea87556c357ceb7e4e Mon Sep 17 00:00:00 2001 From: Giovanni La Perna Date: Fri, 7 Apr 2023 01:04:23 +0200 Subject: [PATCH 012/422] Create giovannilaperna.txt --- contributors/giovannilaperna.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 contributors/giovannilaperna.txt diff --git a/contributors/giovannilaperna.txt b/contributors/giovannilaperna.txt new file mode 100644 index 000000000..65a9adf54 --- /dev/null +++ b/contributors/giovannilaperna.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of April 7, 203. + +Signed: giovannilaperna From 123b853205f22b6f8931bd0cdcbf2f6600d71160 Mon Sep 17 00:00:00 2001 From: Rex Date: Sat, 8 Apr 2023 11:41:34 +0800 Subject: [PATCH 013/422] Add cla --- contributors/nothing0012.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 contributors/nothing0012.txt diff --git a/contributors/nothing0012.txt b/contributors/nothing0012.txt new file mode 100644 index 000000000..e26514d35 --- /dev/null +++ b/contributors/nothing0012.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of April 8, 2023. + +Signed: nothing0012 From 71935e29c8cd4453da5538bf395928d49bdc42b7 Mon Sep 17 00:00:00 2001 From: Giovanni La Perna Date: Sat, 8 Apr 2023 11:19:04 +0200 Subject: [PATCH 014/422] Update and rename giovannilaperna.txt to learntheropes.txt change github username --- contributors/{giovannilaperna.txt => learntheropes.txt} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename contributors/{giovannilaperna.txt => learntheropes.txt} (86%) diff --git a/contributors/giovannilaperna.txt b/contributors/learntheropes.txt similarity index 86% rename from contributors/giovannilaperna.txt rename to contributors/learntheropes.txt index 65a9adf54..f8d79bf93 100644 --- a/contributors/giovannilaperna.txt +++ b/contributors/learntheropes.txt @@ -1,3 +1,3 @@ I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of April 7, 203. -Signed: giovannilaperna +Signed: learntheropes From 22ee9916ddbe8f65130d2f63bae7aab739798e57 Mon Sep 17 00:00:00 2001 From: TechMiX Date: Wed, 19 Apr 2023 12:14:21 +0200 Subject: [PATCH 015/422] fix change component and audit button position in RTL mode --- frontend/src/app/components/change/change.component.html | 2 +- frontend/src/styles.scss | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/change/change.component.html b/frontend/src/app/components/change/change.component.html index 117a0c534..ffc00bf5f 100644 --- a/frontend/src/app/components/change/change.component.html +++ b/frontend/src/app/components/change/change.component.html @@ -1,3 +1,3 @@ - {{ change >= 0 ? '+' : '' }}{{ change | amountShortener }}% + ‎{{ change >= 0 ? '+' : '' }}{{ change | amountShortener }}% diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index d9ea867dc..d64450b93 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -988,6 +988,10 @@ th { margin-right: 10px; } } + + .btn-audit { + margin-left: .5em; + } } .scriptmessage { From b0859f91b2e6b62231d88b5fa9086cd939841f38 Mon Sep 17 00:00:00 2001 From: hunicus <93150691+hunicus@users.noreply.github.com> Date: Wed, 19 Apr 2023 22:00:09 -0400 Subject: [PATCH 016/422] Update unchained icon on about page --- .../app/components/about/about.component.html | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/frontend/src/app/components/about/about.component.html b/frontend/src/app/components/about/about.component.html index 8a0e13335..8a139e5a9 100644 --- a/frontend/src/app/components/about/about.component.html +++ b/frontend/src/app/components/about/about.component.html @@ -107,22 +107,7 @@ Blockstream - - - - - - - - - - - + Unchained From 008ec104b60fca5db7444ef2dcb81a77e6296dc5 Mon Sep 17 00:00:00 2001 From: hunicus <93150691+hunicus@users.noreply.github.com> Date: Wed, 19 Apr 2023 22:52:53 -0400 Subject: [PATCH 017/422] Fix clashing class in unchained svg --- frontend/src/app/components/about/about.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/components/about/about.component.html b/frontend/src/app/components/about/about.component.html index 8a139e5a9..9c52d754d 100644 --- a/frontend/src/app/components/about/about.component.html +++ b/frontend/src/app/components/about/about.component.html @@ -107,7 +107,7 @@ Blockstream - + Unchained From 7b9fd8ac635e8d7d1acea436a8c62c75cb0000b2 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 26 Apr 2023 04:55:42 +0900 Subject: [PATCH 018/422] prevent table overflow in unfurl previews --- .../components/address/address-preview.component.html | 2 +- .../components/address/address-preview.component.scss | 5 +++++ .../app/components/block/block-preview.component.html | 2 +- .../app/components/block/block-preview.component.scss | 5 +++++ .../app/lightning/channel/channel-preview.component.html | 2 +- .../app/lightning/channel/channel-preview.component.scss | 5 +++++ .../src/app/lightning/node/node-preview.component.scss | 9 +++++---- 7 files changed, 23 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/components/address/address-preview.component.html b/frontend/src/app/components/address/address-preview.component.html index 1924d1a4c..392cc971e 100644 --- a/frontend/src/app/components/address/address-preview.component.html +++ b/frontend/src/app/components/address/address-preview.component.html @@ -3,7 +3,7 @@ Address
-
+

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

Replacements

+
+
+ +
+
+

Flow

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

RBF Replacements

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

there are no replacements in the mempool yet!

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

Transaction

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

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

- -
- +
+
-
+

there are no replacements in the mempool yet!

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

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

+
+

fiat price

+

+ +

+
+
+

priority rate

+

{{ recommendedFees.fastestFee }} sat/vB

+
+
+

+

block size

+
+
+

{{ block.tx_count | number }}

+

transactions

+
+ +
+

+

memory usage

+
+
+

{{ mempoolInfo.size | number }}

+

unconfirmed

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

fiat price

+

fiat price

-

priority rate

-

{{ recommendedFees.fastestFee }} sat/vB

+

priority rate

+

{{ recommendedFees.fastestFee }} sat/vB

-

block size

+

block size

-

{{ block.tx_count | number }}

-

transactions

+

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

-

memory usage

+

memory usage

{{ mempoolInfo.size | number }}

-

unconfirmed

+

unconfirmed

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

fiat price

-

- -

-
-
-

priority rate

-

{{ recommendedFees.fastestFee }} sat/vB

-
-
-

-

block size

-
-
-

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

-
- -
-

-

memory usage

+ +
+

fiat price

+

+ +

-
-

{{ mempoolInfo.size | number }}

-

unconfirmed

+
+

priority rate

+

{{ recommendedFees.fastestFee }} sat/vB

+
+

+

block size

+
+
+

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

+
+ +
+

+

memory usage

+
+
+

{{ mempoolInfo.size | number }}

+

unconfirmed

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

priority rate

{{ recommendedFees.fastestFee }} sat/vB

-
+

block size

-
+

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

-

fiat price

+

fiat price

-

priority rate

-

{{ recommendedFees.fastestFee }} sat/vB

+

priority rate

+

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

-

block size

+

block size

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

-

memory usage

+

memory usage

{{ mempoolInfo.size | number }}

-

unconfirmed

+

unconfirmed

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

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

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