diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index 681271450..5b85beeaf 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -252,9 +252,17 @@ class MempoolBlocks { private processBlockTemplates(mempool, blocks, clusters, saveResults): MempoolBlockWithTransactions[] { // update this thread's mempool with the results - blocks.forEach(block => { + blocks.forEach((block, blockIndex) => { + let runningVsize = 0; block.forEach(tx => { if (tx.txid && tx.txid in mempool) { + // save position in projected blocks + mempool[tx.txid].position = { + block: blockIndex, + vsize: runningVsize + (mempool[tx.txid].vsize / 2), + }; + runningVsize += mempool[tx.txid].vsize; + if (tx.effectiveFeePerVsize != null) { mempool[tx.txid].effectiveFeePerVsize = tx.effectiveFeePerVsize; } diff --git a/backend/src/api/tx-selection-worker.ts b/backend/src/api/tx-selection-worker.ts index 93060cd67..c035099a3 100644 --- a/backend/src/api/tx-selection-worker.ts +++ b/backend/src/api/tx-selection-worker.ts @@ -2,7 +2,6 @@ import config from '../config'; import logger from '../logger'; import { ThreadTransaction, MempoolBlockWithTransactions, AuditTransaction } from '../mempool.interfaces'; import { PairingHeap } from '../utils/pairing-heap'; -import { Common } from './common'; import { parentPort } from 'worker_threads'; let mempool: { [txid: string]: ThreadTransaction } = {}; @@ -72,7 +71,14 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction }) } // Sort by descending ancestor score - mempoolArray.sort((a, b) => (b.score || 0) - (a.score || 0)); + mempoolArray.sort((a, b) => { + if (b.score === a.score) { + // tie-break by lexicographic txid order for stability + return a.txid < b.txid ? -1 : 1; + } else { + return (b.score || 0) - (a.score || 0); + } + }); // Build blocks by greedily choosing the highest feerate package // (i.e. the package rooted in the transaction with the best ancestor score) @@ -80,7 +86,14 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction }) let blockWeight = 4000; let blockSize = 0; let transactions: AuditTransaction[] = []; - const modified: PairingHeap = new PairingHeap((a, b): boolean => (a.score || 0) > (b.score || 0)); + const modified: PairingHeap = new PairingHeap((a, b): boolean => { + if (a.score === b.score) { + // tie-break by lexicographic txid order for stability + return a.txid > b.txid; + } else { + return (a.score || 0) > (b.score || 0); + } + }); let overflow: AuditTransaction[] = []; let failures = 0; let top = 0; diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 3f5eb1e02..f28f284c7 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -66,9 +66,10 @@ class WebsocketHandler { if (parsedMessage && parsedMessage['track-tx']) { if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-tx'])) { client['track-tx'] = parsedMessage['track-tx']; + const trackTxid = client['track-tx']; // Client is telling the transaction wasn't found if (parsedMessage['watch-mempool']) { - const rbfCacheTxid = rbfCache.getReplacedBy(client['track-tx']); + const rbfCacheTxid = rbfCache.getReplacedBy(trackTxid); if (rbfCacheTxid) { response['txReplaced'] = { txid: rbfCacheTxid, @@ -76,7 +77,7 @@ class WebsocketHandler { client['track-tx'] = null; } else { // It might have appeared before we had the time to start watching for it - const tx = memPool.getMempool()[client['track-tx']]; + const tx = memPool.getMempool()[trackTxid]; if (tx) { if (config.MEMPOOL.BACKEND === 'esplora') { response['tx'] = tx; @@ -100,6 +101,13 @@ class WebsocketHandler { } } } + const tx = memPool.getMempool()[trackTxid]; + if (tx && tx.position) { + response['txPosition'] = { + txid: trackTxid, + position: tx.position, + }; + } } else { client['track-tx'] = null; } @@ -401,9 +409,10 @@ class WebsocketHandler { } if (client['track-tx']) { + const trackTxid = client['track-tx']; const outspends: object = {}; newTransactions.forEach((tx) => tx.vin.forEach((vin, i) => { - if (vin.txid === client['track-tx']) { + if (vin.txid === trackTxid) { outspends[vin.vout] = { vin: i, txid: tx.txid, @@ -426,6 +435,14 @@ class WebsocketHandler { if (rbfChange) { response['rbfInfo'] = rbfChanges.trees[rbfChange]; } + + const mempoolTx = newMempool[trackTxid]; + if (mempoolTx && mempoolTx.position) { + response['txPosition'] = { + txid: trackTxid, + position: mempoolTx.position, + }; + } } if (client['track-mempool-block'] >= 0) { @@ -556,8 +573,19 @@ class WebsocketHandler { response['mempool-blocks'] = mBlocks; } - if (client['track-tx'] && txIds.indexOf(client['track-tx']) > -1) { - response['txConfirmed'] = true; + if (client['track-tx']) { + const trackTxid = client['track-tx']; + if (txIds.indexOf(trackTxid) > -1) { + response['txConfirmed'] = true; + } else { + const mempoolTx = _memPool[trackTxid]; + if (mempoolTx && mempoolTx.position) { + response['txPosition'] = { + txid: trackTxid, + position: mempoolTx.position, + }; + } + } } if (client['track-address']) { diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 16b856bcc..7b7926a09 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -80,6 +80,10 @@ export interface TransactionExtended extends IEsploraApi.Transaction { bestDescendant?: BestDescendant | null; cpfpChecked?: boolean; deleteAfter?: number; + position?: { + block: number, + vsize: number, + }; } export interface AuditTransaction { 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..0ef7de49e 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -8,7 +8,7 @@ import { feeLevels, mempoolFeeColors } from '../../app.constants'; import { specialBlocks } from '../../app.constants'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { Location } from '@angular/common'; -import { DifficultyAdjustment } from '../../interfaces/node-api.interface'; +import { DifficultyAdjustment, MempoolPosition } from '../../interfaces/node-api.interface'; import { animate, style, transition, trigger } from '@angular/animations'; @Component({ @@ -58,6 +58,7 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy { transition = 'background 2s, right 2s, transform 1s'; markIndex: number; + txPosition: MempoolPosition; txFeePerVSize: number; resetTransitionTimeout: number; @@ -156,6 +157,9 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy { if (state.mempoolBlockIndex !== undefined) { this.markIndex = state.mempoolBlockIndex; } + if (state.mempoolPosition) { + this.txPosition = state.mempoolPosition; + } if (state.txFeePerVSize) { this.txFeePerVSize = state.txFeePerVSize; } @@ -302,7 +306,7 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy { } calculateTransactionPosition() { - if ((!this.txFeePerVSize && (this.markIndex === undefined || this.markIndex === -1)) || !this.mempoolBlocks) { + if ((!this.txPosition && !this.txFeePerVSize && (this.markIndex === undefined || this.markIndex === -1)) || !this.mempoolBlocks) { this.arrowVisible = false; return; } else if (this.markIndex > -1) { @@ -320,33 +324,43 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy { this.arrowVisible = true; - let found = false; - for (let txInBlockIndex = 0; txInBlockIndex < this.mempoolBlocks.length && !found; txInBlockIndex++) { - const block = this.mempoolBlocks[txInBlockIndex]; - for (let i = 0; i < block.feeRange.length - 1 && !found; i++) { - if (this.txFeePerVSize < block.feeRange[i + 1] && this.txFeePerVSize >= block.feeRange[i]) { - const feeRangeIndex = i; - const feeRangeChunkSize = 1 / (block.feeRange.length - 1); + if (this.txPosition) { + if (this.txPosition.block >= this.mempoolBlocks.length) { + this.rightPosition = ((this.mempoolBlocks.length - 1) * (this.blockWidth + this.blockPadding)) + this.blockWidth; + } else { + const positionInBlock = Math.min(1, this.txPosition.vsize / this.stateService.blockVSize) * this.blockWidth; + const positionOfBlock = this.txPosition.block * (this.blockWidth + this.blockPadding); + this.rightPosition = positionOfBlock + positionInBlock; + } + } else { + let found = false; + for (let txInBlockIndex = 0; txInBlockIndex < this.mempoolBlocks.length && !found; txInBlockIndex++) { + const block = this.mempoolBlocks[txInBlockIndex]; + for (let i = 0; i < block.feeRange.length - 1 && !found; i++) { + if (this.txFeePerVSize < block.feeRange[i + 1] && this.txFeePerVSize >= block.feeRange[i]) { + const feeRangeIndex = i; + const feeRangeChunkSize = 1 / (block.feeRange.length - 1); - const txFee = this.txFeePerVSize - block.feeRange[i]; - const max = block.feeRange[i + 1] - block.feeRange[i]; - const blockLocation = txFee / max; + const txFee = this.txFeePerVSize - block.feeRange[i]; + const max = block.feeRange[i + 1] - block.feeRange[i]; + const blockLocation = txFee / max; - const chunkPositionOffset = blockLocation * feeRangeChunkSize; - const feePosition = feeRangeChunkSize * feeRangeIndex + chunkPositionOffset; + const chunkPositionOffset = blockLocation * feeRangeChunkSize; + const feePosition = feeRangeChunkSize * feeRangeIndex + chunkPositionOffset; - const blockedFilledPercentage = (block.blockVSize > this.stateService.blockVSize ? this.stateService.blockVSize : block.blockVSize) / this.stateService.blockVSize; - const arrowRightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding) - + ((1 - feePosition) * blockedFilledPercentage * this.blockWidth); + const blockedFilledPercentage = (block.blockVSize > this.stateService.blockVSize ? this.stateService.blockVSize : block.blockVSize) / this.stateService.blockVSize; + const arrowRightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding) + + ((1 - feePosition) * blockedFilledPercentage * this.blockWidth); - this.rightPosition = arrowRightPosition; + this.rightPosition = arrowRightPosition; + found = true; + } + } + if (this.txFeePerVSize >= block.feeRange[block.feeRange.length - 1]) { + this.rightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding); found = true; } } - if (this.txFeePerVSize >= block.feeRange[block.feeRange.length - 1]) { - this.rightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding); - found = true; - } } } diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index f8c08916c..11c3b8063 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, RbfTree } from '../../interfaces/node-api.interface'; +import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition } 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'; @@ -35,6 +35,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { tx: Transaction; txId: string; txInBlockIndex: number; + mempoolPosition: MempoolPosition; isLoadingTx = true; error: any = undefined; errorUnblinded: any = undefined; @@ -47,6 +48,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { fetchCachedTxSubscription: Subscription; txReplacedSubscription: Subscription; txRbfInfoSubscription: Subscription; + mempoolPositionSubscription: Subscription; blocksSubscription: Subscription; queryParamsSubscription: Subscription; urlFragmentSubscription: Subscription; @@ -174,6 +176,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { if (!this.tx?.status?.confirmed) { this.stateService.markBlock$.next({ txFeePerVSize: this.tx.effectiveFeePerVsize, + mempoolPosition: this.mempoolPosition, }); } this.cpfpInfo = cpfpInfo; @@ -231,6 +234,19 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } }); + this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => { + if (txPosition && txPosition.txid === this.txId && txPosition.position) { + this.mempoolPosition = txPosition.position; + if (this.tx && !this.tx.status.confirmed) { + this.stateService.markBlock$.next({ + mempoolPosition: this.mempoolPosition + }); + } + } else { + this.mempoolPosition = null; + } + }); + this.subscription = this.route.paramMap .pipe( switchMap((params: ParamMap) => { @@ -342,6 +358,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { if (tx.cpfpChecked) { this.stateService.markBlock$.next({ txFeePerVSize: tx.effectiveFeePerVsize, + mempoolPosition: this.mempoolPosition, }); this.cpfpInfo = { ancestors: tx.ancestors, @@ -569,6 +586,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.flowPrefSubscription.unsubscribe(); this.urlFragmentSubscription.unsubscribe(); this.mempoolBlocksSubscription.unsubscribe(); + this.mempoolPositionSubscription.unsubscribe(); this.leaveTransaction(); } } diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index bef338a73..8d5d71da7 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -162,6 +162,10 @@ interface RbfTransaction extends TransactionStripped { rbf?: boolean; mined?: boolean, } +export interface MempoolPosition { + block: number, + vsize: number, +} export interface RewardStats { startBlock: number; diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 814f8e9db..1997a1d53 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, RbfTree } from '../interfaces/node-api.interface'; +import { BlockExtended, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface'; import { Router, NavigationStart } from '@angular/router'; import { isPlatformBrowser } from '@angular/common'; import { map, shareReplay } from 'rxjs/operators'; @@ -12,6 +12,7 @@ interface MarkBlockState { blockHeight?: number; mempoolBlockIndex?: number; txFeePerVSize?: number; + mempoolPosition?: MempoolPosition; } export interface ILoadingIndicators { [name: string]: number; } @@ -105,6 +106,7 @@ export class StateService { utxoSpent$ = new Subject(); difficultyAdjustment$ = new ReplaySubject(1); mempoolTransactions$ = new Subject(); + mempoolTxPosition$ = new Subject<{ txid: string, position: MempoolPosition}>(); blockTransactions$ = new Subject(); isLoadingWebSocket$ = new ReplaySubject(1); vbytesPerSecond$ = new ReplaySubject(1); diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index 9e473d24c..ec7d14947 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -249,6 +249,10 @@ export class WebsocketService { this.stateService.mempoolTransactions$.next(response.tx); } + if (response['txPosition']) { + this.stateService.mempoolTxPosition$.next(response['txPosition']); + } + if (response.block) { if (response.block.height > this.stateService.latestBlockHeight) { this.stateService.updateChainTip(response.block.height);