Merge pull request #5287 from mempool/natsoni/acc-timeline-polish

Acceleration timeline polishing
This commit is contained in:
wiz 2024-07-08 23:06:09 +09:00 committed by GitHub
commit 8d2e7bef7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 268 additions and 91 deletions

View file

@ -1,3 +1,4 @@
@if (tx.status.confirmed) {
<div class="acceleration-timeline box"> <div class="acceleration-timeline box">
<div class="timeline-wrapper"> <div class="timeline-wrapper">
<div class="timeline"> <div class="timeline">
@ -11,68 +12,141 @@
<div class="node-spacer"></div> <div class="node-spacer"></div>
<div class="interval"> <div class="interval">
<div class="interval-time"> <div class="interval-time">
@if (eta) { <app-time [time]="tx.status.block_time - acceleratedAt"></app-time>
~<app-time kind="plain" [time]="eta?.wait / 1000"></app-time>
} @else if (tx.status.block_time) {
<app-time kind="plain" [time]="tx.status.block_time - acceleratedAt"></app-time>
}
</div> </div>
</div> </div>
<div class="node-spacer"></div> <div class="node-spacer"></div>
</div> </div>
</div>
<div class="nodes"> <div class="nodes">
<div class="node" [id]="'first-seen'"> <div class="node" [id]="'first-seen'">
<div class="seen-to-acc right" [class.loading]="!tx.acceleration && !tx.status.confirmed"></div> <div class="seen-to-acc right"></div>
<a class="shape-border" [class.sent-selected]="!tx.status.confirmed && !tx.acceleration"> <div class="shape-border">
<div class="shape"></div> <div class="shape"></div>
</a> </div>
<div class="status"><span class="badge badge-primary">Sent</span></div> <div class="status"><span class="badge badge-primary" i18n="transaction.first-seen|Transaction first seen">First seen</span></div>
<div class="time"> <div class="time">
<app-time *ngIf="transactionTime > 0" kind="since" [time]="transactionTime"></app-time> <app-time *ngIf="transactionTime > 0" kind="since" [time]="transactionTime"></app-time>
</div> </div>
</div> </div>
<div class="interval-spacer"> <div class="interval-spacer">
<div class="seen-to-acc" [class.loading]="!tx.acceleration && !tx.status.confirmed"></div> <div class="seen-to-acc"></div>
</div> </div>
<div class="node" [id]="'accelerated'"> <div class="node" [id]="'accelerated'">
<div class="seen-to-acc left" [class.loading]="!tx.acceleration && !tx.status.confirmed"></div> <div class="seen-to-acc left"></div>
<div class="acc-to-confirmed right" [class.loading]="tx.acceleration && !tx.status.confirmed"></div> <div class="acc-to-confirmed right"></div>
<a class="shape-border" [class.accelerated-selected]="tx.acceleration && !tx.status.confirmed"> <div class="shape-border">
<div class="shape"></div> <div class="shape"></div>
</a> </div>
<div class="status" [style]="!tx.acceleration && !tx.status.confirmed ? 'opacity: 0.5' : ''"><span class="badge badge-accelerated">Accelerated</span></div> <div class="status"><span class="badge badge-accelerated" i18n="transaction.audit.accelerated">Accelerated</span></div>
<div class="time"> <div class="time">
<app-time *ngIf="acceleratedAt" kind="since" [time]="acceleratedAt"></app-time> <app-time *ngIf="acceleratedAt" kind="since" [time]="acceleratedAt"></app-time>
</div> </div>
</div> </div>
<div class="interval-spacer"> <div class="interval-spacer">
<div class="acc-to-confirmed" [class.loading]="tx.acceleration && !tx.status.confirmed"></div> <div class="acc-to-confirmed"></div>
</div> </div>
<div class="node" [id]="'confirmed'" [class.mined]="tx.status.confirmed"> <div class="node mined" [id]="'confirmed'" >
<div class="acc-to-confirmed left" [class.loading]="tx.acceleration && !tx.status.confirmed"></div> <div class="acc-to-confirmed left" ></div>
<a class="shape-border" [class.mined-selected]="tx.status.confirmed"> <div class="shape-border mined-selected">
<div class="shape"></div> <div class="shape"></div>
</a> </div>
<div class="status" [style]="!tx.status.confirmed ? 'opacity: 0.5' : ''"><span class="badge badge-success">Mined</span></div> <div class="status"><span class="badge badge-success" i18n="transaction.rbf.mined">Mined</span></div>
<div class="time"> <div class="time">
@if (tx.status.block_time) {
<app-time kind="since" [time]="tx.status.block_time"></app-time> <app-time kind="since" [time]="tx.status.block_time"></app-time>
} @else if (eta) { </div>
<app-time kind="until" [time]="eta?.time"></app-time> </div>
</div>
</div>
</div>
</div>
} @else if (acceleratedETA) { <!-- Not yet accelerated; to be shown only in acceleration checkout -->
} @else if (standardETA) { <!-- Accelerated -->
<div class="acceleration-timeline box">
<div class="timeline-wrapper">
<div class="timeline">
<div class="intervals">
<div class="node-spacer"></div>
<div class="interval-spacer"></div>
<div class="node-spacer"></div>
<div class="interval">
<div class="interval-time">
@if (eta) {
~<app-time [time]="eta?.wait / 1000"></app-time> <span *ngIf="accelerateRatio > 1" class="compare"> ({{ accelerateRatio }}x faster)</span>
} }
</div> </div>
</div> </div>
</div>
</div>
<ng-template #nodeSpacer>
<div class="node-spacer"></div> <div class="node-spacer"></div>
</ng-template>
<ng-template #intervalSpacer>
<div class="interval-spacer"></div>
</ng-template>
</div> </div>
<div class="nodes">
<div class="node-spacer"></div>
<div class="interval-spacer"></div>
<div class="node">
<div class="acc-to-confirmed loading right"></div>
</div>
<div class="interval-spacer">
<div class="acc-to-confirmed loading"></div>
</div>
<div class="node" [id]="'confirmed'">
<div class="acc-to-confirmed loading left"></div>
<div class="shape-border waiting">
<div class="shape animate"></div>
</div>
<div class="status"><span class="badge badge-waiting" i18n="transaction.rbf.mined">Mined</span></div>
</div>
</div>
</div>
<div class="timeline">
<div class="intervals">
<div class="node-spacer"></div>
<div class="interval">
<div class="interval-time">
<app-time [time]="acceleratedAt - transactionTime"></app-time>
</div>
</div>
<div class="node-spacer"></div>
<div class="interval">
<div class="interval-time">
~<app-time [time]="standardETA / 1000 - now"></app-time>
</div>
</div>
<div class="node-spacer"></div>
</div>
<div class="nodes">
<div class="node" [id]="'first-seen'">
<div class="seen-to-acc right"></div>
<div class="shape-border">
<div class="shape"></div>
</div>
<div class="status"><span class="badge badge-primary" i18n="transaction.first-seen|Transaction first seen">First seen</span></div>
<div class="time">
<app-time *ngIf="transactionTime > 0" kind="since" [time]="transactionTime"></app-time>
</div>
</div>
<div class="interval-spacer">
<div class="seen-to-acc"></div>
</div>
<div class="node" [id]="'accelerated'">
<div class="seen-to-acc left"></div>
<div class="seen-to-acc right"></div>
<div class="shape-border accelerated-selected">
<div class="shape accelerating"></div>
<div class="connector down loading"></div>
</div>
<div class="time" style="margin-top: 3px;">
<span i18n="transaction.audit.accelerated">Accelerated</span>&nbsp;<app-time *ngIf="acceleratedAt" kind="since" [time]="acceleratedAt"></app-time>
</div>
</div>
<div class="interval-spacer">
<div class="seen-to-acc"></div>
</div>
<div class="node" [id]="'confirmed'">
<div class="seen-to-acc left"></div>
<div class="shape-border waiting">
<div class="shape"></div>
</div>
</div>
</div>
</div>
</div>
</div>
}

