@@ -82,7 +322,7 @@
diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss
index af11f6c2b..e03f223ca 100644
--- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss
+++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss
@@ -24,4 +24,134 @@
display: flex;
align-items: center;
max-width: 330px;
+}
+
+.fee-card {
+ padding: 15px;
+ background-color: var(--bg);
+
+ .feerate {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+
+ .rate {
+ font-size: 0.9em;
+ .symbol {
+ color: white;
+ }
+ }
+ }
+}
+
+.btn-border {
+ border: solid 1px black;
+ background-color: #0c4a87;
+}
+
+.feerate.active {
+ background-color: var(--primary) !important;
+ opacity: 1;
+ border: 1px solid #007fff !important;
+}
+.feerate:focus {
+ box-shadow: none !important;
+}
+
+.estimateDisabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.table-toggle {
+ width: 100%;
+ margin-top: 0.5em;
+}
+
+.tab {
+ &:first-child {
+ margin-right: 1px;
+ }
+ border: solid 1px black;
+ border-bottom: none;
+ background-color: #323655;
+ border-top-left-radius: 10px !important;
+ border-top-right-radius: 10px !important;
+}
+.tab.active {
+ background-color: #5d659d !important;
+ opacity: 1;
+}
+.tab:focus {
+ box-shadow: none !important;
+}
+
+.table-accelerator {
+ tr {
+ td {
+ padding-top: 0;
+ padding-bottom: 0;
+ vertical-align: baseline;
+ }
+
+ &.group-first {
+ td {
+ padding-top: 0.75rem;
+ }
+ }
+ &.group-last, &:last-child {
+ td {
+ padding-bottom: 0.75rem;
+ }
+ }
+ &.dashed-top {
+ border-top: 1px dashed grey;
+ }
+ &.dashed-bottom {
+ border-bottom: 1px dashed grey
+ }
+ }
+ td {
+ &:first-child {
+ width: 100vw;
+ }
+ &.info {
+ color: #6c757d;
+ white-space: initial;
+ }
+ &.amt {
+ text-align: right;
+ padding-right: 0.2em;
+ }
+ &.units {
+ padding-left: 0.2em;
+ white-space: nowrap;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+ }
+}
+
+.accelerate-cols {
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ margin-top: 1em;
+}
+
+.col.pie {
+ flex-grow: 0;
+ padding: 0 1em;
+ position: relative;
+ top: -15px;
+}
+
+.item {
+ white-space: initial;
+}
+
+.table-background {
+ background-color: var(--bg);
}
\ No newline at end of file
diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts
index 8714373d0..3d720e757 100644
--- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts
+++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts
@@ -1,13 +1,42 @@
-import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges } from '@angular/core';
+import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges, HostListener } from '@angular/core';
import { Subscription, tap, of, catchError, Observable } from 'rxjs';
import { ServicesApiServices } from '../../services/services-api.service';
import { nextRoundNumber } from '../../shared/common.utils';
import { StateService } from '../../services/state.service';
import { AudioService } from '../../services/audio.service';
-import { AccelerationEstimate } from '../accelerate-preview/accelerate-preview.component';
import { ETA, EtaService } from '../../services/eta.service';
import { Transaction } from '../../interfaces/electrs.interface';
import { MiningStats } from '../../services/mining.service';
+import { StorageService } from '../../services/storage.service';
+
+export type AccelerationEstimate = {
+ hasAccess: boolean;
+ txSummary: TxSummary;
+ nextBlockFee: number;
+ targetFeeRate: number;
+ userBalance: number;
+ enoughBalance: boolean;
+ cost: number;
+ mempoolBaseFee: number;
+ vsizeFee: number;
+ pools: number[]
+}
+export type TxSummary = {
+ txid: string; // txid of the current transaction
+ effectiveVsize: number; // Total vsize of the dependency tree
+ effectiveFee: number; // Total fee of the dependency tree in sats
+ ancestorCount: number; // Number of ancestors
+}
+
+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-checkout',
@@ -20,24 +49,43 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
@Input() eta: ETA;
@Input() scrollEvent: boolean;
@Input() cashappEnabled: boolean;
- @Input() isTracker: boolean = false;
+ @Input() showDetails: boolean;
+ @Input() advancedEnabled: boolean = false;
+ @Input() forceMobile: boolean = false;
+ @Output() changeMode = new EventEmitter
();
@Output() close = new EventEmitter();
calculating = true;
choosenOption: 'wait' | 'accel';
error = '';
+ math = Math;
+ isMobile: boolean = window.innerWidth <= 767.98;
- step: 'paymentMethod' | 'cta' | 'checkout' | 'processing' = 'cta';
+ step: 'quote' | 'paymentMethod' | 'checkout' | 'processing' = 'quote';
+ simpleMode: boolean = true;
paymentMethod: 'cashapp' | 'btcpay';
+ user: any = undefined;
+
// accelerator stuff
square: { appId: string, locationId: string};
accelerationUUID: string;
+ accelerationSubscription: Subscription;
+ difficultySubscription: Subscription;
estimateSubscription: Subscription;
estimate: AccelerationEstimate;
maxBidBoost: number; // sats
cost: number; // sats
etaInfo$: Observable<{ hashratePercentage: number, ETA: number, acceleratedETA: number }>;
+ showSuccess = false;
+ hasAncestors: boolean = false;
+ minExtraCost = 0;
+ minBidAllowed = 0;
+ maxBidAllowed = 0;
+ defaultBid = 0;
+ userBid = 0;
+ selectFeeRateIndex = 1;
+ maxRateOptions: RateOption[] = [];
// square
loadingCashapp = false;
@@ -52,8 +100,9 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
invoice = undefined;
constructor(
+ public stateService: StateService,
private servicesApiService: ServicesApiServices,
- private stateService: StateService,
+ private storageService: StorageService,
private etaService: EtaService,
private audioService: AudioService,
private cd: ChangeDetectorRef
@@ -62,6 +111,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}
ngOnInit() {
+ this.user = this.storageService.getAuth()?.user ?? null;
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('cash_request_id')) { // Redirected from cashapp
this.insertSquare();
@@ -74,7 +124,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
appId: ids.squareAppId,
locationId: ids.squareLocationId
};
- if (this.step === 'cta') {
+ if (this.step === 'quote') {
this.fetchEstimate();
}
});
@@ -95,7 +145,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
/**
* Scroll to element id with or without setTimeout
*/
- scrollToElementWithTimeout(id: string, position: ScrollLogicalPosition, timeout: number = 1000) {
+ scrollToElementWithTimeout(id: string, position: ScrollLogicalPosition, timeout: number = 1000): void {
setTimeout(() => {
this.scrollToElement(id, position);
}, timeout);
@@ -130,24 +180,100 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.error = `cannot_accelerate_tx`;
return;
}
+ if (this.estimate.hasAccess === true && this.estimate.userBalance <= 0) {
+ if (this.isLoggedIn()) {
+ this.error = `not_enough_balance`;
+ }
+ }
+ this.hasAncestors = this.estimate.txSummary.ancestorCount > 1;
+ this.etaInfo$ = this.etaService.getProjectedEtaObservable(this.estimate, this.miningStats);
+
// Make min extra fee at least 50% of the current tx fee
- const minExtraBoost = nextRoundNumber(Math.max(this.estimate.cost * 2, this.estimate.txSummary.effectiveFee));
- const DEFAULT_BID_RATIO = 1.5;
- this.maxBidBoost = minExtraBoost * DEFAULT_BID_RATIO;
- this.cost = this.maxBidBoost + this.estimate.mempoolBaseFee + this.estimate.vsizeFee;
- this.etaInfo$ = this.etaService.getProjectedEtaObservable(this.estimate);
+ 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.defaultBid = this.minExtraCost * DEFAULT_BID_RATIO;
+ this.maxBidAllowed = this.minExtraCost * MAX_BID_RATIO;
+
+ this.userBid = this.defaultBid;
+ if (this.userBid < this.minBidAllowed) {
+ this.userBid = this.minBidAllowed;
+ } else if (this.userBid > this.maxBidAllowed) {
+ this.userBid = this.maxBidAllowed;
+ }
+ this.cost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee;
+
this.calculating = false;
this.cd.markForCheck();
}
}),
catchError((response) => {
+ this.estimate = undefined;
this.error = `cannot_accelerate_tx`;
+ this.estimateSubscription.unsubscribe();
return of(null);
})
).subscribe();
}
+ /**
+ * User changed his bid
+ */
+ setUserBid({ fee, index }: { fee: number, index: number}): void {
+ if (this.estimate) {
+ this.selectFeeRateIndex = index;
+ this.userBid = Math.max(0, fee);
+ this.cost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee;
+ }
+ }
+
+ /**
+ * Advanced mode acceleration button clicked
+ */
+ accelerate(): void {
+ if (this.isLoggedIn()) {
+ this.accelerateWithMempoolAccount();
+ } else {
+ this.step = 'paymentMethod';
+ }
+ }
+
+ /**
+ * Account-based acceleration request
+ */
+ accelerateWithMempoolAccount(): void {
+ if (this.accelerationSubscription) {
+ this.accelerationSubscription.unsubscribe();
+ }
+ this.accelerationSubscription = this.servicesApiService.accelerate$(
+ this.tx.txid,
+ this.userBid,
+ this.accelerationUUID
+ ).subscribe({
+ next: () => {
+ this.audioService.playSound('ascend-chime-cartoon');
+ this.showSuccess = true;
+ this.estimateSubscription.unsubscribe();
+ },
+ error: (response) => {
+ if (response.status === 403 && response.error === 'not_available') {
+ this.error = 'waitlisted';
+ } else {
+ this.error = response.error;
+ }
+ }
+ });
+ }
+
/**
* Square
*/
@@ -321,4 +447,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.close.emit();
}, timeout);
}
+
+ isLoggedIn(): boolean {
+ const auth = this.storageService.getAuth();
+ return auth !== null;
+ }
+
+ @HostListener('window:resize', ['$event'])
+ onResize(): void {
+ this.isMobile = window.innerWidth <= 767.98;
+ }
}
diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.html b/frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.html
new file mode 100644
index 000000000..fe0718ecc
--- /dev/null
+++ b/frontend/src/app/components/accelerate-checkout/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-checkout/accelerate-fee-graph.component.scss b/frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.scss
new file mode 100644
index 000000000..919fdec4a
--- /dev/null
+++ b/frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.scss
@@ -0,0 +1,156 @@
+.fee-graph {
+ height: 100%;
+ min-width: 120px;
+ width: 120px;
+ margin-left: 4em;
+ margin-right: 1.5em;
+
+ .column {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ background: var(--stat-box-bg);
+
+ .bar {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ min-height: 30px;
+ 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: var(--green);
+ }
+ .line {
+ .fee-rate {
+ top: 0;
+ }
+ }
+ .fee {
+ position: absolute;
+ opacity: 1;
+ z-index: 11;
+ }
+ }
+
+ &.target {
+ .fill {
+ background: var(--tertiary);
+ }
+ .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: var(--primary);
+ }
+ .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-checkout/accelerate-fee-graph.component.ts b/frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.ts
new file mode 100644
index 000000000..c41cb2f87
--- /dev/null
+++ b/frontend/src/app/components/accelerate-checkout/accelerate-fee-graph.component.ts
@@ -0,0 +1,100 @@
+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-checkout.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() showEstimate = false;
+ @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;
+ console.log(maxRate, baseRate, baseHeight);
+ const bars: GraphBar[] = this.maxRateOptions.slice().reverse().map(option => {
+ return {
+ rate: option.rate,
+ style: this.getStyle(option.rate, maxRate, baseHeight),
+ class: 'max',
+ label: this.showEstimate ? $localize`maximum` : $localize`accelerated`,
+ active: option.index === this.maxRateIndex,
+ rateIndex: option.index,
+ fee: option.fee,
+ }
+ });
+ if (this.estimate.nextBlockFee > this.estimate.txSummary.effectiveFee) {
+ bars.push({
+ rate: this.estimate.targetFeeRate,
+ style: this.getStyle(this.estimate.targetFeeRate, maxRate, baseHeight),
+ class: 'target',
+ label: $localize`:@@bdf0e930eb22431140a2eaeacd809cc5f8ebd38c:Next Block`.toLowerCase(),
+ 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/tracker/tracker.component.html b/frontend/src/app/components/tracker/tracker.component.html
index c0f77c424..faa2db793 100644
--- a/frontend/src/app/components/tracker/tracker.component.html
+++ b/frontend/src/app/components/tracker/tracker.component.html
@@ -117,7 +117,7 @@
@if (showAccelerationSummary && !accelerationFlowCompleted) {
-
+
} @else {
@if (tx?.acceleration && !tx.status?.confirmed) {
diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html
index 65c859e5c..87477c5d7 100644
--- a/frontend/src/app/components/transaction/transaction.component.html
+++ b/frontend/src/app/components/transaction/transaction.component.html
@@ -84,17 +84,11 @@
- @if (isLoggedIn()) {
-
- } @else {
-
-
- Urgent transaction? Get it confirmed faster.
-
-
- }
+
+
+ Urgent transaction? Get it confirmed faster.
+
+
diff --git a/frontend/src/app/components/transaction/transaction.module.ts b/frontend/src/app/components/transaction/transaction.module.ts
index ac09067de..b536b3045 100644
--- a/frontend/src/app/components/transaction/transaction.module.ts
+++ b/frontend/src/app/components/transaction/transaction.module.ts
@@ -5,9 +5,8 @@ import { TransactionComponent } from './transaction.component';
import { SharedModule } from '../../shared/shared.module';
import { TxBowtieModule } from '../tx-bowtie-graph/tx-bowtie.module';
import { GraphsModule } from '../../graphs/graphs.module';
-import { AcceleratePreviewComponent } from '../accelerate-preview/accelerate-preview.component';
import { AccelerateCheckout } from '../accelerate-checkout/accelerate-checkout.component';
-import { AccelerateFeeGraphComponent } from '../accelerate-preview/accelerate-fee-graph.component';
+import { AccelerateFeeGraphComponent } from '../accelerate-checkout/accelerate-fee-graph.component';
import { TrackerComponent } from '../tracker/tracker.component';
import { TrackerBarComponent } from '../tracker/tracker-bar.component';
@@ -43,7 +42,6 @@ export class TransactionRoutingModule { }
TransactionComponent,
TrackerComponent,
TrackerBarComponent,
- AcceleratePreviewComponent,
AccelerateCheckout,
AccelerateFeeGraphComponent,
]
diff --git a/frontend/src/app/services/eta.service.ts b/frontend/src/app/services/eta.service.ts
index 3dc396a55..cc1436e4c 100644
--- a/frontend/src/app/services/eta.service.ts
+++ b/frontend/src/app/services/eta.service.ts
@@ -5,7 +5,7 @@ import { MempoolBlock } from '../interfaces/websocket.interface';
import { Transaction } from '../interfaces/electrs.interface';
import { MiningService, MiningStats } from './mining.service';
import { getUnacceleratedFeeRate } from '../shared/transaction.utils';
-import { AccelerationEstimate } from '../components/accelerate-preview/accelerate-preview.component';
+import { AccelerationEstimate } from '../components/accelerate-checkout/accelerate-checkout.component';
import { Observable, combineLatest, map, of, share, shareReplay, tap } from 'rxjs';
export interface ETA {