diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 2d77969a1..ea8154206 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -18,6 +18,7 @@ import blocks from '../blocks'; import bitcoinClient from './bitcoin-client'; import difficultyAdjustment from '../difficulty-adjustment'; import transactionRepository from '../../repositories/TransactionRepository'; +import rbfCache from '../rbf-cache'; class BitcoinRoutes { public initRoutes(app: Application) { @@ -31,6 +32,8 @@ 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/cached', this.getCachedTx) .post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', this.$postTransactionForm) .get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => { try { @@ -589,6 +592,28 @@ class BitcoinRoutes { } } + private async getRbfHistory(req: Request, res: Response) { + try { + const result = rbfCache.getReplaces(req.params.txId); + 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); + if (result) { + res.json(result); + } else { + res.status(404).send('not found'); + } + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + private async getTransactionOutspends(req: Request, res: Response) { try { const result = await bitcoinApi.$getOutspends(req.params.txId); diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 29dad1a5d..954c1a17d 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -60,8 +60,6 @@ export class Common { static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended } { const matches: { [txid: string]: TransactionExtended } = {}; deleted - // The replaced tx must have at least one input with nSequence < maxint-1 (That’s the opt-in) - .filter((tx) => tx.vin.some((vin) => vin.sequence < 0xfffffffe)) .forEach((deletedTx) => { const foundMatches = added.find((addedTx) => { // The new tx must, absolutely speaking, pay at least as much fee as the replaced tx. @@ -70,7 +68,7 @@ export class Common { && addedTx.feePerVsize > deletedTx.feePerVsize // Spends one or more of the same inputs && deletedTx.vin.some((deletedVin) => - addedTx.vin.some((vin) => vin.txid === deletedVin.txid)); + addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout)); }); if (foundMatches) { matches[deletedTx.txid] = foundMatches; diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 717f4eebb..4c475502c 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -210,7 +210,7 @@ class Mempool { for (const rbfTransaction in rbfTransactions) { if (this.mempoolCache[rbfTransaction]) { // Store replaced transactions - rbfCache.add(rbfTransaction, rbfTransactions[rbfTransaction].txid); + rbfCache.add(this.mempoolCache[rbfTransaction], rbfTransactions[rbfTransaction].txid); // Erase the replaced transactions from the local mempool delete this.mempoolCache[rbfTransaction]; } @@ -236,6 +236,7 @@ class Mempool { const lazyDeleteAt = this.mempoolCache[tx].deleteAfter; if (lazyDeleteAt && lazyDeleteAt < now) { delete this.mempoolCache[tx]; + rbfCache.evict(tx); } } } diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index 3162ad263..410239e73 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -1,31 +1,62 @@ -export interface CachedRbf { - txid: string; - expires: Date; -} +import { TransactionExtended } from "../mempool.interfaces"; class RbfCache { - private cache: { [txid: string]: CachedRbf; } = {}; + private replacedBy: { [txid: string]: string; } = {}; + private replaces: { [txid: string]: string[] } = {}; + private txs: { [txid: string]: TransactionExtended } = {}; + private expiring: { [txid: string]: Date } = {}; 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 add(replacedTx: TransactionExtended, newTxId: string): void { + this.replacedBy[replacedTx.txid] = newTxId; + this.txs[replacedTx.txid] = replacedTx; + if (!this.replaces[newTxId]) { + this.replaces[newTxId] = []; + } + this.replaces[newTxId].push(replacedTx.txid); } - public get(txId: string): CachedRbf | undefined { - return this.cache[txId]; + public getReplacedBy(txId: string): string | undefined { + return this.replacedBy[txId]; + } + + public getReplaces(txId: string): string[] | undefined { + return this.replaces[txId]; + } + + public getTx(txId: string): TransactionExtended | undefined { + return this.txs[txId]; + } + + // flag a transaction as removed from the mempool + public evict(txid): void { + this.expiring[txid] = new Date(Date.now() + 1000 * 86400); // 24 hours } private cleanup(): void { const currentDate = new Date(); - for (const c in this.cache) { - if (this.cache[c].expires < currentDate) { - delete this.cache[c]; + for (const txid in this.expiring) { + if (this.expiring[txid] < currentDate) { + delete this.expiring[txid]; + this.remove(txid); + } + } + } + + // 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]) { + const replaces = this.replaces[txid]; + delete this.replaces[txid]; + for (const tx of replaces) { + // recursively remove prior versions from the cache + delete this.replacedBy[tx]; + delete this.txs[tx]; + this.remove(tx); } } } diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index b6f32aa05..3ca49293d 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -58,10 +58,10 @@ class WebsocketHandler { client['track-tx'] = parsedMessage['track-tx']; // Client is telling the transaction wasn't found if (parsedMessage['watch-mempool']) { - const rbfCacheTx = rbfCache.get(client['track-tx']); - if (rbfCacheTx) { + const rbfCacheTxid = rbfCache.getReplacedBy(client['track-tx']); + if (rbfCacheTxid) { response['txReplaced'] = { - txid: rbfCacheTx.txid, + txid: rbfCacheTxid, }; client['track-tx'] = null; } else { @@ -467,6 +467,7 @@ class WebsocketHandler { for (const txId of txIds) { delete _memPool[txId]; removed.push(txId); + rbfCache.evict(txId); } if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) { diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index b1e9afaf2..dd871a886 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -6,7 +6,14 @@ - + + +

Transaction

@@ -25,7 +32,10 @@ {{ i }} confirmations - + + + + @@ -88,7 +98,7 @@
- + @@ -100,7 +110,7 @@ - +
ETA diff --git a/frontend/src/app/components/transaction/transaction.component.scss b/frontend/src/app/components/transaction/transaction.component.scss index 2208909ef..47c8baa4a 100644 --- a/frontend/src/app/components/transaction/transaction.component.scss +++ b/frontend/src/app/components/transaction/transaction.component.scss @@ -204,4 +204,10 @@ .txids { width: 60%; } +} + +.tx-list { + .alert-link { + display: block; + } } \ No newline at end of file diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index b32be6a8e..7e1ae525e 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -40,15 +40,21 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { transactionTime = -1; subscription: Subscription; fetchCpfpSubscription: Subscription; + fetchRbfSubscription: Subscription; + fetchCachedTxSubscription: Subscription; txReplacedSubscription: Subscription; blocksSubscription: Subscription; queryParamsSubscription: Subscription; urlFragmentSubscription: Subscription; fragmentParams: URLSearchParams; rbfTransaction: undefined | Transaction; + replaced: boolean = false; + rbfReplaces: string[]; cpfpInfo: CpfpInfo | null; showCpfpDetails = false; fetchCpfp$ = new Subject(); + fetchRbfHistory$ = new Subject(); + fetchCachedTx$ = new Subject(); now = new Date().getTime(); timeAvg$: Observable; liquidUnblinding = new LiquidUnblinding(); @@ -159,6 +165,49 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.cpfpInfo = cpfpInfo; }); + this.fetchRbfSubscription = this.fetchRbfHistory$ + .pipe( + switchMap((txId) => + this.apiService + .getRbfHistory$(txId) + ), + catchError(() => { + return of([]); + }) + ).subscribe((replaces) => { + this.rbfReplaces = replaces; + }); + + this.fetchCachedTxSubscription = this.fetchCachedTx$ + .pipe( + switchMap((txId) => + this.apiService + .getRbfCachedTx$(txId) + ), + catchError(() => { + return of(null); + }) + ).subscribe((tx) => { + if (!tx) { + return; + } + + this.tx = tx; + 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); + } + }); + this.subscription = this.route.paramMap .pipe( switchMap((params: ParamMap) => { @@ -272,6 +321,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } else { this.fetchCpfp$.next(this.tx.txid); } + this.fetchRbfHistory$.next(this.tx.txid); } setTimeout(() => { this.applyFragment(); }, 0); }, @@ -303,6 +353,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } this.rbfTransaction = rbfTransaction; this.cacheService.setTxCache([this.rbfTransaction]); + this.replaced = true; + if (rbfTransaction && !this.tx) { + this.fetchCachedTx$.next(this.txId); + } }); this.queryParamsSubscription = this.route.queryParams.subscribe((params) => { @@ -368,8 +422,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.waitingForTransaction = false; this.isLoadingTx = true; this.rbfTransaction = undefined; + this.replaced = false; this.transactionTime = -1; this.cpfpInfo = null; + this.rbfReplaces = []; this.showCpfpDetails = false; document.body.scrollTo(0, 0); this.leaveTransaction(); @@ -435,6 +491,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { ngOnDestroy() { this.subscription.unsubscribe(); this.fetchCpfpSubscription.unsubscribe(); + this.fetchRbfSubscription.unsubscribe(); + this.fetchCachedTxSubscription.unsubscribe(); this.txReplacedSubscription.unsubscribe(); this.blocksSubscription.unsubscribe(); this.queryParamsSubscription.unsubscribe(); diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index f813959e3..6eff41f61 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -5,7 +5,7 @@ import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITrans import { Observable } from 'rxjs'; import { StateService } from './state.service'; import { WebsocketResponse } from '../interfaces/websocket.interface'; -import { Outspend } from '../interfaces/electrs.interface'; +import { Outspend, Transaction } from '../interfaces/electrs.interface'; @Injectable({ providedIn: 'root' @@ -119,6 +119,14 @@ 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'); + } + + getRbfCachedTx$(txid: string): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/cached'); + } + listLiquidPegsMonth$(): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs/month'); }