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 +
+
+ + +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+ + +
+
+