Add projected acceleration ETA to tracker page

This commit is contained in:
Mononaut 2024-06-24 02:06:22 +00:00
parent 0c72e1b6ed
commit 517e82ec8b
No known key found for this signature in database
GPG key ID: A3F058E41374C04E
7 changed files with 85 additions and 75 deletions

View file

@ -21,7 +21,7 @@
<input type="radio" class="form-check-input" id="accelerate" name="accelerate" (change)="selectedOptionChanged($event)"> <input type="radio" class="form-check-input" id="accelerate" name="accelerate" (change)="selectedOptionChanged($event)">
<label class="form-check-label d-flex flex-column" for="accelerate"> <label class="form-check-label d-flex flex-column" for="accelerate">
<span class="font-weight-bold">Accelerate</span> <span class="font-weight-bold">Accelerate</span>
<span style="color: rgb(186, 186, 186); font-size: 14px;">Confirmation expected within ~30 minutes<br> <span style="color: rgb(186, 186, 186); font-size: 14px;" *ngIf="(etaInfo$ | async) as etaInfo">Confirmation expected <app-time kind="within" [time]="etaInfo.acceleratedETA" [fastRender]="false" [fixedRender]="true"></app-time><br>
@if (!calculating) { @if (!calculating) {
<app-fiat [value]="cost"></app-fiat>fee (<span><small style="font-family: monospace;">{{ cost | number }}</small>&nbsp;<span class="symbol" i18n="shared.sats">sats</span></span>) <app-fiat [value]="cost"></app-fiat>fee (<span><small style="font-family: monospace;">{{ cost | number }}</small>&nbsp;<span class="symbol" i18n="shared.sats">sats</span></span>)
} @else { } @else {

View file

@ -1,9 +1,11 @@
import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges } from '@angular/core'; import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges } from '@angular/core';
import { Subscription, tap, of, catchError } from 'rxjs'; import { Subscription, tap, of, catchError, Observable } from 'rxjs';
import { ServicesApiServices } from '../../services/services-api.service'; import { ServicesApiServices } from '../../services/services-api.service';
import { nextRoundNumber } from '../../shared/common.utils'; import { nextRoundNumber } from '../../shared/common.utils';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { AudioService } from '../../services/audio.service'; import { AudioService } from '../../services/audio.service';
import { AccelerationEstimate } from '../accelerate-preview/accelerate-preview.component';
import { EtaService } from '../../services/eta.service';
@Component({ @Component({
selector: 'app-accelerate-checkout', selector: 'app-accelerate-checkout',
@ -24,8 +26,10 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
square: { appId: string, locationId: string}; square: { appId: string, locationId: string};
accelerationUUID: string; accelerationUUID: string;
estimateSubscription: Subscription; estimateSubscription: Subscription;
estimate: AccelerationEstimate;
maxBidBoost: number; // sats maxBidBoost: number; // sats
cost: number; // sats cost: number; // sats
etaInfo$: Observable<{ hashratePercentage: number, ETA: number, acceleratedETA: number }>;
// square // square
loadingCashapp = false; loadingCashapp = false;
@ -39,6 +43,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
constructor( constructor(
private servicesApiService: ServicesApiServices, private servicesApiService: ServicesApiServices,
private stateService: StateService, private stateService: StateService,
private etaService: EtaService,
private audioService: AudioService, private audioService: AudioService,
private cd: ChangeDetectorRef private cd: ChangeDetectorRef
) { ) {
@ -59,7 +64,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
locationId: ids.squareLocationId locationId: ids.squareLocationId
}; };
if (this.step === 'cta') { if (this.step === 'cta') {
this.estimate(); this.fetchEstimate();
} }
}); });
} }
@ -99,7 +104,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
/** /**
* Accelerator * Accelerator
*/ */
estimate() { fetchEstimate() {
if (this.estimateSubscription) { if (this.estimateSubscription) {
this.estimateSubscription.unsubscribe(); this.estimateSubscription.unsubscribe();
} }
@ -110,16 +115,17 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
if (response.status === 204) { if (response.status === 204) {
this.error = `cannot_accelerate_tx`; this.error = `cannot_accelerate_tx`;
} else { } else {
const estimation = response.body; this.estimate = response.body;
if (!estimation) { if (!this.estimate) {
this.error = `cannot_accelerate_tx`; this.error = `cannot_accelerate_tx`;
return; return;
} }
// Make min extra fee at least 50% of the current tx fee // Make min extra fee at least 50% of the current tx fee
const minExtraBoost = nextRoundNumber(Math.max(estimation.cost * 2, estimation.txSummary.effectiveFee)); const minExtraBoost = nextRoundNumber(Math.max(this.estimate.cost * 2, this.estimate.txSummary.effectiveFee));
const DEFAULT_BID_RATIO = 1.5; const DEFAULT_BID_RATIO = 1.5;
this.maxBidBoost = minExtraBoost * DEFAULT_BID_RATIO; this.maxBidBoost = minExtraBoost * DEFAULT_BID_RATIO;
this.cost = this.maxBidBoost + estimation.mempoolBaseFee + estimation.vsizeFee; this.cost = this.maxBidBoost + this.estimate.mempoolBaseFee + this.estimate.vsizeFee;
this.etaInfo$ = this.etaService.getProjectedEtaObservable(this.estimate);
} }
}), }),