View file

@ -1,7 +1,7 @@
.acceleration-timeline { .acceleration-timeline {
position: relative; position: relative;
width: 100%; width: 100%;
padding: 1em 0; padding: 0.5em 0 1em;
&::after, &::before { &::after, &::before {
content: ''; content: '';
@ -69,6 +69,15 @@
font-size: 12px; font-size: 12px;
line-height: 16px; line-height: 16px;
white-space: nowrap; white-space: nowrap;
.compare {
font-style: italic;
color: var(--mainnet-alt);
font-weight: 600;
@media (max-width: 600px) {
display: none;
}
}
} }
} }
@ -84,10 +93,6 @@
background: var(--primary); background: var(--primary);
border-radius: 5px; border-radius: 5px;
&.loading {
animation: standardPulse 1s infinite;
}
&.left { &.left {
right: 50%; right: 50%;
} }
@ -119,6 +124,27 @@
} }
} }
.connector {
position: absolute;
height: 88px;
width: 10px;
left: -5px;
top: -73px;
transform: translateX(120%);
background: var(--tertiary);
&.down {
border-top-left-radius: 10px;
}
&.up {
border-top-right-radius: 10px;
}
&.loading {
animation: acceleratePulse 1s infinite;
}
}
} }
.nodes { .nodes {
@ -134,20 +160,20 @@
transform: translateY(-50%); transform: translateY(-50%);
border-radius: 50%; border-radius: 50%;
padding: 2px; padding: 2px;
background: transparent;
transition: background-color 300ms, padding 300ms;
.shape { .shape {
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 50%; border-radius: 50%;
background: white; background: white;
transition: background-color 300ms, border 300ms; &.accelerating {
animation: acceleratePulse 1s infinite;
}
} }
&.sent-selected { &.waiting {
.shape { .shape {
background: var(--primary); background: var(--grey);
} }
} }
@ -167,6 +193,12 @@
.status { .status {
margin-top: -64px; margin-top: -64px;
.badge.badge-waiting {
opacity: 0.5;
background-color: var(--grey);
color: white;
}
.badge.badge-accelerated { .badge.badge-accelerated {
background-color: var(--tertiary); background-color: var(--tertiary);
color: white; color: white;
@ -188,10 +220,3 @@
50% { background-color: var(--mainnet-alt) } 50% { background-color: var(--mainnet-alt) }
100% { background-color: var(--tertiary) } 100% { background-color: var(--tertiary) }
} }
@keyframes standardPulse {
0% { background-color: var(--primary) }
50% { background-color: var(--secondary) }
100% { background-color: var(--primary) }
}

View file

@ -1,4 +1,4 @@
import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID } from '@angular/core'; import { Component, Input, OnInit, OnChanges } from '@angular/core';
import { ETA } from '../../services/eta.service'; import { ETA } from '../../services/eta.service';
import { Transaction } from '../../interfaces/electrs.interface'; import { Transaction } from '../../interfaces/electrs.interface';
@ -11,23 +11,31 @@ export class AccelerationTimelineComponent implements OnInit, OnChanges {
@Input() transactionTime: number; @Input() transactionTime: number;
@Input() tx: Transaction; @Input() tx: Transaction;
@Input() eta: ETA; @Input() eta: ETA;
// A mined transaction has standard ETA and accelerated ETA undefined
// A transaction in mempool has either standardETA defined (if accelerated) or acceleratedETA defined (if not accelerated yet)
@Input() standardETA: number;
@Input() acceleratedETA: number;
acceleratedAt: number; acceleratedAt: number;
dir: 'rtl' | 'ltr' = 'ltr'; now: number;
accelerateRatio: number;
constructor( constructor() {}
@Inject(LOCALE_ID) private locale: string,
) {
if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) {
this.dir = 'rtl';
}
}
ngOnInit(): void { ngOnInit(): void {
this.acceleratedAt = this.tx.acceleratedAt ?? new Date().getTime() / 1000; this.acceleratedAt = this.tx.acceleratedAt ?? new Date().getTime() / 1000;
} }
ngOnChanges(changes): void { ngOnChanges(changes): void {
this.now = Math.floor(new Date().getTime() / 1000);
if (changes?.eta?.currentValue || changes?.standardETA?.currentValue || changes?.acceleratedETA?.currentValue) {
if (changes?.eta?.currentValue) {
if (changes?.acceleratedETA?.currentValue) {
this.accelerateRatio = Math.floor((Math.floor(changes.eta.currentValue.time / 1000) - this.now) / (Math.floor(changes.acceleratedETA.currentValue / 1000) - this.now));
} else if (changes?.standardETA?.currentValue) {
this.accelerateRatio = Math.floor((Math.floor(changes.standardETA.currentValue / 1000) - this.now) / (Math.floor(changes.eta.currentValue.time / 1000) - this.now));
}
}
}
} }
} }

View file

@ -153,15 +153,6 @@
<br> <br>
<ng-container *ngIf="transactionTime && (tx.acceleration || isAcceleration)">
<div class="title float-left">
<h2 id="acceleration-timeline" i18n="transaction.acceleration-timeline|Acceleration Timeline">Acceleration Timeline</h2>
</div>
<div class="clearfix"></div>
<app-acceleration-timeline [transactionTime]="transactionTime" [tx]="tx" [eta]="(ETA$ | async)"></app-acceleration-timeline>
<br>
</ng-container>
<ng-container *ngIf="rbfInfo"> <ng-container *ngIf="rbfInfo">
<div class="title float-left"> <div class="title float-left">
<h2 id="rbf" i18n="transaction.rbf-history|RBF Timeline">RBF Timeline</h2> <h2 id="rbf" i18n="transaction.rbf-history|RBF Timeline">RBF Timeline</h2>
@ -171,6 +162,15 @@
<br> <br>
</ng-container> </ng-container>
<ng-container *ngIf="transactionTime && isAcceleration">
<div class="title float-left">
<h2 id="acceleration-timeline" i18n="transaction.acceleration-timeline|Acceleration Timeline">Acceleration Timeline</h2>
</div>
<div class="clearfix"></div>
<app-acceleration-timeline [transactionTime]="transactionTime" [tx]="tx" [eta]="(ETA$ | async)" [standardETA]="(standardETA$ | async)?.time"></app-acceleration-timeline>
<br>
</ng-container>
<ng-container *ngIf="flowEnabled; else flowPlaceholder"> <ng-container *ngIf="flowEnabled; else flowPlaceholder">
<div class="title float-left"> <div class="title float-left">
<h2 id="flow" i18n="transaction.flow|Transaction flow">Flow</h2> <h2 id="flow" i18n="transaction.flow|Transaction flow">Flow</h2>

View file

@ -113,6 +113,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
txChanged$ = new BehaviorSubject<boolean>(false); // triggered whenever this.tx changes (long term, we should refactor to make this.tx an observable itself) txChanged$ = new BehaviorSubject<boolean>(false); // triggered whenever this.tx changes (long term, we should refactor to make this.tx an observable itself)
isAccelerated$ = new BehaviorSubject<boolean>(false); // refactor this to make isAccelerated an observable itself isAccelerated$ = new BehaviorSubject<boolean>(false); // refactor this to make isAccelerated an observable itself
ETA$: Observable<ETA | null>; ETA$: Observable<ETA | null>;
standardETA$: Observable<ETA | null>;
isCached: boolean = false; isCached: boolean = false;
now = Date.now(); now = Date.now();
da$: Observable<DifficultyAdjustment>; da$: Observable<DifficultyAdjustment>;
@ -814,6 +815,21 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.miningStats = stats; this.miningStats = stats;
this.isAccelerated$.next(this.isAcceleration); // hack to trigger recalculation of ETA without adding another source observable this.isAccelerated$.next(this.isAcceleration); // hack to trigger recalculation of ETA without adding another source observable
}); });
if (!this.tx.status?.confirmed) {
this.standardETA$ = combineLatest([
this.stateService.mempoolBlocks$.pipe(startWith(null)),
this.stateService.difficultyAdjustment$.pipe(startWith(null)),
]).pipe(
map(([mempoolBlocks, da]) => {
return this.etaService.calculateUnacceleratedETA(
this.tx,
mempoolBlocks,
da,
this.cpfpInfo,
);
})
)
}
} }
this.isAccelerated$.next(this.isAcceleration); this.isAccelerated$.next(this.isAcceleration);
} }

