support trees of RBF replacements

This commit is contained in:
Mononaut 2022-12-17 09:39:06 -06:00
parent c064ef6ace
commit 086b41d958
No known key found for this signature in database
GPG key ID: A3F058E41374C04E
18 changed files with 413 additions and 219 deletions

View file

@ -644,7 +644,7 @@ class BitcoinRoutes {
private async getRbfHistory(req: Request, res: Response) { private async getRbfHistory(req: Request, res: Response) {
try { try {
const replacements = rbfCache.getRbfChain(req.params.txId) || []; const replacements = rbfCache.getRbfTree(req.params.txId) || null;
const replaces = rbfCache.getReplaces(req.params.txId) || null; const replaces = rbfCache.getReplaces(req.params.txId) || null;
res.json({ res.json({
replacements, replacements,
@ -657,7 +657,7 @@ class BitcoinRoutes {
private async getRbfReplacements(req: Request, res: Response) { private async getRbfReplacements(req: Request, res: Response) {
try { try {
const result = rbfCache.getRbfChains(false); const result = rbfCache.getRbfTrees(false);
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);
@ -666,7 +666,7 @@ class BitcoinRoutes {
private async getFullRbfReplacements(req: Request, res: Response) { private async getFullRbfReplacements(req: Request, res: Response) {
try { try {
const result = rbfCache.getRbfChains(true); const result = rbfCache.getRbfTrees(true);
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);

View file

@ -57,11 +57,11 @@ export class Common {
return arr; return arr;
} }
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended } { static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended[] } {
const matches: { [txid: string]: TransactionExtended } = {}; const matches: { [txid: string]: TransactionExtended[] } = {};
deleted added
.forEach((deletedTx) => { .forEach((addedTx) => {
const foundMatches = added.find((addedTx) => { const foundMatches = deleted.filter((deletedTx) => {
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx. // The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
return addedTx.fee > deletedTx.fee return addedTx.fee > deletedTx.fee
// The new transaction must pay more fee per kB than the replaced tx. // The new transaction must pay more fee per kB than the replaced tx.
@ -70,8 +70,8 @@ export class Common {
&& deletedTx.vin.some((deletedVin) => && deletedTx.vin.some((deletedVin) =>
addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout)); addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout));
}); });
if (foundMatches) { if (foundMatches?.length) {
matches[deletedTx.txid] = foundMatches; matches[addedTx.txid] = foundMatches;
} }
}); });
return matches; return matches;

View file

@ -265,13 +265,15 @@ class Mempool {
} }
} }
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) { public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended[]; }): void {
for (const rbfTransaction in rbfTransactions) { for (const rbfTransaction in rbfTransactions) {
if (this.mempoolCache[rbfTransaction]) { if (this.mempoolCache[rbfTransaction] && rbfTransactions[rbfTransaction]?.length) {
// Store replaced transactions // 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 // Erase the replaced transactions from the local mempool
delete this.mempoolCache[rbfTransaction]; for (const replaced of rbfTransactions[rbfTransaction]) {
delete this.mempoolCache[replaced.txid];
}
} }
} }
} }

View file

@ -1,22 +1,27 @@
import { runInNewContext } from "vm";
import { TransactionExtended, TransactionStripped } from "../mempool.interfaces"; import { TransactionExtended, TransactionStripped } from "../mempool.interfaces";
import { Common } from "./common"; import { Common } from "./common";
interface RbfTransaction extends TransactionStripped { interface RbfTransaction extends TransactionStripped {
rbf?: boolean; rbf?: boolean;
mined?: boolean;
} }
type RbfChain = { interface RbfTree {
tx: RbfTransaction, tx: RbfTransaction;
time: number, time: number;
mined?: boolean, interval?: number;
}[]; mined?: boolean;
fullRbf: boolean;
replaces: RbfTree[];
}
class RbfCache { class RbfCache {
private replacedBy: Map<string, string> = new Map(); private replacedBy: Map<string, string> = new Map();
private replaces: Map<string, string[]> = new Map(); private replaces: Map<string, string[]> = new Map();
private rbfChains: Map<string, RbfChain> = new Map(); // sequences of consecutive replacements private rbfTrees: Map<string, RbfTree> = new Map(); // sequences of consecutive replacements
private dirtyChains: Set<string> = new Set(); private dirtyTrees: Set<string> = new Set();
private chainMap: Map<string, string> = new Map(); // map of txids to sequence ids private treeMap: Map<string, string> = new Map(); // map of txids to sequence ids
private txs: Map<string, TransactionExtended> = new Map(); private txs: Map<string, TransactionExtended> = new Map();
private expiring: Map<string, Date> = new Map(); private expiring: Map<string, Date> = new Map();
@ -24,37 +29,58 @@ class RbfCache {
setInterval(this.cleanup.bind(this), 1000 * 60 * 60); setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
} }
public add(replacedTxExtended: TransactionExtended, newTxExtended: TransactionExtended): void { public add(replaced: TransactionExtended[], newTxExtended: TransactionExtended): void {
const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction; if (!newTxExtended || !replaced?.length) {
replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe); return;
}
const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction; const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction;
const newTime = newTxExtended.firstSeen || Date.now();
newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe); 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); 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 // maintain rbf trees
if (this.chainMap.has(replacedTx.txid)) { let fullRbf = false;
// add to an existing chain const replacedTrees: RbfTree[] = [];
const chainRoot = this.chainMap.get(replacedTx.txid) || ''; for (const replacedTxExtended of replaced) {
this.rbfChains.get(chainRoot)?.push({ tx: newTx, time: newTxExtended.firstSeen || Date.now() }); const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction;
this.chainMap.set(newTx.txid, chainRoot); replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
this.dirtyChains.add(chainRoot); this.replacedBy.set(replacedTx.txid, newTx.txid);
} else { if (this.treeMap.has(replacedTx.txid)) {
// start a new chain const treeId = this.treeMap.get(replacedTx.txid);
this.rbfChains.set(replacedTx.txid, [ if (treeId) {
{ tx: replacedTx, time: replacedTxExtended.firstSeen || Date.now() }, const tree = this.rbfTrees.get(treeId);
{ tx: newTx, time: newTxExtended.firstSeen || Date.now() }, this.rbfTrees.delete(treeId);
]); if (tree) {
this.chainMap.set(replacedTx.txid, replacedTx.txid); tree.interval = newTime - tree?.time;
this.chainMap.set(newTx.txid, replacedTx.txid); replacedTrees.push(tree);
this.dirtyChains.add(replacedTx.txid); 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 { public getReplacedBy(txId: string): string | undefined {
@ -69,66 +95,64 @@ class RbfCache {
return this.txs.get(txId); return this.txs.get(txId);
} }
public getRbfChain(txId: string): RbfChain { public getRbfTree(txId: string): RbfTree | void {
return this.rbfChains.get(this.chainMap.get(txId) || '') || []; 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 // ordered by most recent replacement time
public getRbfChains(onlyFullRbf: boolean, after?: string): RbfChain[] { public getRbfTrees(onlyFullRbf: boolean, after?: string): RbfTree[] {
const limit = 25; const limit = 25;
const chains: RbfChain[] = []; const trees: RbfTree[] = [];
const used = new Set<string>(); const used = new Set<string>();
const replacements: string[][] = Array.from(this.replacedBy).reverse(); const replacements: string[][] = Array.from(this.replacedBy).reverse();
const afterChain = after ? this.chainMap.get(after) : null; const afterTree = after ? this.treeMap.get(after) : null;
let ready = !afterChain; let ready = !afterTree;
for (let i = 0; i < replacements.length && chains.length <= limit - 1; i++) { for (let i = 0; i < replacements.length && trees.length <= limit - 1; i++) {
const txid = replacements[i][1]; const txid = replacements[i][1];
const chainRoot = this.chainMap.get(txid) || ''; const treeId = this.treeMap.get(txid) || '';
if (chainRoot === afterChain) { if (treeId === afterTree) {
ready = true; ready = true;
} else if (ready) { } else if (ready) {
if (!used.has(chainRoot)) { if (!used.has(treeId)) {
const chain = this.rbfChains.get(chainRoot); const tree = this.rbfTrees.get(treeId);
used.add(chainRoot); used.add(treeId);
if (chain && (!onlyFullRbf || chain.slice(0, -1).some(entry => !entry.tx.rbf))) { if (tree && (!onlyFullRbf || tree.fullRbf)) {
chains.push(chain); trees.push(tree);
} }
} }
} }
} }
return chains; return trees;
} }
// get map of rbf chains that have been updated since the last call // get map of rbf trees that have been updated since the last call
public getRbfChanges(): { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} { public getRbfChanges(): { trees: {[id: string]: RbfTree }, map: { [txid: string]: string }} {
const changes: { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} = { const changes: { trees: {[id: string]: RbfTree }, map: { [txid: string]: string }} = {
chains: {}, trees: {},
map: {}, map: {},
}; };
this.dirtyChains.forEach(root => { this.dirtyTrees.forEach(id => {
const chain = this.rbfChains.get(root); const tree = this.rbfTrees.get(id);
if (chain) { if (tree) {
changes.chains[root] = chain; changes.trees[id] = tree;
chain.forEach(entry => { this.getTransactionsInTree(tree).forEach(tx => {
changes.map[entry.tx.txid] = root; changes.map[tx.txid] = id;
}); });
} }
}); });
this.dirtyChains = new Set(); this.dirtyTrees = new Set();
return changes; return changes;
} }
public mined(txid): void { public mined(txid): void {
const chainRoot = this.chainMap.get(txid) const treeId = this.treeMap.get(txid);
if (chainRoot && this.rbfChains.has(chainRoot)) { if (treeId && this.rbfTrees.has(treeId)) {
const chain = this.rbfChains.get(chainRoot); const tree = this.rbfTrees.get(treeId);
if (chain) { if (tree) {
const chainEntry = chain.find(entry => entry.tx.txid === txid); this.setTreeMined(tree, txid);
if (chainEntry) { tree.mined = true;
chainEntry.mined = true; this.dirtyTrees.add(treeId);
}
this.dirtyChains.add(chainRoot);
} }
} }
this.evict(txid); this.evict(txid);
@ -155,20 +179,45 @@ class RbfCache {
if (!this.replacedBy.has(txid)) { if (!this.replacedBy.has(txid)) {
const replaces = this.replaces.get(txid); const replaces = this.replaces.get(txid);
this.replaces.delete(txid); this.replaces.delete(txid);
this.chainMap.delete(txid); this.treeMap.delete(txid);
this.txs.delete(txid); this.txs.delete(txid);
this.expiring.delete(txid); this.expiring.delete(txid);
for (const tx of (replaces || [])) { for (const tx of (replaces || [])) {
// recursively remove prior versions from the cache // recursively remove prior versions from the cache
this.replacedBy.delete(tx); this.replacedBy.delete(tx);
// if this is the root of a chain, remove that too // if this is the id of a tree, remove that too
if (this.chainMap.get(tx) === tx) { if (this.treeMap.get(tx) === tx) {
this.rbfChains.delete(tx); this.rbfTrees.delete(tx);
} }
this.remove(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(); export default new RbfCache();

View file

@ -289,9 +289,9 @@ class WebsocketHandler {
const rbfChanges = rbfCache.getRbfChanges(); const rbfChanges = rbfCache.getRbfChanges();
let rbfReplacements; let rbfReplacements;
let fullRbfReplacements; let fullRbfReplacements;
if (Object.keys(rbfChanges.chains).length) { if (Object.keys(rbfChanges.trees).length) {
rbfReplacements = rbfCache.getRbfChains(false); rbfReplacements = rbfCache.getRbfTrees(false);
fullRbfReplacements = rbfCache.getRbfChains(true); fullRbfReplacements = rbfCache.getRbfTrees(true);
} }
const recommendedFees = feeApi.getRecommendedFee(); const recommendedFees = feeApi.getRecommendedFee();
@ -415,20 +415,16 @@ class WebsocketHandler {
response['utxoSpent'] = outspends; response['utxoSpent'] = outspends;
} }
if (rbfTransactions[client['track-tx']]) { const rbfReplacedBy = rbfCache.getReplacedBy(client['track-tx']);
for (const rbfTransaction in rbfTransactions) { if (rbfReplacedBy) {
if (client['track-tx'] === rbfTransaction) { response['rbfTransaction'] = {
response['rbfTransaction'] = { txid: rbfReplacedBy,
txid: rbfTransactions[rbfTransaction].txid,
};
break;
}
} }
} }
const rbfChange = rbfChanges.map[client['track-tx']]; const rbfChange = rbfChanges.map[client['track-tx']];
if (rbfChange) { if (rbfChange) {
response['rbfInfo'] = rbfChanges.chains[rbfChange]; response['rbfInfo'] = rbfChanges.trees[rbfChange];
} }
} }

View file

@ -17,37 +17,22 @@
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="rbf-chains" style="min-height: 295px"> <div class="rbf-trees" style="min-height: 295px">
<ng-container *ngIf="rbfChains$ | async as chains"> <ng-container *ngIf="rbfTrees$ | async as trees">
<div *ngFor="let chain of chains" class="chain"> <div *ngFor="let tree of trees" class="tree">
<p class="info"> <p class="info">
<app-time kind="since" [time]="chain[chain.length - 1].time"></app-time>
<span class="type"> <span class="type">
<span *ngIf="isMined(chain)" class="badge badge-success" i18n="transaction.rbf.mined">Mined</span> <span *ngIf="isMined(tree)" class="badge badge-success" i18n="transaction.rbf.mined">Mined</span>
<span *ngIf="isFullRbf(chain)" class="badge badge-info" i18n="transaction.full-rbf">Full RBF</span> <span *ngIf="isFullRbf(tree)" class="badge badge-info" i18n="transaction.full-rbf">Full RBF</span>
</span> </span>
<app-time kind="since" [time]="tree.time"></app-time>
</p> </p>
<div class="txids"> <div class="timeline-wrapper" [class.mined]="isMined(tree)">
<span class="txid"> <app-rbf-timeline [replacements]="tree"></app-rbf-timeline>
<a class="rbf-link" [routerLink]="['/tx/' | relativeUrl, chain[0].tx.txid]">
<span class="d-inline">{{ chain[0].tx.txid | shortenString : 24 }}</span>
</a>
</span>
<span class="arrow">
<fa-icon [icon]="['fas', 'arrow-right']" [fixedWidth]="true"></fa-icon>
</span>
<span class="txid right">
<a class="rbf-link" [routerLink]="['/tx/' | relativeUrl, chain[chain.length - 1].tx.txid]">
<span class="d-inline">{{ chain[chain.length - 1].tx.txid | shortenString : 24 }}</span>
</a>
</span>
</div>
<div class="timeline-wrapper" [class.mined]="isMined(chain)">
<app-rbf-timeline [replacements]="chain"></app-rbf-timeline>
</div> </div>
</div> </div>
<div class="no-replacements" *ngIf="!chains?.length"> <div class="no-replacements" *ngIf="!trees?.length">
<p i18n="rbf.no-replacements-yet">there are no replacements in the mempool yet!</p> <p i18n="rbf.no-replacements-yet">there are no replacements in the mempool yet!</p>
</div> </div>
</ng-container> </ng-container>

View file

@ -4,13 +4,14 @@
margin-top: 13px; margin-top: 13px;
} }
.rbf-chains { .rbf-trees {
.info { .info {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
margin: 0; margin: 0;
margin-bottom: 0.5em;
.type { .type {
.badge { .badge {
@ -19,27 +20,10 @@
} }
} }
.chain { .tree {
margin-bottom: 1em; 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 { .timeline-wrapper.mined {
border: solid 4px #1a9436; border: solid 4px #1a9436;
} }

View file

@ -3,7 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, EMPTY, merge, Observable, Subscription } from 'rxjs'; import { BehaviorSubject, EMPTY, merge, Observable, Subscription } from 'rxjs';
import { catchError, switchMap, tap } from 'rxjs/operators'; import { catchError, switchMap, tap } from 'rxjs/operators';
import { WebsocketService } from 'src/app/services/websocket.service'; 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 { ApiService } from '../../services/api.service';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
@ -14,14 +14,12 @@ import { StateService } from '../../services/state.service';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class RbfList implements OnInit, OnDestroy { export class RbfList implements OnInit, OnDestroy {
rbfChains$: Observable<RbfInfo[][]>; rbfTrees$: Observable<RbfTree[]>;
fromChainSubject = new BehaviorSubject(null); nextRbfSubject = new BehaviorSubject(null);
urlFragmentSubscription: Subscription; urlFragmentSubscription: Subscription;
fullRbfEnabled: boolean; fullRbfEnabled: boolean;
fullRbf: boolean; fullRbf: boolean;
isLoading = true; isLoading = true;
firstChainId: string;
lastChainId: string;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@ -37,13 +35,13 @@ export class RbfList implements OnInit, OnDestroy {
this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => { this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => {
this.fullRbf = (fragment === 'fullrbf'); this.fullRbf = (fragment === 'fullrbf');
this.websocketService.startTrackRbf(this.fullRbf ? 'fullRbf' : 'all'); this.websocketService.startTrackRbf(this.fullRbf ? 'fullRbf' : 'all');
this.fromChainSubject.next(this.firstChainId); this.nextRbfSubject.next(null);
}); });
this.rbfChains$ = merge( this.rbfTrees$ = merge(
this.fromChainSubject.pipe( this.nextRbfSubject.pipe(
switchMap((fromChainId) => { switchMap(() => {
return this.apiService.getRbfList$(this.fullRbf, fromChainId || undefined) return this.apiService.getRbfList$(this.fullRbf);
}), }),
catchError((e) => { catchError((e) => {
return EMPTY; return EMPTY;
@ -52,11 +50,8 @@ export class RbfList implements OnInit, OnDestroy {
this.stateService.rbfLatest$ this.stateService.rbfLatest$
) )
.pipe( .pipe(
tap((result: RbfInfo[][]) => { tap(() => {
this.isLoading = false; 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 { isFullRbf(tree: RbfTree): boolean {
return chain.slice(0, -1).some(entry => !entry.tx.rbf); return tree.fullRbf;
} }
isMined(chain: RbfInfo[]): boolean { isMined(tree: RbfTree): boolean {
return chain.some(entry => entry.mined); return tree.mined;
} }
// pageChange(page: number) { // pageChange(page: number) {
// this.fromChainSubject.next(this.lastChainId); // this.fromTreeSubject.next(this.lastTreeId);
// } // }
ngOnDestroy(): void { ngOnDestroy(): void {

View file

@ -1,31 +1,54 @@
<div class="rbf-timeline box" [class.mined]="mined"> <div class="rbf-timeline box" [class.mined]="replacements.mined">
<div class="timeline"> <div class="timeline-wrapper">
<div class="intervals"> <div class="timeline" *ngFor="let timeline of rows">
<ng-container *ngFor="let replacement of replacements; let i = index;"> <div class="intervals">
<div class="interval" *ngIf="i > 0"> <ng-container *ngFor="let cell of timeline; let i = index;">
<div class="interval-time"> <div class="node-spacer"></div>
<app-time [time]="replacement.time - replacements[i-1].time" [relative]="false"></app-time> <ng-container *ngIf="i < timeline.length - 1">
</div> <div class="interval" *ngIf="cell.replacement?.interval != null; else intervalSpacer">
</div> <div class="interval-time">
<div class="node-spacer"></div> <app-time [time]="cell.replacement.interval" [relative]="false"></app-time>
</ng-container> </div>
</div> </div>
<div class="nodes"> </ng-container>
<ng-container *ngFor="let replacement of replacements; let i = index;"> </ng-container>
<div class="interval-spacer" *ngIf="i > 0"> </div>
<div class="track"></div> <div class="nodes">
</div> <ng-container *ngFor="let cell of timeline; let i = index;">
<div class="node" [class.selected]="txid === replacement.tx.txid" [class.mined]="replacement.mined"> <ng-container *ngIf="cell.replacement; else nonNode">
<div class="track"></div> <div class="node" [class.selected]="txid === cell.replacement.tx.txid" [class.mined]="cell.replacement.tx.mined" [class.first-node]="cell.first">
<a class="shape-border" [class.rbf]="replacement.tx.rbf" [routerLink]="['/tx/' | relativeUrl, replacement.tx.txid]" [title]="replacement.tx.txid"> <div class="track"></div>
<div class="shape"></div> <a class="shape-border" [class.rbf]="cell.replacement.tx.rbf" [routerLink]="['/tx/' | relativeUrl, cell.replacement.tx.txid]" [title]="cell.replacement.tx.txid">
</a> <div class="shape"></div>
<span class="fee-rate">{{ replacement.tx.fee / (replacement.tx.vsize) | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span> </a>
</div> <span class="fee-rate">{{ cell.replacement.tx.fee / (cell.replacement.tx.vsize) | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span>
</ng-container> </div>
</ng-container>
<ng-template #nonNode>
<ng-container [ngSwitch]="cell.connector">
<div class="connector" *ngSwitchCase="'pipe'"><div class="pipe"></div></div>
<div class="connector" *ngSwitchCase="'corner'"><div class="corner"></div></div>
<div class="node-spacer" *ngSwitchDefault></div>
</ng-container>
</ng-template>
<ng-container *ngIf="i < timeline.length - 1">
<div class="interval-spacer" *ngIf="cell.replacement?.interval != null; else intervalSpacer">
<div class="track"></div>
</div>
</ng-container>
</ng-container>
</div>
</div> </div>
</div> </div>
<ng-template #nodeSpacer>
<div class="node-spacer"></div>
</ng-template>
<ng-template #intervalSpacer>
<div class="interval-spacer"></div>
</ng-template>
<!-- <app-rbf-timeline-tooltip <!-- <app-rbf-timeline-tooltip
*ngIf=[tooltip] *ngIf=[tooltip]
[line]="hoverLine" [line]="hoverLine"

View file

@ -23,7 +23,7 @@
background: linear-gradient(to left, #24273e, #24273e, transparent); background: linear-gradient(to left, #24273e, #24273e, transparent);
} }
.timeline { .timeline-wrapper {
position: relative; position: relative;
width: calc(100% - 2em); width: calc(100% - 2em);
margin: auto; margin: auto;
@ -44,20 +44,27 @@
align-items: flex-start; align-items: flex-start;
text-align: center; text-align: center;
.node, .node-spacer { .node, .node-spacer, .connector {
width: 4em; width: 6em;
min-width: 4em; min-width: 6em;
flex-grow: 1; flex-grow: 1;
} }
.interval, .interval-spacer { .interval, .interval-spacer {
width: 8em; width: 8em;
min-width: 4em; min-width: 5em;
max-width: 8em; max-width: 8em;
height: 32px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-end;
} }
.interval-time { .interval-time {
font-size: 12px; font-size: 12px;
line-height: 16px;
padding: 0 10px;
} }
} }
@ -73,7 +80,7 @@
background: #105fb0; background: #105fb0;
border-radius: 5px; border-radius: 5px;
} }
&:first-child { &.first-node {
.track { .track {
left: 50%; left: 50%;
} }
@ -139,5 +146,24 @@
} }
} }
} }
.connector {
position: relative;
height: 10px;
.corner, .pipe {
position: absolute;
left: -10px;
width: 20px;
height: 108px;
bottom: 50%;
border-right: solid 10px #105fb0;
}
.corner {
border-bottom: solid 10px #105fb0;
border-bottom-right-radius: 10px;
}
}
} }
} }

View file

@ -1,18 +1,26 @@
import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID } from '@angular/core'; import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { RbfInfo } from '../../interfaces/node-api.interface'; import { RbfInfo, RbfTree } from '../../interfaces/node-api.interface';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
type Connector = 'pipe' | 'corner';
interface TimelineCell {
replacement?: RbfInfo,
connector?: Connector,
first?: boolean,
}
@Component({ @Component({
selector: 'app-rbf-timeline', selector: 'app-rbf-timeline',
templateUrl: './rbf-timeline.component.html', templateUrl: './rbf-timeline.component.html',
styleUrls: ['./rbf-timeline.component.scss'], styleUrls: ['./rbf-timeline.component.scss'],
}) })
export class RbfTimelineComponent implements OnInit, OnChanges { export class RbfTimelineComponent implements OnInit, OnChanges {
@Input() replacements: RbfInfo[]; @Input() replacements: RbfTree;
@Input() txid: string; @Input() txid: string;
mined: boolean; rows: TimelineCell[][] = [];
dir: 'rtl' | 'ltr' = 'ltr'; dir: 'rtl' | 'ltr' = 'ltr';
@ -28,10 +36,130 @@ export class RbfTimelineComponent implements OnInit, OnChanges {
} }
ngOnInit(): void { ngOnInit(): void {
this.mined = this.replacements.some(entry => entry.mined); this.rows = this.buildTimelines(this.replacements);
} }
ngOnChanges(): void { ngOnChanges(): void {
this.mined = this.replacements.some(entry => entry.mined); this.rows = this.buildTimelines(this.replacements);
} }
// converts a tree of RBF events into a format that can be more easily rendered in HTML
buildTimelines(tree: RbfTree): TimelineCell[][] {
if (!tree) return [];
const split = this.splitTimelines(tree);
const timelines = this.prepareTimelines(split);
return this.connectTimelines(timelines);
}
// splits a tree into N leaf-to-root paths
splitTimelines(tree: RbfTree, tail: RbfInfo[] = []): RbfInfo[][] {
const replacements = [...tail, tree];
if (tree.replaces.length) {
return [].concat(...tree.replaces.map(subtree => this.splitTimelines(subtree, replacements)));
} else {
return [[...replacements]];
}
}
// merges separate leaf-to-root paths into a coherent forking timeline
// represented as a 2D array of Rbf events
prepareTimelines(lines: RbfInfo[][]): RbfInfo[][] {
lines.sort((a, b) => b.length - a.length);
const rows = lines.map(() => []);
let lineGroups = [lines];
let done = false;
let column = 0; // sanity check for while loop stopping condition
while (!done && column < 100) {
// iterate over timelines element-by-element
// at each step, group lines which share a common transaction at their head
// (i.e. lines terminating in the same replacement event)
let index = 0;
let emptyCount = 0;
const nextGroups = [];
for (const group of lineGroups) {
const toMerge: { [txid: string]: RbfInfo[][] } = {};
let emptyInGroup = 0;
let first = true;
for (const line of group) {
const head = line.shift() || null;
if (first) {
// only insert the first instance of the replacement node
rows[index].unshift(head);
first = false;
} else {
// substitute duplicates with empty cells
// (we'll fill these in with connecting lines later)
rows[index].unshift(null);
}
// group the tails of the remaining lines for the next iteration
if (line.length) {
const nextId = line[0].tx.txid;
if (!toMerge[nextId]) {
toMerge[nextId] = [];
}
toMerge[nextId].push(line);
} else {
emptyInGroup++;
}
index++;
}
for (const merged of Object.values(toMerge).sort((a, b) => b.length - a.length)) {
nextGroups.push(merged);
}
for (let i = 0; i < emptyInGroup; i++) {
nextGroups.push([[]]);
}
emptyCount += emptyInGroup;
lineGroups = nextGroups;
done = (emptyCount >= rows.length);
}
column++;
}
return rows;
}
// annotates a 2D timeline array with info needed to draw connecting lines for multi-replacements
connectTimelines(timelines: RbfInfo[][]): TimelineCell[][] {
const rows: TimelineCell[][] = [];
timelines.forEach((lines, row) => {
rows.push([]);
let started = false;
let finished = false;
lines.forEach((replacement, column) => {
const cell: TimelineCell = {};
if (replacement) {
cell.replacement = replacement;
}
rows[row].push(cell);
if (replacement) {
if (!started) {
cell.first = true;
started = true;
}
} else if (started && !finished) {
if (column < timelines[row].length) {
let matched = false;
for (let i = row; i >= 0 && !matched; i--) {
const nextCell = rows[i][column];
if (nextCell.replacement) {
matched = true;
} else if (i === row) {
rows[i][column] = {
connector: 'corner'
};
} else if (nextCell.connector !== 'corner') {
rows[i][column] = {
connector: 'pipe'
};
}
}
}
finished = true;
}
});
});
return rows;
}
} }

View file

@ -190,7 +190,7 @@
<br> <br>
<ng-container *ngIf="rbfInfo?.length"> <ng-container *ngIf="rbfInfo">
<div class="title float-left"> <div class="title float-left">
<h2 id="rbf" i18n="transaction.replacements|Replacements">Replacements</h2> <h2 id="rbf" i18n="transaction.replacements|Replacements">Replacements</h2>
</div> </div>

View file

@ -19,7 +19,7 @@ import { WebsocketService } from '../../services/websocket.service';
import { AudioService } from '../../services/audio.service'; import { AudioService } from '../../services/audio.service';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { BlockExtended, CpfpInfo, RbfInfo } from '../../interfaces/node-api.interface'; import { BlockExtended, CpfpInfo, RbfTree } from '../../interfaces/node-api.interface';
import { LiquidUnblinding } from './liquid-ublinding'; import { LiquidUnblinding } from './liquid-ublinding';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { Price, PriceService } from '../../services/price.service'; import { Price, PriceService } from '../../services/price.service';
@ -54,7 +54,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
rbfTransaction: undefined | Transaction; rbfTransaction: undefined | Transaction;
replaced: boolean = false; replaced: boolean = false;
rbfReplaces: string[]; rbfReplaces: string[];
rbfInfo: RbfInfo[]; rbfInfo: RbfTree;
cpfpInfo: CpfpInfo | null; cpfpInfo: CpfpInfo | null;
showCpfpDetails = false; showCpfpDetails = false;
fetchCpfp$ = new Subject<string>(); fetchCpfp$ = new Subject<string>();
@ -188,7 +188,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
return of(null); return of(null);
}) })
).subscribe((rbfResponse) => { ).subscribe((rbfResponse) => {
this.rbfInfo = rbfResponse?.replacements || []; this.rbfInfo = rbfResponse?.replacements;
this.rbfReplaces = rbfResponse?.replaces || null; this.rbfReplaces = rbfResponse?.replaces || null;
}); });
@ -476,7 +476,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.replaced = false; this.replaced = false;
this.transactionTime = -1; this.transactionTime = -1;
this.cpfpInfo = null; this.cpfpInfo = null;
this.rbfInfo = []; this.rbfInfo = null;
this.rbfReplaces = []; this.rbfReplaces = [];
this.showCpfpDetails = false; this.showCpfpDetails = false;
document.body.scrollTo(0, 0); document.body.scrollTo(0, 0);

View file

@ -27,9 +27,15 @@ export interface CpfpInfo {
} }
export interface RbfInfo { export interface RbfInfo {
tx: RbfTransaction, tx: RbfTransaction;
time: number, time: number;
mined?: boolean, interval?: number;
}
export interface RbfTree extends RbfInfo {
mined?: boolean;
fullRbf: boolean;
replaces: RbfTree[];
} }
export interface DifficultyAdjustment { export interface DifficultyAdjustment {
@ -154,6 +160,7 @@ export interface TransactionStripped {
interface RbfTransaction extends TransactionStripped { interface RbfTransaction extends TransactionStripped {
rbf?: boolean; rbf?: boolean;
mined?: boolean,
} }
export interface RewardStats { export interface RewardStats {

View file

@ -1,6 +1,6 @@
import { ILoadingIndicators } from '../services/state.service'; import { ILoadingIndicators } from '../services/state.service';
import { Transaction } from './electrs.interface'; import { Transaction } from './electrs.interface';
import { BlockExtended, DifficultyAdjustment, RbfInfo } from './node-api.interface'; import { BlockExtended, DifficultyAdjustment, RbfTree } from './node-api.interface';
export interface WebsocketResponse { export interface WebsocketResponse {
block?: BlockExtended; block?: BlockExtended;
@ -16,8 +16,8 @@ export interface WebsocketResponse {
tx?: Transaction; tx?: Transaction;
rbfTransaction?: ReplacedTransaction; rbfTransaction?: ReplacedTransaction;
txReplaced?: ReplacedTransaction; txReplaced?: ReplacedTransaction;
rbfInfo?: RbfInfo[]; rbfInfo?: RbfTree;
rbfLatest?: RbfInfo[][]; rbfLatest?: RbfTree[];
utxoSpent?: object; utxoSpent?: object;
transactions?: TransactionStripped[]; transactions?: TransactionStripped[];
loadingIndicators?: ILoadingIndicators; loadingIndicators?: ILoadingIndicators;

View file

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators,
PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfInfo } from '../interfaces/node-api.interface'; PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree } from '../interfaces/node-api.interface';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { StateService } from './state.service'; import { StateService } from './state.service';
import { WebsocketResponse } from '../interfaces/websocket.interface'; import { WebsocketResponse } from '../interfaces/websocket.interface';
@ -124,16 +124,16 @@ export class ApiService {
return this.httpClient.get<AddressInformation>(this.apiBaseUrl + this.apiBasePath + '/api/v1/validate-address/' + address); return this.httpClient.get<AddressInformation>(this.apiBaseUrl + this.apiBasePath + '/api/v1/validate-address/' + address);
} }
getRbfHistory$(txid: string): Observable<{ replacements: RbfInfo[], replaces: string[] }> { getRbfHistory$(txid: string): Observable<{ replacements: RbfTree, replaces: string[] }> {
return this.httpClient.get<{ replacements: RbfInfo[], replaces: string[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/rbf'); return this.httpClient.get<{ replacements: RbfTree, replaces: string[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/rbf');
} }
getRbfCachedTx$(txid: string): Observable<Transaction> { getRbfCachedTx$(txid: string): Observable<Transaction> {
return this.httpClient.get<Transaction>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/cached'); return this.httpClient.get<Transaction>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/cached');
} }
getRbfList$(fullRbf: boolean, after?: string): Observable<RbfInfo[][]> { getRbfList$(fullRbf: boolean, after?: string): Observable<RbfTree[]> {
return this.httpClient.get<RbfInfo[][]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || '')); return this.httpClient.get<RbfTree[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || ''));
} }
listLiquidPegsMonth$(): Observable<LiquidPegs[]> { listLiquidPegsMonth$(): Observable<LiquidPegs[]> {

View file

@ -2,7 +2,7 @@ import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core';
import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs';
import { Transaction } from '../interfaces/electrs.interface'; import { Transaction } from '../interfaces/electrs.interface';
import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface';
import { BlockExtended, DifficultyAdjustment, OptimizedMempoolStats, RbfInfo } from '../interfaces/node-api.interface'; import { BlockExtended, DifficultyAdjustment, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface';
import { Router, NavigationStart } from '@angular/router'; import { Router, NavigationStart } from '@angular/router';
import { isPlatformBrowser } from '@angular/common'; import { isPlatformBrowser } from '@angular/common';
import { map, shareReplay } from 'rxjs/operators'; import { map, shareReplay } from 'rxjs/operators';
@ -98,8 +98,8 @@ export class StateService {
mempoolBlockTransactions$ = new Subject<TransactionStripped[]>(); mempoolBlockTransactions$ = new Subject<TransactionStripped[]>();
mempoolBlockDelta$ = new Subject<MempoolBlockDelta>(); mempoolBlockDelta$ = new Subject<MempoolBlockDelta>();
txReplaced$ = new Subject<ReplacedTransaction>(); txReplaced$ = new Subject<ReplacedTransaction>();
txRbfInfo$ = new Subject<RbfInfo[]>(); txRbfInfo$ = new Subject<RbfTree>();
rbfLatest$ = new Subject<RbfInfo[][]>(); rbfLatest$ = new Subject<RbfTree[]>();
utxoSpent$ = new Subject<object>(); utxoSpent$ = new Subject<object>();
difficultyAdjustment$ = new ReplaySubject<DifficultyAdjustment>(1); difficultyAdjustment$ = new ReplaySubject<DifficultyAdjustment>(1);
mempoolTransactions$ = new Subject<Transaction>(); mempoolTransactions$ = new Subject<Transaction>();

View file

@ -4,7 +4,7 @@ import { NgbCollapseModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstra
import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome';
import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle, import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle,
faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown, faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown,
faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowRight, faArrowsRotate, faCircleLeft } from '@fortawesome/free-solid-svg-icons'; faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft } from '@fortawesome/free-solid-svg-icons';
import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { InfiniteScrollModule } from 'ngx-infinite-scroll';
import { MasterPageComponent } from '../components/master-page/master-page.component'; import { MasterPageComponent } from '../components/master-page/master-page.component';
import { PreviewTitleComponent } from '../components/master-page-preview/preview-title.component'; import { PreviewTitleComponent } from '../components/master-page-preview/preview-title.component';
@ -315,7 +315,6 @@ export class SharedModule {
library.addIcons(faDownload); library.addIcons(faDownload);
library.addIcons(faQrcode); library.addIcons(faQrcode);
library.addIcons(faArrowRightArrowLeft); library.addIcons(faArrowRightArrowLeft);
library.addIcons(faArrowRight);
library.addIcons(faExchangeAlt); library.addIcons(faExchangeAlt);
} }
} }