diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.html b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.html index 6d3591fb9..23462f8f7 100644 --- a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.html +++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.html @@ -1,3 +1,4 @@ +@if (tx.status.confirmed) {
@@ -11,68 +12,141 @@
- @if (eta) { - ~ - } @else if (tx.status.block_time) { - - } +
- -
-
-
-
- -
-
-
Sent
-
- +
+
+
+
+
+
+
First seen
+
+ +
-
-
-
-
-
-
-
- -
-
-
Accelerated
-
- +
+
-
-
-
-
-
-
- -
-
-
Mined
-
- @if (tx.status.block_time) { +
+
+
+
+
+
+
Accelerated
+
+ +
+
+
+
+
+
+
+
+
+
+
Mined
+
- } @else if (eta) { - - } +
+
+
+
+
+
+} @else if (acceleratedETA) { +} @else if (standardETA) { +
+
+
+
+
+
+
+
+
+ @if (eta) { + ~ ({{ accelerateRatio }}x faster) + } +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Mined
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ ~ +
+
+
+
+
+
+
+
+
+
+
First seen
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ Accelerated  +
+
+
+
+
+
+
+
+
+
+
- - -
-
- - -
-
- -
\ 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 d2cc0789d..ee0980e7c 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -65,6 +65,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { txId: string; txInBlockIndex: number; mempoolPosition: MempoolPosition; + gotInitialPosition = false; accelerationPositions: AccelerationPosition[]; isLoadingTx = true; error: any = undefined; @@ -112,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; @@ -431,9 +433,13 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { if (txPosition.position?.block > 0 && this.tx.weight < 4000) { this.cashappEligible = true; } + if (!this.gotInitialPosition && txPosition.position?.block === 0 && txPosition.position?.vsize < 750_000) { + this.accelerationFlowCompleted = true; + } } } } + this.gotInitialPosition = true; } else { this.mempoolPosition = null; this.accelerationPositions = null; @@ -809,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); } @@ -864,6 +885,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { resetTransaction() { this.firstLoad = false; + this.gotInitialPosition = false; this.error = undefined; this.tx = null; this.txChanged$.next(true); 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); + + } }