From 0481f5730471608d16315752a82fa8d0673fd846 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 9 Dec 2022 10:32:58 -0600 Subject: [PATCH 1/4] cache, serve & display more comprehensive RBF info --- backend/src/api/bitcoin/bitcoin.routes.ts | 25 ++++++++ backend/src/api/common.ts | 2 - backend/src/api/mempool.ts | 2 +- backend/src/api/rbf-cache.ts | 51 +++++++++++++--- backend/src/api/websocket-handler.ts | 2 +- .../transaction/transaction.component.html | 21 +++++-- .../transaction/transaction.component.scss | 6 ++ .../transaction/transaction.component.ts | 58 +++++++++++++++++++ frontend/src/app/services/api.service.ts | 10 +++- 9 files changed, 159 insertions(+), 18 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 2d77969a1..e359fa9a4 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?.txids || []); + } 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 f0c5c6b88..18961d188 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -51,8 +51,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. diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 717f4eebb..80dc846d7 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]; } diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index 3162ad263..a81362655 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -1,31 +1,64 @@ +import { TransactionExtended } from "../mempool.interfaces"; + export interface CachedRbf { txid: string; expires: Date; } +export interface CachedRbfs { + txids: string[]; + expires: Date; +} + class RbfCache { - private cache: { [txid: string]: CachedRbf; } = {}; + private replacedby: { [txid: string]: CachedRbf; } = {}; + private replaces: { [txid: string]: CachedRbfs } = {}; + private txs: { [txid: string]: TransactionExtended } = {}; 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 + public add(replacedTx: TransactionExtended, newTxId: string): void { + const expiry = new Date(Date.now() + 1000 * 604800); // 1 week + this.replacedby[replacedTx.txid] = { + expires: expiry, txid: newTxId, }; + this.txs[replacedTx.txid] = replacedTx; + if (!this.replaces[newTxId]) { + this.replaces[newTxId] = { + txids: [], + expires: expiry, + }; + } + this.replaces[newTxId].txids.push(replacedTx.txid); + this.replaces[newTxId].expires = expiry; } - public get(txId: string): CachedRbf | undefined { - return this.cache[txId]; + public getReplacedBy(txId: string): CachedRbf | undefined { + return this.replacedby[txId]; + } + + public getReplaces(txId: string): CachedRbfs | undefined { + return this.replaces[txId]; + } + + public getTx(txId: string): TransactionExtended | undefined { + return this.txs[txId]; } 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 c in this.replacedby) { + if (this.replacedby[c].expires < currentDate) { + delete this.replacedby[c]; + delete this.txs[c]; + } + } + for (const c in this.replaces) { + if (this.replaces[c].expires < currentDate) { + delete this.replaces[c]; } } } diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index b6f32aa05..0007205be 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -58,7 +58,7 @@ 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']); + const rbfCacheTx = rbfCache.getReplacedBy(client['track-tx']); if (rbfCacheTx) { response['txReplaced'] = { txid: rbfCacheTx.txid, diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index b1e9afaf2..b85f1cd24 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -6,7 +6,17 @@ - + + +

Transaction

@@ -25,7 +35,10 @@ {{ i }} confirmations - + + + + @@ -88,7 +101,7 @@
- + @@ -100,7 +113,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'); } From d77853062044de76026162c76e5f2937a481c819 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 9 Dec 2022 14:35:51 -0600 Subject: [PATCH 2/4] keep cached RBF info for 24 hours after tx leaves the mempool --- backend/src/api/bitcoin/bitcoin.routes.ts | 2 +- backend/src/api/mempool.ts | 1 + backend/src/api/rbf-cache.ts | 64 +++++++++++------------ backend/src/api/websocket-handler.ts | 7 +-- 4 files changed, 37 insertions(+), 37 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index e359fa9a4..ea8154206 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -595,7 +595,7 @@ class BitcoinRoutes { private async getRbfHistory(req: Request, res: Response) { try { const result = rbfCache.getReplaces(req.params.txId); - res.json(result?.txids || []); + res.json(result || []); } 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 80dc846d7..4c475502c 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -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 a81362655..410239e73 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -1,46 +1,29 @@ import { TransactionExtended } from "../mempool.interfaces"; -export interface CachedRbf { - txid: string; - expires: Date; -} - -export interface CachedRbfs { - txids: string[]; - expires: Date; -} - class RbfCache { - private replacedby: { [txid: string]: CachedRbf; } = {}; - private replaces: { [txid: string]: CachedRbfs } = {}; + 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(replacedTx: TransactionExtended, newTxId: string): void { - const expiry = new Date(Date.now() + 1000 * 604800); // 1 week - this.replacedby[replacedTx.txid] = { - expires: expiry, - txid: newTxId, - }; + this.replacedBy[replacedTx.txid] = newTxId; this.txs[replacedTx.txid] = replacedTx; if (!this.replaces[newTxId]) { - this.replaces[newTxId] = { - txids: [], - expires: expiry, - }; + this.replaces[newTxId] = []; } - this.replaces[newTxId].txids.push(replacedTx.txid); - this.replaces[newTxId].expires = expiry; + this.replaces[newTxId].push(replacedTx.txid); } - public getReplacedBy(txId: string): CachedRbf | undefined { - return this.replacedby[txId]; + public getReplacedBy(txId: string): string | undefined { + return this.replacedBy[txId]; } - public getReplaces(txId: string): CachedRbfs | undefined { + public getReplaces(txId: string): string[] | undefined { return this.replaces[txId]; } @@ -48,17 +31,32 @@ class RbfCache { 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.replacedby) { - if (this.replacedby[c].expires < currentDate) { - delete this.replacedby[c]; - delete this.txs[c]; + for (const txid in this.expiring) { + if (this.expiring[txid] < currentDate) { + delete this.expiring[txid]; + this.remove(txid); } } - for (const c in this.replaces) { - if (this.replaces[c].expires < currentDate) { - delete this.replaces[c]; + } + + // 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 0007205be..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.getReplacedBy(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) { From 7da308c1e170841e69573ab8d9def877b6738a03 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 14 Dec 2022 08:56:46 -0600 Subject: [PATCH 3/4] fix RBF detection --- backend/src/api/common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 18961d188..9b647c5b4 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -59,7 +59,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; From e4fcac93f208ab1b00b1397df3a313ed01e28b89 Mon Sep 17 00:00:00 2001 From: softsimon Date: Wed, 25 Jan 2023 17:24:00 +0400 Subject: [PATCH 4/4] Using truncate component for replaced tx link --- .../app/components/transaction/transaction.component.html | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index b85f1cd24..dd871a886 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -9,10 +9,7 @@