diff --git a/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.html b/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.html new file mode 100644 index 000000000..fe0718ecc --- /dev/null +++ b/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.html @@ -0,0 +1,21 @@ +
+
+ +
+
+
+

+ {{ bar.label }} + + + +

+
+
+ {{ bar.class === 'tx' ? '' : '+' }} {{ bar.fee | number }} sat +
+
+
+
+
+
diff --git a/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.scss b/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.scss new file mode 100644 index 000000000..6137b53ee --- /dev/null +++ b/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.scss @@ -0,0 +1,157 @@ +.fee-graph { + height: 100%; + min-width: 120px; + width: 120px; + max-height: 90vh; + margin-left: 4em; + margin-right: 1.5em; + padding-bottom: 63px; + + .column { + width: 100%; + height: 100%; + position: relative; + background: #181b2d; + + .bar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .fill { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + opacity: 0.75; + pointer-events: none; + } + + .fee { + font-size: 0.9em; + opacity: 0; + pointer-events: none; + } + + .spacer { + width: 100%; + height: 1px; + flex-grow: 1; + pointer-events: none; + } + + .line { + position: absolute; + right: 0; + top: 0; + left: -4.5em; + border-top: dashed white 1.5px; + + .fee-rate { + width: 100%; + position: absolute; + left: 0; + right: 0.2em; + font-size: 0.8em; + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + margin: 0; + + .label { + margin-right: .2em; + } + + .rate .symbol { + color: white; + } + } + } + + &.tx { + .fill { + background: #3bcc49; + } + .line { + .fee-rate { + top: 0; + } + } + .fee { + position: absolute; + opacity: 1; + z-index: 11; + } + } + + &.target { + .fill { + background: #653b9c; + } + .fee { + position: absolute; + opacity: 1; + z-index: 11; + } + .line .fee-rate { + bottom: 2px; + } + } + + &.max { + cursor: pointer; + .line .fee-rate { + .label { + opacity: 0; + } + bottom: 2px; + } + &.active, &:hover { + .fill { + background: #105fb0; + } + .line { + .fee-rate .label { + opacity: 1; + } + } + } + } + + &:hover { + .fill { + z-index: 10; + } + .line { + z-index: 11; + } + .fee { + opacity: 1; + z-index: 12; + } + } + } + + &:hover > .bar:not(:hover) { + &.target, &.max { + .fee { + opacity: 0; + } + .line .fee-rate .label { + opacity: 0; + } + } + &.max { + .fill { + background: none; + } + } + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.ts b/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.ts new file mode 100644 index 000000000..4d746a0d9 --- /dev/null +++ b/frontend/src/app/components/accelerate-preview/accelerate-fee-graph.component.ts @@ -0,0 +1,96 @@ +import { Component, OnInit, Input, Output, OnChanges, EventEmitter, HostListener, Inject, LOCALE_ID } from '@angular/core'; +import { StateService } from '../../services/state.service'; +import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface'; +import { Router } from '@angular/router'; +import { ReplaySubject, merge, Subscription, of } from 'rxjs'; +import { tap, switchMap } from 'rxjs/operators'; +import { ApiService } from '../../services/api.service'; +import { AccelerationEstimate, RateOption } from './accelerate-preview.component'; + +interface GraphBar { + rate: number; + style: any; + class: 'tx' | 'target' | 'max'; + label: string; + active?: boolean; + rateIndex?: number; + fee?: number; +} + +@Component({ + selector: 'app-accelerate-fee-graph', + templateUrl: './accelerate-fee-graph.component.html', + styleUrls: ['./accelerate-fee-graph.component.scss'], +}) +export class AccelerateFeeGraphComponent implements OnInit, OnChanges { + @Input() tx: Transaction; + @Input() estimate: AccelerationEstimate; + @Input() maxRateOptions: RateOption[] = []; + @Input() maxRateIndex: number = 0; + @Output() setUserBid = new EventEmitter<{ fee: number, index: number }>(); + + bars: GraphBar[] = []; + tooltipPosition = { x: 0, y: 0 }; + + ngOnInit(): void { + this.initGraph(); + } + + ngOnChanges(): void { + this.initGraph(); + } + + initGraph(): void { + if (!this.tx || !this.estimate) { + return; + } + const maxRate = Math.max(...this.maxRateOptions.map(option => option.rate)); + const baseRate = this.estimate.txSummary.effectiveFee / this.estimate.txSummary.effectiveVsize; + const baseHeight = baseRate / maxRate; + const bars: GraphBar[] = this.maxRateOptions.slice().reverse().map(option => { + return { + rate: option.rate, + style: this.getStyle(option.rate, maxRate, baseHeight), + class: 'max', + label: 'maximum', + active: option.index === this.maxRateIndex, + rateIndex: option.index, + fee: option.fee, + } + }); + bars.push({ + rate: this.estimate.targetFeeRate, + style: this.getStyle(this.estimate.targetFeeRate, maxRate, baseHeight), + class: 'target', + label: 'next block', + fee: this.estimate.nextBlockFee - this.estimate.txSummary.effectiveFee + }); + bars.push({ + rate: baseRate, + style: this.getStyle(baseRate, maxRate, 0), + class: 'tx', + label: '', + fee: this.estimate.txSummary.effectiveFee, + }); + this.bars = bars; + } + + getStyle(rate, maxRate, base) { + const top = (rate / maxRate); + return { + height: `${(top - base) * 100}%`, + bottom: base ? `${base * 100}%` : '0', + } + } + + onClick(event, bar): void { + if (bar.rateIndex != null) { + this.setUserBid.emit({ fee: bar.fee, index: bar.rateIndex }); + } + } + + @HostListener('pointermove', ['$event']) + onPointerMove(event) { + this.tooltipPosition = { x: event.offsetX, y: event.offsetY }; + } +} diff --git a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.html b/frontend/src/app/components/accelerate-preview/accelerate-preview.component.html index c2bcbb7bf..9bb66eda1 100644 --- a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.html +++ b/frontend/src/app/components/accelerate-preview/accelerate-preview.component.html @@ -12,220 +12,251 @@ -
-
-
- This transactions is part of a CPFP tree. Fee rates (in sats/vb) are provided for your information. Change in the CPFP tree will lead to different fee rates values. -
-
-
+
+ + + - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Next block market price - - {{ estimate.targetFeeRate | number : '1.0-0' }} sat/vB -
- Currently estimated fee to get into next block - - - {{ estimate.nextBlockFee| number }} sats - - -
- Fees paid in-band - - ~{{ (estimate.txSummary.effectiveFee / estimate.txSummary.effectiveVsize) | number : '1.0-0' }} sat/vB -
- What you already paid when you made the transaction - - - {{ estimate.txSummary.effectiveFee | number }} sats - - -
- Extra fee required - - {{ math.max(0, estimate.nextBlockFee - estimate.txSummary.effectiveFee) | number }} sats - -
- Difference between the next block fee and your tx fee -
+ +
+
Your transaction
+
+
+ + Plus {{ estimate.txSummary.ancestorCount - 1 }} unconfirmed ancestor{{ estimate.txSummary.ancestorCount > 2 ? 's' : ''}}. + + + + + + + + + + + + + + + + + + +
+ Virtual size +
+ Size in vbytes of this transaction and its unconfirmed ancestors +
+ In-band fees + + {{ estimate.txSummary.effectiveFee | number : '1.0-0' }} sats +
+ Fees already paid by this transaction and its unconfirmed ancestors +
+
-
- -
How much more are you willing to pay at most to get into the next block?
-
-
- - The maximum extra transaction fee you're willing to pay to get into the next block. If the next block market price becomes too expensive for you, we will automatically cancel your acceleration request. Final charged fee may be smaller based on the fee market. - -
-
-
- - - - +
+
How much more are you willing to pay?
+
+
+ + Choose the maximum extra transaction fee you're willing to pay to get into the next block.
+ If the estimated next block rate rises beyond this limit, we will automatically cancel your acceleration request. +
+
+
+
+ + + +
-
+ +
Acceleration summary
+
+
+
+
+ Estimated cost +
+
+ Maximum cost +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -
Acceleration summary
-
-
-
+ Next block market rate + + {{ estimate.targetFeeRate | number : '1.0-0' }} + sat/vB
+ Estimated extra fee required + + {{ math.max(0, estimate.nextBlockFee - estimate.txSummary.effectiveFee) | number }} + + sats + +
+ Your maximum + + ~{{ ((estimate.txSummary.effectiveFee + userBid) / estimate.txSummary.effectiveVsize) | number : '1.0-0' }} + sat/vB
+ The maximum extra transaction fee you could pay + + + {{ userBid | number }} + + + sats + +
+ Mempool Acceleratorâ„¢ fees +
+ mempool.space fee + + +{{ estimate.mempoolBaseFee | number }} + + sats + +
+ Transaction vsize fee + + +{{ estimate.vsizeFee | number }} + + sats + +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Your maximum tx fees - - ~{{ ((estimate.txSummary.effectiveFee + userBid) / estimate.txSummary.effectiveVsize) | number : '1.0-0' }} sat/vB -
- The maximum extra transaction fee you're willing to pay - - - {{ userBid | number }} sats - - -
- Mempool Acceleratorâ„¢ fee - - +{{ estimate.mempoolBaseFee + estimate.vsizeFee | number }} sats - -
- mempool.space fee - - - {{ estimate.mempoolBaseFee | number }} sats - - -
- Transaction vsize fee - - - {{ estimate.vsizeFee | number }} sats - - -
- Estimated acceleration cost - - - {{ estimate.cost + estimate.mempoolBaseFee + estimate.vsizeFee | number }} sats - - -
- Cost if your tx is accelerated using {{ estimate.targetFeeRate | number : '1.0-0' }} sat/vB -
- Maximum acceleration cost - - {{ maxCost | number }} sats - - - -
- Cost if your tx is accelerated using ~{{ ((estimate.txSummary.effectiveFee + userBid) / estimate.txSummary.effectiveVsize) | number : '1.0-0' }} sat/vB -
- Available balance - - {{ estimate.userBalance | number }} sats - - - -
-
-
- -
-
-
- + + + + + Estimated acceleration cost + + + + {{ estimate.cost + estimate.mempoolBaseFee + estimate.vsizeFee | number }} + + + + sats + + + + + + If your tx is accelerated to {{ estimate.targetFeeRate | number : '1.0-0' }} sat/vB + + + + + + + + + Maximum acceleration cost + + + + {{ maxCost | number }} + + + + sats + + + + + + + + If your tx is accelerated to ~{{ ((estimate.txSummary.effectiveFee + userBid) / estimate.txSummary.effectiveVsize) | number : '1.0-0' }} sat/vB + + + + + + + + + Available balance + + + {{ estimate.userBalance | number }} + + + sats + + + + + + + +
+ +
+
+
+ +
+
+
+
- -
- \ No newline at end of file + +
\ No newline at end of file diff --git a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.scss b/frontend/src/app/components/accelerate-preview/accelerate-preview.component.scss index e6c717369..433c05520 100644 --- a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.scss +++ b/frontend/src/app/components/accelerate-preview/accelerate-preview.component.scss @@ -1,6 +1,23 @@ .fee-card { padding: 15px; background-color: #1d1f31; + + .feerate { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + .fee { + font-size: 1.2em; + } + .rate { + font-size: 0.9em; + .symbol { + color: white; + } + } + } } .btn-border { @@ -19,9 +36,53 @@ pointer-events: none; } +.table-toggle { + width: 100%; + margin-top: 0.5em; +} + .table-accelerator { - table-layout: fixed; - & tr { + tr { text-wrap: wrap; + + td { + padding-top: 0; + padding-bottom: 0; + vertical-align: baseline; + } + + &.group-first { + td { + padding-top: 0.75rem; + } + } + &.group-last { + td { + padding-bottom: 0.75rem; + } + } } + td { + &:first-child { + width: 100vw; + } + &.info { + color: #6c757d; + } + &.amt { + text-align: right; + padding-right: 0.2em; + } + &.units { + padding-left: 0.2em; + white-space: nowrap; + } + } +} + +.accelerate-cols { + display: flex; + flex-direction: row; + align-items: stretch; + margin-top: 1em; } \ No newline at end of file diff --git a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.ts b/frontend/src/app/components/accelerate-preview/accelerate-preview.component.ts index b4c7af704..1c356a80b 100644 --- a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.ts +++ b/frontend/src/app/components/accelerate-preview/accelerate-preview.component.ts @@ -1,7 +1,9 @@ -import { Component, OnInit, Input, OnDestroy, OnChanges, SimpleChanges } from '@angular/core'; +import { Component, OnInit, Input, OnDestroy, OnChanges, SimpleChanges, HostListener } from '@angular/core'; import { ApiService } from '../../services/api.service'; import { Subscription, catchError, of, tap } from 'rxjs'; import { StorageService } from '../../services/storage.service'; +import { Transaction } from '../../interfaces/electrs.interface'; +import { nextRoundNumber } from '../../shared/common.utils'; export type AccelerationEstimate = { txSummary: TxSummary; @@ -20,9 +22,15 @@ export type TxSummary = { ancestorCount: number; // Number of ancestors } -export const DEFAULT_BID_RATIO = 5; -export const MIN_BID_RATIO = 2; -export const MAX_BID_RATIO = 20; +export interface RateOption { + fee: number; + rate: number; + index: number; +} + +export const MIN_BID_RATIO = 1; +export const DEFAULT_BID_RATIO = 2; +export const MAX_BID_RATIO = 4; @Component({ selector: 'app-accelerate-preview', @@ -30,7 +38,7 @@ export const MAX_BID_RATIO = 20; styleUrls: ['accelerate-preview.component.scss'] }) export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges { - @Input() txid: string | undefined; + @Input() tx: Transaction | undefined; @Input() scrollEvent: boolean; math = Math; @@ -39,13 +47,18 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges estimateSubscription: Subscription; accelerationSubscription: Subscription; estimate: any; + hasAncestors: boolean = false; minExtraCost = 0; minBidAllowed = 0; maxBidAllowed = 0; defaultBid = 0; maxCost = 0; userBid = 0; - selectFeeRateIndex = 2; + selectFeeRateIndex = 1; + showTable: 'estimated' | 'maximum' = 'maximum'; + isMobile: boolean = window.innerWidth <= 767.98; + + maxRateOptions: RateOption[] = []; constructor( private apiService: ApiService, @@ -65,7 +78,7 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges } ngOnInit() { - this.estimateSubscription = this.apiService.estimate$(this.txid).pipe( + this.estimateSubscription = this.apiService.estimate$(this.tx.txid).pipe( tap((response) => { if (response.status === 204) { this.estimate = undefined; @@ -86,14 +99,23 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges this.scrollToPreviewWithTimeout('mempoolError', 'center'); } } + + this.hasAncestors = this.estimate.txSummary.ancestorCount > 1; // Make min extra fee at least 50% of the current tx fee - this.minExtraCost = Math.max(this.estimate.cost, this.estimate.txSummary.effectiveFee / 2); - this.minExtraCost = Math.round(this.minExtraCost); + this.minExtraCost = nextRoundNumber(Math.max(this.estimate.cost * 2, this.estimate.txSummary.effectiveFee)); + + this.maxRateOptions = [1, 2, 4].map((multiplier, index) => { + return { + fee: this.minExtraCost * multiplier, + rate: (this.estimate.txSummary.effectiveFee + (this.minExtraCost * multiplier)) / this.estimate.txSummary.effectiveVsize, + index, + }; + }); this.minBidAllowed = this.minExtraCost * MIN_BID_RATIO; - this.maxBidAllowed = this.minExtraCost * MAX_BID_RATIO; this.defaultBid = this.minExtraCost * DEFAULT_BID_RATIO; + this.maxBidAllowed = this.minExtraCost * MAX_BID_RATIO; this.userBid = this.defaultBid; if (this.userBid < this.minBidAllowed) { @@ -121,10 +143,10 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges /** * User changed his bid */ - setUserBid(multiplier: number, index: number) { + setUserBid({ fee, index }: { fee: number, index: number}) { if (this.estimate) { this.selectFeeRateIndex = index; - this.userBid = Math.max(0, this.minExtraCost * multiplier); + this.userBid = Math.max(0, fee); this.maxCost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee; } } @@ -156,7 +178,7 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges this.accelerationSubscription.unsubscribe(); } this.accelerationSubscription = this.apiService.accelerate$( - this.txid, + this.tx.txid, this.userBid ).subscribe({ next: () => { @@ -175,4 +197,9 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges const auth = this.storageService.getAuth(); return auth !== null; } + + @HostListener('window:resize', ['$event']) + onResize(): void { + this.isMobile = window.innerWidth <= 767.98; + } } \ No newline at end of file diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index ce5d703fb..006870864 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -84,7 +84,7 @@

Accelerate

- +
diff --git a/frontend/src/app/shared/common.utils.ts b/frontend/src/app/shared/common.utils.ts index 7d206f4b5..af72c0e72 100644 --- a/frontend/src/app/shared/common.utils.ts +++ b/frontend/src/app/shared/common.utils.ts @@ -135,4 +135,12 @@ export function haversineDistance(lat1: number, lon1: number, lat2: number, lon2 export function kmToMiles(km: number): number { return km * 0.62137119; +} + +const roundNumbers = [1, 2, 5, 10, 15, 20, 25, 50, 75, 100, 125, 150, 175, 200, 250, 300, 350, 400, 450, 500, 600, 700, 750, 800, 900, 1000]; +export function nextRoundNumber(num: number): number { + const log = Math.floor(Math.log10(num)); + const factor = log >= 3 ? Math.pow(10, log - 2) : 1; + num /= factor; + return factor * (roundNumbers.find(val => val >= num) || roundNumbers[roundNumbers.length - 1]); } \ No newline at end of file diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index d77eea2cf..f7c253a96 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -94,6 +94,7 @@ import { GeolocationComponent } from '../shared/components/geolocation/geolocati import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert.component'; import { GlobalFooterComponent } from './components/global-footer/global-footer.component'; import { AcceleratePreviewComponent } from '../components/accelerate-preview/accelerate-preview.component'; +import { AccelerateFeeGraphComponent } from '../components/accelerate-preview/accelerate-fee-graph.component'; import { MempoolErrorComponent } from './components/mempool-error/mempool-error.component'; import { MempoolBlockOverviewComponent } from '../components/mempool-block-overview/mempool-block-overview.component'; @@ -192,6 +193,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir TestnetAlertComponent, GlobalFooterComponent, AcceleratePreviewComponent, + AccelerateFeeGraphComponent, CalculatorComponent, BitcoinsatoshisPipe, MempoolBlockOverviewComponent, @@ -315,6 +317,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir PreviewTitleComponent, GlobalFooterComponent, AcceleratePreviewComponent, + AccelerateFeeGraphComponent, MempoolErrorComponent, MempoolBlockOverviewComponent,