support trees of RBF replacements
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,31 +1,54 @@
|
||||
<div class="rbf-timeline box" [class.mined]="mined">
|
||||
<div class="timeline">
|
||||
<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>
|
||||
<div class="node-spacer"></div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="nodes">
|
||||
<ng-container *ngFor="let replacement of replacements; let i = index;">
|
||||
<div class="interval-spacer" *ngIf="i > 0">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</ng-container>
|
||||
<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 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 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>
|
||||
<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">{{ 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]
|
||||
[line]="hoverLine"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user