diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 64505ba2b..43aea6059 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -8,6 +8,7 @@ import { IBitcoinApi } from './bitcoin/bitcoin-api.interface'; import loadingIndicators from './loading-indicators'; import bitcoinClient from './bitcoin/bitcoin-client'; import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; +import rbfCache from './rbf-cache'; class Mempool { private static WEBSOCKET_REFRESH_RATE_MS = 10000; @@ -200,6 +201,17 @@ class Mempool { logger.debug('Mempool updated in ' + time / 1000 + ' seconds'); } + public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) { + for (const rbfTransaction in rbfTransactions) { + if (this.mempoolCache[rbfTransaction]) { + // Store replaced transactions + rbfCache.add(rbfTransaction, rbfTransactions[rbfTransaction].txid); + // Erase the replaced transactions from the local mempool + delete this.mempoolCache[rbfTransaction]; + } + } + } + private updateTxPerSecond() { const nowMinusTimeSpan = new Date().getTime() - (1000 * config.STATISTICS.TX_PER_SECOND_SAMPLE_PERIOD); this.txPerSecondArray = this.txPerSecondArray.filter((unixTime) => unixTime > nowMinusTimeSpan); diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts new file mode 100644 index 000000000..3162ad263 --- /dev/null +++ b/backend/src/api/rbf-cache.ts @@ -0,0 +1,34 @@ +export interface CachedRbf { + txid: string; + expires: Date; +} + +class RbfCache { + private cache: { [txid: string]: CachedRbf; } = {}; + + constructor() { + setInterval(this.cleanup.bind(this), 1000 * 60 * 60); + } + + public add(replacedTxId: string, newTxId: string): void { + this.cache[replacedTxId] = { + expires: new Date(Date.now() + 1000 * 604800), // 1 week + txid: newTxId, + }; + } + + public get(txId: string): CachedRbf | undefined { + return this.cache[txId]; + } + + private cleanup(): void { + const currentDate = new Date(); + for (const c in this.cache) { + if (this.cache[c].expires < currentDate) { + delete this.cache[c]; + } + } + } +} + +export default new RbfCache(); diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 92abaab6d..fe43a725f 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -11,6 +11,7 @@ import { Common } from './common'; import loadingIndicators from './loading-indicators'; import config from '../config'; import transactionUtils from './transaction-utils'; +import rbfCache from './rbf-cache'; class WebsocketHandler { private wss: WebSocket.Server | undefined; @@ -48,29 +49,38 @@ class WebsocketHandler { if (parsedMessage && parsedMessage['track-tx']) { if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-tx'])) { client['track-tx'] = parsedMessage['track-tx']; - // Client is telling the transaction wasn't found but it might have appeared before we had the time to start watching for it + // Client is telling the transaction wasn't found if (parsedMessage['watch-mempool']) { - const tx = memPool.getMempool()[client['track-tx']]; - if (tx) { - if (config.MEMPOOL.BACKEND === 'esplora') { - response['tx'] = tx; + const rbfCacheTx = rbfCache.get(client['track-tx']); + if (rbfCacheTx) { + response['txReplaced'] = { + txid: rbfCacheTx.txid, + }; + 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']]; + if (tx) { + if (config.MEMPOOL.BACKEND === 'esplora') { + response['tx'] = tx; + } else { + // tx.prevout is missing from transactions when in bitcoind mode + try { + const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true); + response['tx'] = fullTx; + } catch (e) { + logger.debug('Error finding transaction: ' + (e instanceof Error ? e.message : e)); + } + } } else { - // tx.prevouts is missing from transactions when in bitcoind mode try { - const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true); + const fullTx = await transactionUtils.$getTransactionExtended(client['track-tx'], true); response['tx'] = fullTx; } catch (e) { - logger.debug('Error finding transaction: ' + (e instanceof Error ? e.message : e)); + logger.debug('Error finding transaction. ' + (e instanceof Error ? e.message : e)); + client['track-mempool-tx'] = parsedMessage['track-tx']; } } - } else { - try { - const fullTx = await transactionUtils.$getTransactionExtended(client['track-tx'], true); - response['tx'] = fullTx; - } catch (e) { - logger.debug('Error finding transaction. ' + (e instanceof Error ? e.message : e)); - client['track-mempool-tx'] = parsedMessage['track-tx']; - } } } } else { @@ -221,14 +231,10 @@ class WebsocketHandler { mempoolBlocks.updateMempoolBlocks(newMempool); const mBlocks = mempoolBlocks.getMempoolBlocks(); - const mempool = memPool.getMempool(); const mempoolInfo = memPool.getMempoolInfo(); const vBytesPerSecond = memPool.getVBytesPerSecond(); const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions); - - for (const rbfTransaction in rbfTransactions) { - delete mempool[rbfTransaction]; - } + memPool.handleRbfTransactions(rbfTransactions); this.wss.clients.forEach(async (client: WebSocket) => { if (client.readyState !== WebSocket.OPEN) { diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 381c59d29..a0c92cbb4 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -3,13 +3,13 @@
- +

Transaction

diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 8b8526336..bc33098db 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -37,6 +37,8 @@ export class TransactionComponent implements OnInit, OnDestroy { transactionTime = -1; subscription: Subscription; fetchCpfpSubscription: Subscription; + txReplacedSubscription: Subscription; + blocksSubscription: Subscription; rbfTransaction: undefined | Transaction; cpfpInfo: CpfpInfo | null; showCpfpDetails = false; @@ -217,7 +219,7 @@ export class TransactionComponent implements OnInit, OnDestroy { } ); - this.stateService.blocks$.subscribe(([block, txConfirmed]) => { + this.blocksSubscription = this.stateService.blocks$.subscribe(([block, txConfirmed]) => { this.latestBlock = block; if (txConfirmed && this.tx) { @@ -232,9 +234,13 @@ export class TransactionComponent implements OnInit, OnDestroy { } }); - this.stateService.txReplaced$.subscribe( - (rbfTransaction) => (this.rbfTransaction = rbfTransaction) - ); + this.txReplacedSubscription = this.stateService.txReplaced$.subscribe((rbfTransaction) => { + if (!rbfTransaction.size) { + this.error = new Error(); + this.waitingForTransaction = false; + } + this.rbfTransaction = rbfTransaction; + }); } handleLoadElectrsTransactionError(error: any): Observable { @@ -302,6 +308,8 @@ export class TransactionComponent implements OnInit, OnDestroy { ngOnDestroy() { this.subscription.unsubscribe(); this.fetchCpfpSubscription.unsubscribe(); + this.txReplacedSubscription.unsubscribe(); + this.blocksSubscription.unsubscribe(); this.leaveTransaction(); } } diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index d82d9a618..b0ae27f73 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -15,7 +15,8 @@ export interface WebsocketResponse { action?: string; data?: string[]; tx?: Transaction; - rbfTransaction?: Transaction; + rbfTransaction?: ReplacedTransaction; + txReplaced?: ReplacedTransaction; utxoSpent?: object; transactions?: TransactionStripped[]; loadingIndicators?: ILoadingIndicators; @@ -27,6 +28,9 @@ export interface WebsocketResponse { 'track-bisq-market'?: string; } +export interface ReplacedTransaction extends Transaction { + txid: string; +} export interface MempoolBlock { blink?: boolean; height?: number; diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 85ce7b567..98a65d34a 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; import { Transaction } from '../interfaces/electrs.interface'; -import { IBackendInfo, MempoolBlock, MempoolInfo, TransactionStripped } from '../interfaces/websocket.interface'; +import { IBackendInfo, MempoolBlock, MempoolInfo, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; import { BlockExtended, OptimizedMempoolStats } from '../interfaces/node-api.interface'; import { Router, NavigationStart } from '@angular/router'; import { isPlatformBrowser } from '@angular/common'; @@ -80,7 +80,7 @@ export class StateService { bsqPrice$ = new ReplaySubject(1); mempoolInfo$ = new ReplaySubject(1); mempoolBlocks$ = new ReplaySubject(1); - txReplaced$ = new Subject(); + txReplaced$ = new Subject(); utxoSpent$ = new Subject(); mempoolTransactions$ = new Subject(); blockTransactions$ = new Subject(); diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index f99524197..ae4c0e381 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -239,6 +239,10 @@ export class WebsocketService { this.stateService.txReplaced$.next(response.rbfTransaction); } + if (response.txReplaced) { + this.stateService.txReplaced$.next(response.txReplaced); + } + if (response['mempool-blocks']) { this.stateService.mempoolBlocks$.next(response['mempool-blocks']); }