View file

@ -68,8 +68,10 @@
<h5 *ngIf="estimate?.pools?.length" i18n="accelerator.how-much-faster">How much faster?</h5> <h5 *ngIf="estimate?.pools?.length" i18n="accelerator.how-much-faster">How much faster?</h5>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<small class="form-text text-muted mb-2" i18n="accelerator.hashrate-percentage-description">Your transaction will be prioritized by up to {{ hashratePercentage | number : '1.1-1' }}% of miners.</small> <ng-container *ngIf="(etaInfo$ | async) as etaInfo">
<small class="form-text text-muted mb-2" i18n="accelerator.time-estimate-description">This will reduce your expected waiting time until the first confirmation to <app-time kind="within" [time]="acceleratedETA" [fastRender]="false" [fixedRender]="true"></app-time></small> <small class="form-text text-muted mb-2" i18n="accelerator.hashrate-percentage-description">Your transaction will be prioritized by up to {{ etaInfo.hashratePercentage | number : '1.1-1' }}% of miners.</small>
<small class="form-text text-muted mb-2" i18n="accelerator.time-estimate-description">This will reduce your expected waiting time until the first confirmation to <app-time kind="within" [time]="etaInfo.acceleratedETA" [fastRender]="false" [fixedRender]="true"></app-time></small>
</ng-container>
</div> </div>
<div class="col pie"> <div class="col pie">
<app-active-acceleration-box [miningStats]="miningStats" [pools]="estimate.pools" [chartOnly]="true"></app-active-acceleration-box> <app-active-acceleration-box [miningStats]="miningStats" [pools]="estimate.pools" [chartOnly]="true"></app-active-acceleration-box>

View file

