mempool/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts

598 lines
19 KiB
TypeScript
Raw Normal View History

import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges, HostListener } from '@angular/core';
import { Subscription, tap, of, catchError, Observable, switchMap } from 'rxjs';
import { ServicesApiServices } from '../../services/services-api.service';
import { nextRoundNumber } from '../../shared/common.utils';
import { StateService } from '../../services/state.service';
2024-04-13 23:07:19 +09:00
import { AudioService } from '../../services/audio.service';
import { ETA, EtaService } from '../../services/eta.service';
import { Transaction } from '../../interfaces/electrs.interface';
import { MiningStats } from '../../services/mining.service';
import { IAuth, AuthServiceMempool } from '../../services/auth.service';
export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp';
export type AccelerationEstimate = {
hasAccess: boolean;
txSummary: TxSummary;
nextBlockFee: number;
targetFeeRate: number;
userBalance: number;
enoughBalance: boolean;
cost: number;
mempoolBaseFee: number;
vsizeFee: number;
pools: number[];
availablePaymentMethods: {[method: string]: {min: number, max: 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;
type CheckoutStep = 'quote' | 'summary' | 'checkout' | 'cashapp' | 'processing' | 'paid' | 'success';
2024-06-28 07:02:12 +00:00
@Component({
selector: 'app-accelerate-checkout',
templateUrl: './accelerate-checkout.component.html',
styleUrls: ['./accelerate-checkout.component.scss']
})
export class AccelerateCheckout implements OnInit, OnDestroy {
@Input() tx: Transaction;
@Input() accelerating: boolean = false;
@Input() miningStats: MiningStats;
@Input() eta: ETA;
@Input() scrollEvent: boolean;
2024-06-28 07:02:12 +00:00
@Input() cashappEnabled: boolean = true;
@Input() advancedEnabled: boolean = false;
@Input() forceMobile: boolean = false;
2024-06-30 05:37:51 +00:00
@Input() showDetails: boolean = false;
2024-06-30 03:43:28 +00:00
@Input() noCTA: boolean = false;
2024-07-05 10:41:59 +00:00
@Output() unavailable = new EventEmitter<boolean>();
@Output() completed = new EventEmitter<boolean>();
2024-06-30 05:37:51 +00:00
@Output() hasDetails = new EventEmitter<boolean>();
@Output() changeMode = new EventEmitter<boolean>();
calculating = true;
selectedOption: 'wait' | 'accel';
2024-07-05 10:41:59 +00:00
cantPayReason = '';
quoteError = ''; // error fetching estimate or initial data
accelerateError = ''; // error executing acceleration
btcpayInvoiceFailed = false;
timePaid: number = 0; // time acceleration requested
math = Math;
isMobile: boolean = window.innerWidth <= 767.98;
2024-06-28 07:02:12 +00:00
private _step: CheckoutStep = 'summary';
simpleMode: boolean = true;
paymentMethod: 'cashapp' | 'btcpay';
authSubscription$: Subscription;
auth: IAuth | null = null;
// accelerator stuff
square: { appId: string, locationId: string};
accelerationUUID: string;
accelerationSubscription: Subscription;
difficultySubscription: Subscription;
estimateSubscription: Subscription;
estimate: AccelerationEstimate;
2024-04-13 23:07:19 +09:00
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
2024-04-13 23:07:19 +09:00
loadingCashapp = false;
2024-07-05 10:41:59 +00:00
cashappError = false;
cashappSubmit: any;
payments: any;
cashAppPay: any;
cashAppSubscription: Subscription;
conversionsSubscription: Subscription;
conversions: any;
// btcpay
loadingBtcpayInvoice = false;
invoice = undefined;
constructor(
public stateService: StateService,
private servicesApiService: ServicesApiServices,
private etaService: EtaService,
2024-04-13 23:07:19 +09:00
private audioService: AudioService,
private cd: ChangeDetectorRef,
private authService: AuthServiceMempool
2024-04-13 23:07:19 +09:00
) {
this.accelerationUUID = window.crypto.randomUUID();
}
ngOnInit() {
this.authSubscription$ = this.authService.getAuth$().subscribe((auth) => {
2024-07-02 13:08:20 +00:00
if (this.auth?.user?.userId !== auth?.user?.userId) {
this.auth = auth;
this.estimate = null;
2024-07-05 10:41:59 +00:00
this.quoteError = null;
this.accelerateError = null;
this.timePaid = 0;
this.btcpayInvoiceFailed = false;
2024-07-02 13:08:20 +00:00
this.moveToStep('summary');
} else {
this.auth = auth;
}
});
this.authService.refreshAuth$().subscribe();
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('cash_request_id')) { // Redirected from cashapp
2024-06-28 07:02:12 +00:00
this.moveToStep('processing');
2024-04-13 23:07:19 +09:00
this.insertSquare();
this.setupSquare();
2024-06-28 07:02:12 +00:00
} else {
2024-06-29 09:17:08 +00:00
this.moveToStep('summary');
}
2024-04-13 23:07:19 +09:00
this.servicesApiService.setupSquare$().subscribe(ids => {
this.square = {
appId: ids.squareAppId,
locationId: ids.squareLocationId
};
});
this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => {
this.conversions = conversions;
}
);
}
ngOnDestroy() {
if (this.estimateSubscription) {
this.estimateSubscription.unsubscribe();
}
if (this.authSubscription$) {
this.authSubscription$.unsubscribe();
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.scrollEvent && this.scrollEvent) {
this.scrollToElement('acceleratePreviewAnchor', 'start');
}
if (changes.accelerating) {
if ((this.step === 'processing' || this.step === 'paid') && this.accelerating) {
this.moveToStep('success');
}
}
}
2024-06-28 07:02:12 +00:00
moveToStep(step: CheckoutStep) {
this._step = step;
if (!this.estimate && ['quote', 'summary', 'checkout'].includes(this.step)) {
this.fetchEstimate();
}
if (this._step === 'checkout' && this.canPayWithBitcoin) {
2024-07-05 10:41:59 +00:00
this.btcpayInvoiceFailed = false;
2024-06-28 07:02:12 +00:00
this.loadingBtcpayInvoice = true;
this.invoice = null;
2024-06-28 07:02:12 +00:00
this.requestBTCPayInvoice();
} else if (this._step === 'cashapp' && this.cashappEnabled) {
this.loadingCashapp = true;
this.insertSquare();
this.setupSquare();
}
2024-06-30 05:37:51 +00:00
this.hasDetails.emit(this._step === 'quote');
2024-06-28 07:02:12 +00:00
}
closeModal(): void {
this.completed.emit(true);
this.moveToStep('summary');
}
/**
* Scroll to element id with or without setTimeout
*/
scrollToElementWithTimeout(id: string, position: ScrollLogicalPosition, timeout: number = 1000): void {
setTimeout(() => {
this.scrollToElement(id, position);
}, timeout);
}
scrollToElement(id: string, position: ScrollLogicalPosition) {
const acceleratePreviewAnchor = document.getElementById(id);
if (acceleratePreviewAnchor) {
this.cd.markForCheck();
acceleratePreviewAnchor.scrollIntoView({
behavior: 'smooth',
inline: position,
block: position,
});
}
}
2024-04-16 16:56:37 +09:00
/**
* Accelerator
*/
fetchEstimate() {
if (this.estimateSubscription) {
this.estimateSubscription.unsubscribe();
}
this.calculating = true;
2024-07-05 10:41:59 +00:00
this.quoteError = null;
this.accelerateError = null;
this.estimateSubscription = this.servicesApiService.estimate$(this.tx.txid).pipe(
tap((response) => {
if (response.status === 204) {
2024-07-05 10:41:59 +00:00
this.quoteError = `cannot_accelerate_tx`;
if (this.step === 'summary') {
this.unavailable.emit(true);
}
} else {
this.estimate = response.body;
if (!this.estimate) {
2024-07-05 10:41:59 +00:00
this.quoteError = `cannot_accelerate_tx`;
if (this.step === 'summary') {
this.unavailable.emit(true);
}
return;
}
if (this.estimate.hasAccess === true && this.estimate.userBalance <= 0) {
if (this.isLoggedIn()) {
2024-07-05 10:41:59 +00:00
this.quoteError = `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
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;
2024-07-05 10:41:59 +00:00
this.validateChoice();
if (this.step === 'checkout' && this.canPayWithBitcoin && !this.loadingBtcpayInvoice) {
2024-06-28 07:02:12 +00:00
this.loadingBtcpayInvoice = true;
this.requestBTCPayInvoice();
}
this.calculating = false;
this.cd.markForCheck();
}
}),
catchError((response) => {
this.estimate = undefined;
2024-07-05 10:41:59 +00:00
this.quoteError = `cannot_accelerate_tx`;
this.estimateSubscription.unsubscribe();
return of(null);
})
).subscribe();
}
2024-07-05 10:41:59 +00:00
validateChoice(): void {
if (!this.canPay) {
if (this.estimate?.availablePaymentMethods?.balance) {
if (this.cost >= this.estimate?.userBalance) {
this.cantPayReason = 'not_enough_balance';
}
} else {
this.cantPayReason = 'cannot_accelerate_tx';
}
} else {
this.cantPayReason = '';
}
}
/**
* 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;
}
}
/**
* Account-based acceleration request
*/
accelerateWithMempoolAccount(): void {
if (!this.canPay || this.calculating) {
return;
}
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();
this.moveToStep('paid')
},
error: (response) => {
2024-07-05 10:41:59 +00:00
this.accelerateError = response.error;
}
});
}
/**
* Square
*/
insertSquare(): void {
//@ts-ignore
if (window.Square) {
return;
}
let statsUrl = 'https://sandbox.web.squarecdn.com/v1/square.js';
if (document.location.hostname === 'mempool-staging.fmt.mempool.space' ||
document.location.hostname === 'mempool-staging.va1.mempool.space' ||
document.location.hostname === 'mempool-staging.fra.mempool.space' ||
document.location.hostname === 'mempool-staging.tk7.mempool.space' ||
document.location.hostname === 'mempool.space') {
statsUrl = 'https://web.squarecdn.com/v1/square.js';
}
(function() {
const d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
// @ts-ignore
g.type='text/javascript'; g.src=statsUrl; s.parentNode.insertBefore(g, s);
})();
}
setupSquare() {
const init = () => {
this.initSquare();
};
//@ts-ignore
if (!window.Square) {
console.debug('Square.js failed to load properly. Retrying in 1 second.');
setTimeout(init, 1000);
} else {
init();
}
}
async initSquare(): Promise<void> {
try {
//@ts-ignore
this.payments = window.Square.payments(this.square.appId, this.square.locationId)
await this.requestCashAppPayment();
} catch (e) {
2024-04-16 16:56:37 +09:00
console.debug('Error loading Square Payments', e);
2024-07-05 10:41:59 +00:00
this.cashappError = true;
return;
}
}
async requestCashAppPayment() {
if (this.cashAppSubscription) {
this.cashAppSubscription.unsubscribe();
}
if (this.conversionsSubscription) {
this.conversionsSubscription.unsubscribe();
}
this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => {
this.conversions = conversions;
if (this.cashAppPay) {
2024-04-16 16:56:37 +09:00
this.cashAppPay.destroy();
}
const redirectHostname = document.location.hostname === 'localhost' ? `http://localhost:4200`: `https://${document.location.hostname}`;
2024-04-13 23:07:19 +09:00
const costUSD = this.step === 'processing' ? 69.69 : (this.cost / 100_000_000 * conversions.USD); // When we're redirected to this component, the payment data is already linked to the payment token, so does not matter what amonut we put in there, therefore it's 69.69
const paymentRequest = this.payments.paymentRequest({
countryCode: 'US',
currencyCode: 'USD',
total: {
amount: costUSD.toString(),
label: 'Total',
pending: true,
productUrl: `${redirectHostname}/tracker/${this.tx.txid}`,
},
button: { shape: 'semiround', size: 'small', theme: 'light'}
});
this.cashAppPay = await this.payments.cashAppPay(paymentRequest, {
redirectURL: `${redirectHostname}/tracker/${this.tx.txid}`,
referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
button: { shape: 'semiround', size: 'small', theme: 'light'}
});
2024-04-13 23:07:19 +09:00
2024-06-28 07:02:12 +00:00
if (this.step === 'cashapp') {
2024-04-13 23:07:19 +09:00
await this.cashAppPay.attach(`#cash-app-pay`, { theme: 'light', size: 'small', shape: 'semiround' })
}
this.loadingCashapp = false;
const that = this;
this.cashAppPay.addEventListener('ontokenization', function (event) {
const { tokenResult, error } = event.detail;
if (error) {
2024-07-05 10:41:59 +00:00
this.accelerateError = error;
} else if (tokenResult.status === 'OK') {
that.servicesApiService.accelerateWithCashApp$(
that.tx.txid,
tokenResult.token,
tokenResult.details.cashAppPay.cashtag,
tokenResult.details.cashAppPay.referenceId,
that.accelerationUUID
).subscribe({
next: () => {
2024-04-13 23:07:19 +09:00
that.audioService.playSound('ascend-chime-cartoon');
2024-04-16 16:56:37 +09:00
if (that.cashAppPay) {
that.cashAppPay.destroy();
}
setTimeout(() => {
this.moveToStep('paid');
2024-04-16 17:02:10 +09:00
if (window.history.replaceState) {
const urlParams = new URLSearchParams(window.location.search);
window.history.replaceState(null, null, window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ''));
}
2024-04-16 16:56:37 +09:00
}, 1000);
},
error: (response) => {
2024-07-05 10:41:59 +00:00
that.accelerateError = response.error;
if (!(response.status === 403 && response.error === 'not_available')) {
2024-04-16 16:56:37 +09:00
setTimeout(() => {
2024-04-16 17:02:10 +09:00
// Reset everything by reloading the page :D, can be improved
2024-04-16 16:56:37 +09:00
const urlParams = new URLSearchParams(window.location.search);
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
}, 3000);
}
}
});
}
});
}
);
}
/**
* BTCPay
*/
async requestBTCPayInvoice() {
this.servicesApiService.generateBTCPayAcceleratorInvoice$(this.tx.txid, this.userBid).pipe(
switchMap(response => {
return this.servicesApiService.retreiveInvoice$(response.btcpayInvoiceId);
}),
catchError(error => {
console.log(error);
2024-07-05 10:41:59 +00:00
this.btcpayInvoiceFailed = true;
return of(null);
})
).subscribe((invoice) => {
this.invoice = invoice;
this.cd.markForCheck();
});
}
bitcoinPaymentCompleted(): void {
this.audioService.playSound('ascend-chime-cartoon');
this.estimateSubscription.unsubscribe();
this.moveToStep('paid')
}
isLoggedIn(): boolean {
return this.auth !== null;
}
/**
* UI events
*/
selectedOptionChanged(event) {
this.selectedOption = event.target.id;
}
2024-06-28 07:02:12 +00:00
get step() {
return this._step;
}
2024-07-05 10:41:59 +00:00
get paymentMethods() {
return Object.keys(this.estimate?.availablePaymentMethods || {});
}
get couldPayWithBitcoin() {
return !!this.estimate?.availablePaymentMethods?.bitcoin;
}
get couldPayWithCashapp() {
if (!this.cashappEnabled || this.stateService.referrer !== 'https://cash.app/') {
return false;
}
return !!this.estimate?.availablePaymentMethods?.cashapp;
}
get couldPayWithBalance() {
if (!this.hasAccessToBalanceMode) {
return false;
}
return !!this.estimate?.availablePaymentMethods?.balance;
}
get couldPay() {
return this.couldPayWithBalance || this.couldPayWithBitcoin || this.couldPayWithCashapp;
}
2024-06-28 07:02:12 +00:00
get canPayWithBitcoin() {
const paymentMethod = this.estimate?.availablePaymentMethods?.bitcoin;
return paymentMethod && this.cost >= paymentMethod.min && this.cost <= paymentMethod.max;
2024-06-28 07:02:12 +00:00
}
get canPayWithCashapp() {
if (!this.cashappEnabled || !this.conversions || this.stateService.referrer !== 'https://cash.app/') {
return false;
}
const paymentMethod = this.estimate?.availablePaymentMethods?.cashapp;
if (paymentMethod) {
const costUSD = (this.cost / 100_000_000 * this.conversions.USD);
if (costUSD >= paymentMethod.min && costUSD <= paymentMethod.max) {
return true;
}
}
return false;
2024-06-28 07:02:12 +00:00
}
get canPayWithBalance() {
if (!this.hasAccessToBalanceMode) {
return false;
}
const paymentMethod = this.estimate?.availablePaymentMethods?.balance;
2024-07-05 10:41:59 +00:00
return paymentMethod && this.cost >= paymentMethod.min && this.cost <= paymentMethod.max && this.cost <= this.estimate?.userBalance;
}
get canPay() {
return this.canPayWithBalance || this.canPayWithBitcoin || this.canPayWithCashapp;
2024-06-28 07:02:12 +00:00
}
get hasAccessToBalanceMode() {
return this.isLoggedIn() && this.estimate?.hasAccess;
}
@HostListener('window:resize', ['$event'])
onResize(): void {
this.isMobile = window.innerWidth <= 767.98;
}
}