View file

@ -225,4 +225,58 @@ export class EtaService {
blocks: Math.ceil(eta / da.adjustedTimeAvg), blocks: Math.ceil(eta / da.adjustedTimeAvg),
}; };
} }
calculateUnacceleratedETA(
tx: Transaction,
mempoolBlocks: MempoolBlock[],
da: DifficultyAdjustment,
cpfpInfo: CpfpInfo | null,
): ETA | null {
if (!tx || !mempoolBlocks) {
return null;
}
const now = Date.now();
// use known projected position, or fall back to feerate-based estimate
const mempoolPosition = this.mempoolPositionFromFees(this.getFeeRateFromCpfpInfo(tx, cpfpInfo), mempoolBlocks);
if (!mempoolPosition) {
return null;
}
// difficulty adjustment estimate is required to know avg block time on non-Liquid networks
if (!da) {
return null;
}
const blocks = mempoolPosition.block + 1;
const wait = da.adjustedTimeAvg * (mempoolPosition.block + 1);
return {
now,
time: wait + now + da.timeOffset,
wait,
blocks,
};
}
getFeeRateFromCpfpInfo(tx: Transaction, cpfpInfo: CpfpInfo | null): number {
if (!cpfpInfo) {
return tx.fee / (tx.weight / 4);
}
const relatives = [...(cpfpInfo.ancestors || []), ...(cpfpInfo.descendants || [])];
if (cpfpInfo.bestDescendant && !cpfpInfo.descendants?.length) {
relatives.push(cpfpInfo.bestDescendant);
}
if (!!relatives.length) {
const totalWeight = tx.weight + relatives.reduce((prev, val) => prev + val.weight, 0);
const totalFees = tx.fee + relatives.reduce((prev, val) => prev + val.fee, 0);
return totalFees / (totalWeight / 4);
}
return tx.fee / (tx.weight / 4);
}
} }