@ -1,5 +1,5 @@
import { Component, OnInit, Input, OnDestroy, OnChanges, SimpleChanges, HostListener, ChangeDetectorRef } from '@angular/core'; import { Component, OnInit, Input, OnDestroy, OnChanges, SimpleChanges, HostListener, ChangeDetectorRef } from '@angular/core';
import { Subscription, catchError, of, tap } from 'rxjs'; import { Observable, Subscription, catchError, of, tap } from 'rxjs';
import { StorageService } from '../../services/storage.service'; import { StorageService } from '../../services/storage.service';
import { Transaction } from '../../interfaces/electrs.interface'; import { Transaction } from '../../interfaces/electrs.interface';
import { nextRoundNumber } from '../../shared/common.utils'; import { nextRoundNumber } from '../../shared/common.utils';
@ -8,7 +8,6 @@ import { AudioService } from '../../services/audio.service';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { MiningStats } from '../../services/mining.service'; import { MiningStats } from '../../services/mining.service';
import { EtaService } from '../../services/eta.service'; import { EtaService } from '../../services/eta.service';
import { DifficultyAdjustment, MempoolPosition, SinglePoolStats } from '../../interfaces/node-api.interface';
export type AccelerationEstimate = { export type AccelerationEstimate = {
txSummary: TxSummary; txSummary: TxSummary;
@ -19,6 +18,7 @@ export type AccelerationEstimate = {
cost: number; cost: number;
mempoolBaseFee: number; mempoolBaseFee: number;
vsizeFee: number; vsizeFee: number;
pools: number[]
} }
export type TxSummary = { export type TxSummary = {
txid: string; // txid of the current transaction txid: string; // txid of the current transaction
@ -44,7 +44,6 @@ export const MAX_BID_RATIO = 4;
}) })
export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges { export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges {
@Input() tx: Transaction; @Input() tx: Transaction;
@Input() mempoolPosition: MempoolPosition;
@Input() miningStats: MiningStats; @Input() miningStats: MiningStats;
@Input() scrollEvent: boolean; @Input() scrollEvent: boolean;
@ -54,11 +53,8 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges
estimateSubscription: Subscription; estimateSubscription: Subscription;
accelerationSubscription: Subscription; accelerationSubscription: Subscription;
difficultySubscription: Subscription; difficultySubscription: Subscription;
da: DifficultyAdjustment;
estimate: any; estimate: any;
hashratePercentage?: number; etaInfo$: Observable<{ hashratePercentage: number, ETA: number, acceleratedETA: number }>;
ETA?: number;
acceleratedETA?: number;
hasAncestors: boolean = false; hasAncestors: boolean = false;
minExtraCost = 0; minExtraCost = 0;
minBidAllowed = 0; minBidAllowed = 0;
@ -87,27 +83,19 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges
if (this.estimateSubscription) { if (this.estimateSubscription) {
this.estimateSubscription.unsubscribe(); this.estimateSubscription.unsubscribe();
} }
this.difficultySubscription.unsubscribe();
} }
ngOnInit() { ngOnInit(): void {
this.accelerationUUID = window.crypto.randomUUID(); this.accelerationUUID = window.crypto.randomUUID();
this.difficultySubscription = this.stateService.difficultyAdjustment$.subscribe(da => {
this.da = da;
this.updateETA();
})
} }
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes.scrollEvent) { if (changes.scrollEvent) {
this.scrollToPreview('acceleratePreviewAnchor', 'start'); this.scrollToPreview('acceleratePreviewAnchor', 'start');
} }
if (changes.miningStats || changes.mempoolPosition) {
this.updateETA();
}
} }
ngAfterViewInit() { ngAfterViewInit(): void {
this.user = this.storageService.getAuth()?.user ?? null; this.user = this.storageService.getAuth()?.user ?? null;
this.estimateSubscription = this.servicesApiService.estimate$(this.tx.txid).pipe( this.estimateSubscription = this.servicesApiService.estimate$(this.tx.txid).pipe(
@ -132,7 +120,7 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges
} }
} }
this.updateETA(); this.etaInfo$ = this.etaService.getProjectedEtaObservable(this.estimate, this.miningStats);
this.hasAncestors = this.estimate.txSummary.ancestorCount > 1; this.hasAncestors = this.estimate.txSummary.ancestorCount > 1;
@ -178,40 +166,10 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges
).subscribe(); ).subscribe();
} }
updateETA(): void {
if (!this.mempoolPosition || !this.estimate?.pools?.length || !this.miningStats || !this.da) {
this.hashratePercentage = undefined;
this.ETA = undefined;
this.acceleratedETA = undefined;
return;
}
const pools: { [id: number]: SinglePoolStats } = {};
for (const pool of this.miningStats.pools) {
pools[pool.poolUniqueId] = pool;
}
let totalAcceleratedHashrate = 0;
for (const poolId of this.estimate.pools) {
const pool = pools[poolId];
if (!pool) {
continue;
}
totalAcceleratedHashrate += pool.lastEstimatedHashrate;
}
const acceleratingHashrateFraction = (totalAcceleratedHashrate / this.miningStats.lastEstimatedHashrate)
this.hashratePercentage = acceleratingHashrateFraction * 100;
this.ETA = Date.now() + this.da.timeAvg * this.mempoolPosition.block;
this.acceleratedETA = this.etaService.calculateETAFromShares([
{ block: this.mempoolPosition.block, hashrateShare: (1 - acceleratingHashrateFraction) },
{ block: 0, hashrateShare: acceleratingHashrateFraction },
], this.da).time;
}
/** /**
* User changed his bid * User changed his bid
*/ */
setUserBid({ fee, index }: { fee: number, index: number}) { setUserBid({ fee, index }: { fee: number, index: number}): void {
if (this.estimate) { if (this.estimate) {
this.selectFeeRateIndex = index; this.selectFeeRateIndex = index;
this.userBid = Math.max(0, fee); this.userBid = Math.max(0, fee);
@ -222,12 +180,12 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges
/** /**
* Scroll to element id with or without setTimeout * Scroll to element id with or without setTimeout
*/ */
scrollToPreviewWithTimeout(id: string, position: ScrollLogicalPosition) { scrollToPreviewWithTimeout(id: string, position: ScrollLogicalPosition): void {
setTimeout(() => { setTimeout(() => {
this.scrollToPreview(id, position); this.scrollToPreview(id, position);
}, 100); }, 100);
} }
scrollToPreview(id: string, position: ScrollLogicalPosition) { scrollToPreview(id: string, position: ScrollLogicalPosition): void {
const acceleratePreviewAnchor = document.getElementById(id); const acceleratePreviewAnchor = document.getElementById(id);
if (acceleratePreviewAnchor) { if (acceleratePreviewAnchor) {
this.cd.markForCheck(); this.cd.markForCheck();
@ -242,7 +200,7 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges
/** /**
* Send acceleration request * Send acceleration request
*/ */
accelerate() { accelerate(): void {
if (this.accelerationSubscription) { if (this.accelerationSubscription) {
this.accelerationSubscription.unsubscribe(); this.accelerationSubscription.unsubscribe();
} }
@ -268,7 +226,7 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges
}); });
} }
isLoggedIn() { isLoggedIn(): boolean {
const auth = this.storageService.getAuth(); const auth = this.storageService.getAuth();
return auth !== null; return auth !== null;
} }
@ -280,7 +238,7 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges
@HostListener('window:scroll', ['$event']) // for window scroll events @HostListener('window:scroll', ['$event']) // for window scroll events
onScroll() { onScroll(): void {
if (this.estimate) { if (this.estimate) {
setTimeout(() => { setTimeout(() => {
this.onScroll(); this.onScroll();

View file

@ -83,7 +83,7 @@
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="box"> <div class="box">
<app-accelerate-preview [tx]="tx" [miningStats]="miningStats" [mempoolPosition]="mempoolPosition" [scrollEvent]="scrollIntoAccelPreview"></app-accelerate-preview> <app-accelerate-preview [tx]="tx" [miningStats]="miningStats" [scrollEvent]="scrollIntoAccelPreview"></app-accelerate-preview>
</div> </div>
</ng-container> </ng-container>

View file

@ -3,8 +3,10 @@ import { AccelerationPosition, CpfpInfo, DifficultyAdjustment, MempoolPosition,
import { StateService } from './state.service'; import { StateService } from './state.service';
import { MempoolBlock } from '../interfaces/websocket.interface'; import { MempoolBlock } from '../interfaces/websocket.interface';
import { Transaction } from '../interfaces/electrs.interface'; import { Transaction } from '../interfaces/electrs.interface';
import { MiningStats } from './mining.service'; import { MiningService, MiningStats } from './mining.service';
import { getUnacceleratedFeeRate } from '../shared/transaction.utils'; import { getUnacceleratedFeeRate } from '../shared/transaction.utils';
import { AccelerationEstimate } from '../components/accelerate-preview/accelerate-preview.component';
import { Observable, combineLatest, map, of } from 'rxjs';
export interface ETA { export interface ETA {
now: number, // time at which calculation performed now: number, // time at which calculation performed
@ -19,8 +21,50 @@ export interface ETA {
export class EtaService { export class EtaService {
constructor( constructor(
private stateService: StateService, private stateService: StateService,
private miningService: MiningService,
) { } ) { }
getProjectedEtaObservable(estimate: AccelerationEstimate, miningStats?: MiningStats): Observable<{ hashratePercentage: number, ETA: number, acceleratedETA: number }> {
return combineLatest([
this.stateService.mempoolTxPosition$.pipe(map(p => p.position)),
this.stateService.difficultyAdjustment$,
miningStats ? of(miningStats) : this.miningService.getMiningStats('1w'),
]).pipe(
map(([mempoolPosition, da, miningStats]) => {
if (!mempoolPosition || !estimate?.pools?.length || !miningStats || !da) {
return {
hashratePercentage: undefined,
ETA: undefined,
acceleratedETA: undefined,
};
}
const pools: { [id: number]: SinglePoolStats } = {};
for (const pool of miningStats.pools) {
pools[pool.poolUniqueId] = pool;
}
let totalAcceleratedHashrate = 0;
for (const poolId of estimate.pools) {
const pool = pools[poolId];
if (!pool) {
continue;
}
totalAcceleratedHashrate += pool.lastEstimatedHashrate;
}
const acceleratingHashrateFraction = (totalAcceleratedHashrate / miningStats.lastEstimatedHashrate);
return {
hashratePercentage: acceleratingHashrateFraction * 100,
ETA: Date.now() + da.timeAvg * mempoolPosition.block,
acceleratedETA: this.calculateETAFromShares([
{ block: mempoolPosition.block, hashrateShare: (1 - acceleratingHashrateFraction) },
{ block: 0, hashrateShare: acceleratingHashrateFraction },
], da).time,
};
})
);
}
mempoolPositionFromFees(feerate: number, mempoolBlocks: MempoolBlock[]): MempoolPosition { mempoolPositionFromFees(feerate: number, mempoolBlocks: MempoolBlock[]): MempoolPosition {
for (let txInBlockIndex = 0; txInBlockIndex < mempoolBlocks.length; txInBlockIndex++) { for (let txInBlockIndex = 0; txInBlockIndex < mempoolBlocks.length; txInBlockIndex++) {
const block = mempoolBlocks[txInBlockIndex]; const block = mempoolBlocks[txInBlockIndex];
@ -41,7 +85,7 @@ export class EtaService {
return { return {
block: txInBlockIndex, block: txInBlockIndex,
vsize: (1 - feePosition) * blockedFilledPercentage * this.stateService.blockVSize, vsize: (1 - feePosition) * blockedFilledPercentage * this.stateService.blockVSize,
} };
} }
} }
if (feerate >= block.feeRange[block.feeRange.length - 1]) { if (feerate >= block.feeRange[block.feeRange.length - 1]) {
@ -49,14 +93,14 @@ export class EtaService {
return { return {
block: txInBlockIndex, block: txInBlockIndex,
vsize: 0, vsize: 0,
} };
} }
} }
// at the very back of the last block // at the very back of the last block
return { return {
block: mempoolBlocks.length - 1, block: mempoolBlocks.length - 1,
vsize: mempoolBlocks[mempoolBlocks.length - 1].blockVSize, vsize: mempoolBlocks[mempoolBlocks.length - 1].blockVSize,
} };
} }
calculateETA( calculateETA(
@ -88,7 +132,7 @@ export class EtaService {
time: now + (60_000 * (mempoolPosition.block + 1)), time: now + (60_000 * (mempoolPosition.block + 1)),
wait: (60_000 * (mempoolPosition.block + 1)), wait: (60_000 * (mempoolPosition.block + 1)),
blocks: mempoolPosition.block + 1, blocks: mempoolPosition.block + 1,
} };
} }
// difficulty adjustment estimate is required to know avg block time on non-Liquid networks // difficulty adjustment estimate is required to know avg block time on non-Liquid networks
@ -104,7 +148,7 @@ export class EtaService {
time: wait + now + da.timeOffset, time: wait + now + da.timeOffset,
wait, wait,
blocks, blocks,
} };
} else { } else {
// accelerated transactions // accelerated transactions
@ -121,7 +165,7 @@ export class EtaService {
pools[pool.poolUniqueId] = pool; pools[pool.poolUniqueId] = pool;
} }
const unacceleratedPosition = this.mempoolPositionFromFees(getUnacceleratedFeeRate(tx, true), mempoolBlocks); const unacceleratedPosition = this.mempoolPositionFromFees(getUnacceleratedFeeRate(tx, true), mempoolBlocks);
let totalAcceleratedHashrate = accelerationPositions.reduce((total, pos) => total + (pools[pos.poolId].lastEstimatedHashrate), 0); const totalAcceleratedHashrate = accelerationPositions.reduce((total, pos) => total + (pools[pos.poolId].lastEstimatedHashrate), 0);
const shares = [ const shares = [
{ {
block: unacceleratedPosition.block, block: unacceleratedPosition.block,
@ -163,7 +207,7 @@ export class EtaService {
// find H_i // find H_i
const H = shares.reduce((total, share) => total + (share.block <= i ? share.hashrateShare : 0), 0); const H = shares.reduce((total, share) => total + (share.block <= i ? share.hashrateShare : 0), 0);
// find S_i // find S_i
let S = H * (1 - tailProb); const S = H * (1 - tailProb);
// accumulate sum (S_i x i) // accumulate sum (S_i x i)
Q += (S * (i + 1)); Q += (S * (i + 1));
// accumulate sum (S_j) // accumulate sum (S_j)
@ -178,6 +222,6 @@ export class EtaService {
time: eta + now + da.timeOffset, time: eta + now + da.timeOffset,
wait: eta, wait: eta,
blocks: Math.ceil(eta / da.adjustedTimeAvg), blocks: Math.ceil(eta / da.adjustedTimeAvg),
} };
} }
} }

View file

@ -150,7 +150,7 @@ export class StateService {
utxoSpent$ = new Subject<object>(); utxoSpent$ = new Subject<object>();
difficultyAdjustment$ = new ReplaySubject<DifficultyAdjustment>(1); difficultyAdjustment$ = new ReplaySubject<DifficultyAdjustment>(1);
mempoolTransactions$ = new Subject<Transaction>(); mempoolTransactions$ = new Subject<Transaction>();
mempoolTxPosition$ = new Subject<{ txid: string, position: MempoolPosition, cpfp: CpfpInfo | null, accelerationPositions?: AccelerationPosition[] }>(); mempoolTxPosition$ = new BehaviorSubject<{ txid: string, position: MempoolPosition, cpfp: CpfpInfo | null, accelerationPositions?: AccelerationPosition[] }>(null);
mempoolRemovedTransactions$ = new Subject<Transaction>(); mempoolRemovedTransactions$ = new Subject<Transaction>();
multiAddressTransactions$ = new Subject<{ [address: string]: { mempool: Transaction[], confirmed: Transaction[], removed: Transaction[] }}>(); multiAddressTransactions$ = new Subject<{ [address: string]: { mempool: Transaction[], confirmed: Transaction[], removed: Transaction[] }}>();
blockTransactions$ = new Subject<Transaction>(); blockTransactions$ = new Subject<Transaction>();