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) {
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);

View file

@ -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;

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) {
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];
}
}
}
}

View file

@ -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<string, string> = new Map();
private replaces: Map<string, string[]> = new Map();
private rbfChains: Map<string, RbfChain> = new Map(); // sequences of consecutive replacements
private dirtyChains: Set<string> = new Set();
private chainMap: Map<string, string> = new Map(); // map of txids to sequence ids
private rbfTrees: Map<string, RbfTree> = new Map(); // sequences of consecutive replacements
private dirtyTrees: Set<string> = new Set();
private treeMap: Map<string, string> = new Map(); // map of txids to sequence ids
private txs: Map<string, TransactionExtended> = new Map();
private expiring: Map<string, Date> = new Map();
@ -24,38 +29,59 @@ class RbfCache {
setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
}
public add(replacedTxExtended: TransactionExtended, newTxExtended: TransactionExtended): void {
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.txs.set(newTx.txid, newTxExtended);
// 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);
const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction;
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, []);
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;
}
}
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);
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 {
return this.replacedBy.get(txId);
@ -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<string>();
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();

View file

@ -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) {
const rbfReplacedBy = rbfCache.getReplacedBy(client['track-tx']);
if (rbfReplacedBy) {
response['rbfTransaction'] = {
txid: rbfTransactions[rbfTransaction].txid,
};
break;
}
txid: rbfReplacedBy,
}
}
const rbfChange = rbfChanges.map[client['track-tx']];
if (rbfChange) {
response['rbfInfo'] = rbfChanges.chains[rbfChange];
response['rbfInfo'] = rbfChanges.trees[rbfChange];
}
}

View file

@ -17,37 +17,22 @@
<div class="clearfix"></div>
<div class="rbf-chains" style="min-height: 295px">
<ng-container *ngIf="rbfChains$ | async as chains">
<div *ngFor="let chain of chains" class="chain">
<div class="rbf-trees" style="min-height: 295px">
<ng-container *ngIf="rbfTrees$ | async as trees">
<div *ngFor="let tree of trees" class="tree">
<p class="info">
<app-time kind="since" [time]="chain[chain.length - 1].time"></app-time>
<span class="type">
<span *ngIf="isMined(chain)" 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="isMined(tree)" class="badge badge-success" i18n="transaction.rbf.mined">Mined</span>
<span *ngIf="isFullRbf(tree)" class="badge badge-info" i18n="transaction.full-rbf">Full RBF</span>
</span>
<app-time kind="since" [time]="tree.time"></app-time>
</p>
<div class="txids">
<span class="txid">
<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 class="timeline-wrapper" [class.mined]="isMined(tree)">
<app-rbf-timeline [replacements]="tree"></app-rbf-timeline>
</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>
</div>
</ng-container>

View file

@ -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;
}

View file

@ -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<RbfInfo[][]>;
fromChainSubject = new BehaviorSubject(null);
rbfTrees$: Observable<RbfTree[]>;
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 {

View file

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

View file

@ -23,7 +23,7 @@
background: linear-gradient(to left, #24273e, #24273e, transparent);
}
.timeline {
.timeline-wrapper {
position: relative;
width: calc(100% - 2em);
margin: auto;
@ -44,20 +44,27 @@
align-items: flex-start;
text-align: center;
.node, .node-spacer {
width: 4em;
min-width: 4em;
.node, .node-spacer, .connector {
width: 6em;
min-width: 6em;
flex-grow: 1;
}
.interval, .interval-spacer {
width: 8em;
min-width: 4em;
min-width: 5em;
max-width: 8em;
height: 32px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-end;
}
.interval-time {
font-size: 12px;
line-height: 16px;
padding: 0 10px;
}
}
@ -73,7 +80,7 @@
background: #105fb0;
border-radius: 5px;
}
&:first-child {
&.first-node {
.track {
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 { 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 { ApiService } from '../../services/api.service';
type Connector = 'pipe' | 'corner';
interface TimelineCell {
replacement?: RbfInfo,
connector?: Connector,
first?: boolean,
}
@Component({
selector: 'app-rbf-timeline',
templateUrl: './rbf-timeline.component.html',
styleUrls: ['./rbf-timeline.component.scss'],
})
export class RbfTimelineComponent implements OnInit, OnChanges {
@Input() replacements: RbfInfo[];
@Input() replacements: RbfTree;
@Input() txid: string;
mined: boolean;
rows: TimelineCell[][] = [];
dir: 'rtl' | 'ltr' = 'ltr';
@ -28,10 +36,130 @@ export class RbfTimelineComponent implements OnInit, OnChanges {
}
ngOnInit(): void {
this.mined = this.replacements.some(entry => entry.mined);
this.rows = this.buildTimelines(this.replacements);
}
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>
<ng-container *ngIf="rbfInfo?.length">
<ng-container *ngIf="rbfInfo">
<div class="title float-left">
<h2 id="rbf" i18n="transaction.replacements|Replacements">Replacements</h2>
</div>

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
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 { StateService } from './state.service';
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);
}
getRbfHistory$(txid: string): Observable<{ replacements: RbfInfo[], replaces: string[] }> {
return this.httpClient.get<{ replacements: RbfInfo[], replaces: string[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/rbf');
getRbfHistory$(txid: string): Observable<{ replacements: RbfTree, replaces: string[] }> {
return this.httpClient.get<{ replacements: RbfTree, replaces: string[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/rbf');
}
getRbfCachedTx$(txid: string): Observable<Transaction> {
return this.httpClient.get<Transaction>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/cached');
}
getRbfList$(fullRbf: boolean, after?: string): Observable<RbfInfo[][]> {
return this.httpClient.get<RbfInfo[][]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || ''));
getRbfList$(fullRbf: boolean, after?: string): Observable<RbfTree[]> {
return this.httpClient.get<RbfTree[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || ''));
}
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 { Transaction } from '../interfaces/electrs.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 { isPlatformBrowser } from '@angular/common';
import { map, shareReplay } from 'rxjs/operators';
@ -98,8 +98,8 @@ export class StateService {
mempoolBlockTransactions$ = new Subject<TransactionStripped[]>();
mempoolBlockDelta$ = new Subject<MempoolBlockDelta>();
txReplaced$ = new Subject<ReplacedTransaction>();
txRbfInfo$ = new Subject<RbfInfo[]>();
rbfLatest$ = new Subject<RbfInfo[][]>();
txRbfInfo$ = new Subject<RbfTree>();
rbfLatest$ = new Subject<RbfTree[]>();
utxoSpent$ = new Subject<object>();
difficultyAdjustment$ = new ReplaySubject<DifficultyAdjustment>(1);
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 { 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,
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 { MasterPageComponent } from '../components/master-page/master-page.component';
import { PreviewTitleComponent } from '../components/master-page-preview/preview-title.component';
@ -315,7 +315,6 @@ export class SharedModule {
library.addIcons(faDownload);
library.addIcons(faQrcode);
library.addIcons(faArrowRightArrowLeft);
library.addIcons(faArrowRight);
library.addIcons(faExchangeAlt);
}
}