-
-
-
-
-
-
Sent
-
-
0" kind="since" [time]="transactionTime">
+
+
+
+
+
First seen
+
+
0" kind="since" [time]="transactionTime">
+
-
-
-
-
-
-
-
-
-
Accelerated
-
-
-
-
-
-
-
-
Mined
-
- @if (tx.status.block_time) {
+
+
+
+
+
+
Mined
+
- } @else if (eta) {
-
- }
+
+
+
+
+
+
+} @else if (acceleratedETA) {
+} @else if (standardETA) {
+
+
+
+
+
+
+
+
+
+ @if (eta) {
+ ~
1" class="compare"> ({{ accelerateRatio }}x faster)
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
First seen
+
+
0" kind="since" [time]="transactionTime">
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.scss b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.scss
index d0338ec84..8648052f4 100644
--- a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.scss
+++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.scss
@@ -1,7 +1,7 @@
.acceleration-timeline {
position: relative;
width: 100%;
- padding: 1em 0;
+ padding: 0.5em 0 1em;
&::after, &::before {
content: '';
@@ -69,6 +69,15 @@
font-size: 12px;
line-height: 16px;
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);
border-radius: 5px;
- &.loading {
- animation: standardPulse 1s infinite;
- }
-
&.left {
right: 50%;
}
@@ -118,7 +123,28 @@
left: 50%;
}
}
-
+
+ .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 {
@@ -134,20 +160,20 @@
transform: translateY(-50%);
border-radius: 50%;
padding: 2px;
- background: transparent;
- transition: background-color 300ms, padding 300ms;
.shape {
width: 100%;
height: 100%;
border-radius: 50%;
background: white;
- transition: background-color 300ms, border 300ms;
+ &.accelerating {
+ animation: acceleratePulse 1s infinite;
+ }
}
- &.sent-selected {
+ &.waiting {
.shape {
- background: var(--primary);
+ background: var(--grey);
}
}
@@ -166,6 +192,12 @@
.status {
margin-top: -64px;
+
+ .badge.badge-waiting {
+ opacity: 0.5;
+ background-color: var(--grey);
+ color: white;
+ }
.badge.badge-accelerated {
background-color: var(--tertiary);
@@ -188,10 +220,3 @@
50% { background-color: var(--mainnet-alt) }
100% { background-color: var(--tertiary) }
}
-
-@keyframes standardPulse {
- 0% { background-color: var(--primary) }
- 50% { background-color: var(--secondary) }
- 100% { background-color: var(--primary) }
-
-}
\ No newline at end of file
diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.ts b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.ts
index d40215c1d..ba687e093 100644
--- a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.ts
+++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.ts
@@ -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 { Transaction } from '../../interfaces/electrs.interface';
@@ -11,23 +11,31 @@ export class AccelerationTimelineComponent implements OnInit, OnChanges {
@Input() transactionTime: number;
@Input() tx: Transaction;
@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;
- dir: 'rtl' | 'ltr' = 'ltr';
+ now: number;
+ accelerateRatio: number;
- constructor(
- @Inject(LOCALE_ID) private locale: string,
- ) {
- if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) {
- this.dir = 'rtl';
- }
- }
+ constructor() {}
ngOnInit(): void {
this.acceleratedAt = this.tx.acceleratedAt ?? new Date().getTime() / 1000;
}
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));
+ }
+ }
+ }
}
-
}
diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html
index 4bbb194fb..28a68b424 100644
--- a/frontend/src/app/components/transaction/transaction.component.html
+++ b/frontend/src/app/components/transaction/transaction.component.html
@@ -153,15 +153,6 @@
-
-
-
Acceleration Timeline
-
-
-
-
-
-
RBF Timeline
@@ -171,6 +162,15 @@
+
+
+
Acceleration Timeline
+
+
+
+
+
+
Flow
diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts
index b6c5050e3..ee0980e7c 100644
--- a/frontend/src/app/components/transaction/transaction.component.ts
+++ b/frontend/src/app/components/transaction/transaction.component.ts
@@ -113,6 +113,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
txChanged$ = new BehaviorSubject(false); // triggered whenever this.tx changes (long term, we should refactor to make this.tx an observable itself)
isAccelerated$ = new BehaviorSubject(false); // refactor this to make isAccelerated an observable itself
ETA$: Observable;
+ standardETA$: Observable;
isCached: boolean = false;
now = Date.now();
da$: Observable;
@@ -814,6 +815,21 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.miningStats = stats;
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);
}
diff --git a/frontend/src/app/services/eta.service.ts b/frontend/src/app/services/eta.service.ts
index cc1436e4c..f632c9adb 100644
--- a/frontend/src/app/services/eta.service.ts
+++ b/frontend/src/app/services/eta.service.ts
@@ -225,4 +225,58 @@ export class EtaService {
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);
+
+ }
}