From 833418514e10463a9f37983232010778eb0d0cbc Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 30 May 2024 21:22:53 +0000 Subject: [PATCH 1/5] Multi-pool ETA calculation --- frontend/src/app/services/eta.service.ts | 170 +++++++++++++++++++ frontend/src/app/shared/transaction.utils.ts | 14 ++ 2 files changed, 184 insertions(+) create mode 100644 frontend/src/app/services/eta.service.ts diff --git a/frontend/src/app/services/eta.service.ts b/frontend/src/app/services/eta.service.ts new file mode 100644 index 000000000..eba311012 --- /dev/null +++ b/frontend/src/app/services/eta.service.ts @@ -0,0 +1,170 @@ +import { Injectable } from '@angular/core'; +import { AccelerationPosition, CpfpInfo, DifficultyAdjustment, MempoolPosition, SinglePoolStats } from '../interfaces/node-api.interface'; +import { StateService } from './state.service'; +import { MempoolBlock } from '../interfaces/websocket.interface'; +import { Transaction } from '../interfaces/electrs.interface'; +import { MiningStats } from './mining.service'; +import { getUnacceleratedFeeRate } from '../shared/transaction.utils'; + +export interface ETA { + now: number, // time at which calculation performed + time: number, // absolute time expected (in unix epoch ms) + wait: number, // expected wait time in ms + blocks: number, // expected number of blocks (rounded up to next integer) +} + +@Injectable({ + providedIn: 'root' +}) +export class EtaService { + constructor( + private stateService: StateService, + ) { } + + mempoolPositionFromFees(feerate: number, mempoolBlocks: MempoolBlock[]): MempoolPosition { + for (let txInBlockIndex = 0; txInBlockIndex < mempoolBlocks.length; txInBlockIndex++) { + const block = mempoolBlocks[txInBlockIndex]; + for (let i = 0; i < block.feeRange.length - 1; i++) { + if (feerate < block.feeRange[i + 1] && feerate >= block.feeRange[i]) { + const feeRangeIndex = i; + const feeRangeChunkSize = 1 / (block.feeRange.length - 1); + + const txFee = feerate - block.feeRange[i]; + const max = block.feeRange[i + 1] - block.feeRange[i]; + const blockLocation = txFee / max; + + const chunkPositionOffset = blockLocation * feeRangeChunkSize; + const feePosition = feeRangeChunkSize * feeRangeIndex + chunkPositionOffset; + + const blockedFilledPercentage = (block.blockVSize > this.stateService.blockVSize ? this.stateService.blockVSize : block.blockVSize) / this.stateService.blockVSize; + + return { + block: txInBlockIndex, + vsize: (1 - feePosition) * blockedFilledPercentage * this.stateService.blockVSize, + } + } + } + if (feerate >= block.feeRange[block.feeRange.length - 1]) { + // at the very front of this block + return { + block: txInBlockIndex, + vsize: 0, + } + } + } + // at the very back of the last block + return { + block: mempoolBlocks.length - 1, + vsize: mempoolBlocks[mempoolBlocks.length - 1].blockVSize, + } + } + + calculateETA( + network: string, + tx: Transaction, + mempoolBlocks: MempoolBlock[], + position: { txid: string, position: MempoolPosition, cpfp: CpfpInfo | null, accelerationPositions?: AccelerationPosition[] }, + da: DifficultyAdjustment, + miningStats: MiningStats, + isAccelerated: boolean, + accelerationPositions: AccelerationPosition[], + ): ETA | null { + // return this.calculateETA(tx, this.accelerationPositions, position, mempoolBlocks, da, isAccelerated) + if (!tx || !mempoolBlocks) { + return null; + } + const now = Date.now(); + + // use known projected position, or fall back to feerate-based estimate + const mempoolPosition = position?.position ?? this.mempoolPositionFromFees(tx.effectiveFeePerVsize || tx.feePerVsize, mempoolBlocks); + if (!mempoolPosition) { + return null; + } + + // Liquid block time is always 60 seconds + if (network === 'liquid' || network === 'liquidtestnet') { + return { + now, + time: now + (60_000 * (mempoolPosition.block + 1)), + wait: (60_000 * (mempoolPosition.block + 1)), + blocks: mempoolPosition.block + 1, + } + } + + // difficulty adjustment estimate is required to know avg block time on non-Liquid networks + if (!da) { + return null; + } + + if (!isAccelerated) { + const blocks = mempoolPosition.block + 1; + const wait = da.adjustedTimeAvg * (mempoolPosition.block + 1); + return { + now, + time: wait + now + da.timeOffset, + wait, + blocks, + } + } else { + // accelerated transactions + + // mining stats are required for pool hashrate weightings + if (!miningStats) { + return null; + } + // acceleration positions are required + if (!accelerationPositions) { + return null; + } + + /** + * **Define parameters** + - Let $\{C_i\}$ be the set of pools. + - $P(C_i)$ is the probability that a random block belongs to pool $C_i$. + - $N(C_i)$ is the number of blocks that need to be mined before a block by pool $C_i$ contains the given transaction. + - $H(n)$ is the proportion of hashrate for which the transaction is in mempool block ≤ $n$ + - $S(n)$ is the probability of the transaction being mined in block $n$ + - by definition, $S(max) = 1$ , where $max$ is the maximum depth of the transaction in any mempool, and therefore $S(n>max) = 0$ + - $Q$ is the expected number of blocks before the transaction is confirmed + - $E$ is the expected time before the transaction is confirmed + **Overall expected confirmation time** + - $S(i) = H(i) \times (1 - \sum_{j=0}^{i-1} S(j))$ + - the probability of mining a block including the transaction at this depth, multiplied by the probability that it hasn't already been mined at an earlier depth. + - $Q = \sum_{i=0}^{max} S(i) \times (i+1)$ + - number of blocks, weighted by the probability that the block includes the transaction + - $E = Q \times T$ + - expected number of blocks, multiplied by the avg time per block + */ + const pools: { [id: number]: SinglePoolStats } = {}; + for (const pool of miningStats.pools) { + pools[pool.poolUniqueId] = pool; + } + const unacceleratedPosition = this.mempoolPositionFromFees(getUnacceleratedFeeRate(tx, true), mempoolBlocks); + const positions = [unacceleratedPosition, ...accelerationPositions]; + const max = unacceleratedPosition.block; // by definition, assuming no negative fee deltas or out of band txs + + let tailProb = 0; + let Q = 0; + for (let i = 0; i < max; i++) { + // find H_i + const H = accelerationPositions.reduce((total, pos) => total + (pos.block <= i ? pools[pos.poolId].lastEstimatedHashrate : 0), 0) / miningStats.lastEstimatedHashrate; + // find S_i + let S = H * (1 - tailProb); + // accumulate sum (S_i x i) + Q += (S * (i + 1)); + // accumulate sum (S_j) + tailProb += S; + } + // at max depth, the transaction is guaranteed to be mined in the next block if it hasn't already + Q += (1-tailProb); + const eta = da.timeAvg * Q; // T x Q + + return { + now, + time: eta + now + da.timeOffset, + wait: eta, + blocks: Math.ceil(eta / da.adjustedTimeAvg), + } + } + } +} diff --git a/frontend/src/app/shared/transaction.utils.ts b/frontend/src/app/shared/transaction.utils.ts index 5fab0ffe1..7bc986330 100644 --- a/frontend/src/app/shared/transaction.utils.ts +++ b/frontend/src/app/shared/transaction.utils.ts @@ -442,4 +442,18 @@ export function getTransactionFlags(tx: Transaction, cpfpInfo?: CpfpInfo, replac } return flags; +} + +export function getUnacceleratedFeeRate(tx: Transaction, accelerated: boolean): number { + if (accelerated) { + let ancestorVsize = tx.weight / 4; + let ancestorFee = tx.fee; + for (const ancestor of tx.ancestors || []) { + ancestorVsize += (ancestor.weight / 4); + ancestorFee += ancestor.fee; + } + return Math.min(tx.fee / (tx.weight / 4), (ancestorFee / ancestorVsize)); + } else { + return tx.effectiveFeePerVsize; + } } \ No newline at end of file From e11ce14f8122728ca6c55d4100a05b91c41314d5 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 30 May 2024 21:24:33 +0000 Subject: [PATCH 2/5] hashrate is a number not a string --- .../active-acceleration-box.component.ts | 8 ++++---- .../app/components/pool-ranking/pool-ranking.component.ts | 4 ++-- frontend/src/app/interfaces/node-api.interface.ts | 2 +- frontend/src/app/services/mining.service.ts | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.ts b/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.ts index f52c45041..309d2ed1f 100644 --- a/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.ts +++ b/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.ts @@ -78,17 +78,17 @@ export class ActiveAccelerationBox implements OnChanges { if (!pool) { continue; } - totalAcceleratedHashrate += parseFloat(pool.lastEstimatedHashrate); + totalAcceleratedHashrate += pool.lastEstimatedHashrate; } - this.acceleratedByPercentage = ((totalAcceleratedHashrate / parseFloat(this.miningStats.lastEstimatedHashrate)) * 100).toFixed(1) + '%'; + this.acceleratedByPercentage = ((totalAcceleratedHashrate / this.miningStats.lastEstimatedHashrate) * 100).toFixed(1) + '%'; data.push(getDataItem( totalAcceleratedHashrate, 'var(--mainnet-alt)', `${this.acceleratedByPercentage} accelerating`, ) as PieSeriesOption); - const notAcceleratedByPercentage = ((1 - (totalAcceleratedHashrate / parseFloat(this.miningStats.lastEstimatedHashrate))) * 100).toFixed(1) + '%'; + const notAcceleratedByPercentage = ((1 - (totalAcceleratedHashrate / this.miningStats.lastEstimatedHashrate)) * 100).toFixed(1) + '%'; data.push(getDataItem( - (parseFloat(this.miningStats.lastEstimatedHashrate) - totalAcceleratedHashrate), + (this.miningStats.lastEstimatedHashrate - totalAcceleratedHashrate), 'rgba(127, 127, 127, 0.3)', `${notAcceleratedByPercentage} not accelerating`, ) as PieSeriesOption); diff --git a/frontend/src/app/components/pool-ranking/pool-ranking.component.ts b/frontend/src/app/components/pool-ranking/pool-ranking.component.ts index 2d78252ef..2e8a820be 100644 --- a/frontend/src/app/components/pool-ranking/pool-ranking.component.ts +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.ts @@ -163,7 +163,7 @@ export class PoolRankingComponent implements OnInit { const i = pool.blockCount.toString(); if (this.miningWindowPreference === '24h') { return `${pool.name} (${pool.share}%)
` + - pool.lastEstimatedHashrate.toString() + ' ' + miningStats.miningUnits.hashrateUnit + + pool.lastEstimatedHashrate.toFixed(2) + ' ' + miningStats.miningUnits.hashrateUnit + `
` + $localize`${ i }:INTERPOLATION: blocks`; } else { return `${pool.name} (${pool.share}%)
` + @@ -291,7 +291,7 @@ export class PoolRankingComponent implements OnInit { */ getEmptyMiningStat(): MiningStats { return { - lastEstimatedHashrate: 'Error', + lastEstimatedHashrate: 0, blockCount: 0, totalEmptyBlock: 0, totalEmptyBlockRatio: '', diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index aee872968..d50d4ba00 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -140,7 +140,7 @@ export interface SinglePoolStats { emptyBlocks: number; rank: number; share: number; - lastEstimatedHashrate: string; + lastEstimatedHashrate: number; emptyBlockRatio: string; logo: string; slug: string; diff --git a/frontend/src/app/services/mining.service.ts b/frontend/src/app/services/mining.service.ts index 45d2e4ac8..7bbf6b759 100644 --- a/frontend/src/app/services/mining.service.ts +++ b/frontend/src/app/services/mining.service.ts @@ -12,7 +12,7 @@ export interface MiningUnits { } export interface MiningStats { - lastEstimatedHashrate: string; + lastEstimatedHashrate: number; blockCount: number; totalEmptyBlock: number; totalEmptyBlockRatio: string; @@ -111,7 +111,7 @@ export class MiningService { const poolsStats = stats.pools.map((poolStat) => { return { share: parseFloat((poolStat.blockCount / stats.blockCount * 100).toFixed(2)), - lastEstimatedHashrate: (poolStat.blockCount / stats.blockCount * stats.lastEstimatedHashrate / hashrateDivider).toFixed(2), + lastEstimatedHashrate: poolStat.blockCount / stats.blockCount * stats.lastEstimatedHashrate / hashrateDivider, emptyBlockRatio: (poolStat.emptyBlocks / poolStat.blockCount * 100).toFixed(2), logo: `/resources/mining-pools/` + poolStat.slug + '.svg', ...poolStat @@ -119,7 +119,7 @@ export class MiningService { }); return { - lastEstimatedHashrate: (stats.lastEstimatedHashrate / hashrateDivider).toFixed(2), + lastEstimatedHashrate: stats.lastEstimatedHashrate / hashrateDivider, blockCount: stats.blockCount, totalEmptyBlock: totalEmptyBlock, totalEmptyBlockRatio: totalEmptyBlockRatio, From f67ae10684a2c44fe1a44c83218b8d52bfd7ad52 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 30 May 2024 21:26:10 +0000 Subject: [PATCH 3/5] Integrate multi-pool ETA into transaction page --- .../mempool-blocks.component.ts | 33 ++-------- .../transaction/transaction.component.html | 39 ++++++------ .../transaction/transaction.component.ts | 63 ++++++++++++------- .../src/app/interfaces/node-api.interface.ts | 2 +- 4 files changed, 68 insertions(+), 69 deletions(-) diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts index dee770cd8..b4d698bb2 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRe import { Subscription, Observable, of, combineLatest } from 'rxjs'; import { MempoolBlock } from '../../interfaces/websocket.interface'; import { StateService } from '../../services/state.service'; +import { EtaService } from '../../services/eta.service'; import { Router } from '@angular/router'; import { delay, filter, map, switchMap, tap } from 'rxjs/operators'; import { feeLevels } from '../../app.constants'; @@ -89,6 +90,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { constructor( private router: Router, public stateService: StateService, + private etaService: EtaService, private themeService: ThemeService, private cd: ChangeDetectorRef, private relativeUrlPipe: RelativeUrlPipe, @@ -437,34 +439,9 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { this.rightPosition = positionOfBlock + positionInBlock; } } else { - let found = false; - for (let txInBlockIndex = 0; txInBlockIndex < this.mempoolBlocks.length && !found; txInBlockIndex++) { - const block = this.mempoolBlocks[txInBlockIndex]; - for (let i = 0; i < block.feeRange.length - 1 && !found; i++) { - if (this.txFeePerVSize < block.feeRange[i + 1] && this.txFeePerVSize >= block.feeRange[i]) { - const feeRangeIndex = i; - const feeRangeChunkSize = 1 / (block.feeRange.length - 1); - - const txFee = this.txFeePerVSize - block.feeRange[i]; - const max = block.feeRange[i + 1] - block.feeRange[i]; - const blockLocation = txFee / max; - - const chunkPositionOffset = blockLocation * feeRangeChunkSize; - const feePosition = feeRangeChunkSize * feeRangeIndex + chunkPositionOffset; - - const blockedFilledPercentage = (block.blockVSize > this.stateService.blockVSize ? this.stateService.blockVSize : block.blockVSize) / this.stateService.blockVSize; - const arrowRightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding) - + ((1 - feePosition) * blockedFilledPercentage * this.blockWidth); - - this.rightPosition = arrowRightPosition; - found = true; - } - } - if (this.txFeePerVSize >= block.feeRange[block.feeRange.length - 1]) { - this.rightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding); - found = true; - } - } + const estimatedPosition = this.etaService.mempoolPositionFromFees(this.txFeePerVSize, this.mempoolBlocks); + this.rightPosition = estimatedPosition.block * (this.blockWidth + this.blockPadding) + + ((estimatedPosition.vsize / this.stateService.blockVSize) * this.blockWidth) } this.rightPosition = Math.min(this.maxArrowPosition, this.rightPosition); } diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index a09e16d6b..f70bd3f0e 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -533,25 +533,28 @@ ETA - @if (this.mempoolPosition?.block == null) { + + @if (eta.blocks >= 7) { + + In several hours (or more) + @if (!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button') { + Accelerate + } + + } @else if (network === 'liquid' || network === 'liquidtestnet') { + + } @else { + + + @if (!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button') { + Accelerate + } + + } + + - } @else if (this.mempoolPosition.block >= 7) { - - In several hours (or more) - @if (!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button') { - Accelerate - } - - } @else if (network === 'liquid' || network === 'liquidtestnet') { - - } @else { - - - @if (!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button') { - Accelerate - } - - } + } diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index a066e3e7f..afbc6d62b 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -10,10 +10,11 @@ import { mergeMap, tap, map, - retry + retry, + startWith } from 'rxjs/operators'; import { Transaction } from '../../interfaces/electrs.interface'; -import { of, merge, Subscription, Observable, Subject, from, throwError } from 'rxjs'; +import { of, merge, Subscription, Observable, Subject, from, throwError, combineLatest, BehaviorSubject } from 'rxjs'; import { StateService } from '../../services/state.service'; import { CacheService } from '../../services/cache.service'; import { WebsocketService } from '../../services/websocket.service'; @@ -22,9 +23,9 @@ import { ApiService } from '../../services/api.service'; import { SeoService } from '../../services/seo.service'; import { StorageService } from '../../services/storage.service'; import { seoDescriptionNetwork } from '../../shared/common.utils'; -import { getTransactionFlags } from '../../shared/transaction.utils'; +import { getTransactionFlags, getUnacceleratedFeeRate } from '../../shared/transaction.utils'; import { Filter, toFilters } from '../../shared/filters.utils'; -import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition, DifficultyAdjustment, Acceleration, AccelerationPosition } from '../../interfaces/node-api.interface'; +import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition, DifficultyAdjustment, Acceleration, AccelerationPosition, SinglePoolStats } from '../../interfaces/node-api.interface'; import { LiquidUnblinding } from './liquid-ublinding'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { PriceService } from '../../services/price.service'; @@ -33,6 +34,7 @@ import { ServicesApiServices } from '../../services/services-api.service'; import { EnterpriseService } from '../../services/enterprise.service'; import { ZONE_SERVICE } from '../../injection-tokens'; import { MiningService, MiningStats } from '../../services/mining.service'; +import { ETA, EtaService } from '../../services/eta.service'; interface Pool { id: number; @@ -106,6 +108,9 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { fetchCachedTx$ = new Subject(); fetchAcceleration$ = new Subject(); fetchMiningInfo$ = new Subject<{ hash: string, height: number, txid: string }>(); + 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; isCached: boolean = false; now = Date.now(); da$: Observable; @@ -155,6 +160,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { private storageService: StorageService, private enterpriseService: EnterpriseService, private miningService: MiningService, + private etaService: EtaService, private cd: ChangeDetectorRef, @Inject(ZONE_SERVICE) private zoneService: any, ) {} @@ -281,6 +287,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.rbfInfo = rbfInfo; } }); + this.txChanged$.next(true); } }); @@ -365,7 +372,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { }) ).subscribe(auditStatus => { this.auditStatus = auditStatus; - this.setIsAccelerated(); }); @@ -375,7 +381,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.mempoolPosition = txPosition.position; this.accelerationPositions = txPosition.accelerationPositions; if (this.tx && !this.tx.status.confirmed) { - const txFeePerVSize = this.getUnacceleratedFeeRate(this.tx, this.tx.acceleration || this.mempoolPosition?.accelerated); + const txFeePerVSize = getUnacceleratedFeeRate(this.tx, this.tx.acceleration || this.mempoolPosition?.accelerated); this.stateService.markBlock$.next({ txid: txPosition.txid, txFeePerVSize, @@ -493,6 +499,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.adjustedVsize = Math.max(this.tx.weight / 4, this.sigops * 5); } this.tx.feePerVsize = tx.fee / (tx.weight / 4); + this.txChanged$.next(true); this.isLoadingTx = false; this.error = undefined; this.loadingCachedTx = false; @@ -519,7 +526,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { }); this.fetchCpfp$.next(this.tx.txid); } else { - const txFeePerVSize = this.getUnacceleratedFeeRate(this.tx, this.tx.acceleration || this.mempoolPosition?.accelerated); + const txFeePerVSize = getUnacceleratedFeeRate(this.tx, this.tx.acceleration || this.mempoolPosition?.accelerated); if (tx.cpfpChecked) { this.stateService.markBlock$.next({ txid: tx.txid, @@ -566,6 +573,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { block_hash: block.id, block_time: block.timestamp, }; + this.txChanged$.next(true); this.stateService.markBlock$.next({ blockHeight: block.height }); if (this.tx.acceleration || (this.accelerationInfo && ['accelerating', 'completed_provisional', 'completed'].includes(this.accelerationInfo.status))) { this.audioService.playSound('wind-chimes-harp-ascend'); @@ -637,6 +645,27 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.txInBlockIndex = 7; } }); + + this.ETA$ = combineLatest([ + this.stateService.mempoolTxPosition$.pipe(startWith(null)), + this.stateService.mempoolBlocks$.pipe(startWith(null)), + this.stateService.difficultyAdjustment$.pipe(startWith(null)), + this.isAccelerated$, + this.txChanged$, + ]).pipe( + map(([position, mempoolBlocks, da, isAccelerated]) => { + return this.etaService.calculateETA( + this.network, + this.tx, + mempoolBlocks, + position, + da, + this.miningStats, + isAccelerated, + this.accelerationPositions, + ); + }) + ) } ngAfterViewInit(): void { @@ -715,6 +744,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.tx.acceleratedBy = cpfpInfo.acceleratedBy; this.setIsAccelerated(firstCpfp); } + this.txChanged$.next(true); this.cpfpInfo = cpfpInfo; if (this.cpfpInfo.adjustedVsize && this.cpfpInfo.sigops != null) { @@ -734,8 +764,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { // this immediately returns cached stats if we fetched them recently this.miningService.getMiningStats('1w').subscribe(stats => { this.miningStats = stats; + this.isAccelerated$.next(this.isAcceleration); // hack to trigger recalculation of ETA without adding another source observable }); } + this.isAccelerated$.next(this.isAcceleration); } setFeatures(): void { @@ -780,6 +812,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.firstLoad = false; this.error = undefined; this.tx = null; + this.txChanged$.next(true); this.setFeatures(); this.waitingForTransaction = false; this.isLoadingTx = true; @@ -802,6 +835,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.accelerationPositions = null; document.body.scrollTo(0, 0); this.isAcceleration = false; + this.isAccelerated$.next(this.isAcceleration); this.leaveTransaction(); } @@ -814,20 +848,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { return +(cpfpTx.fee / (cpfpTx.weight / 4)).toFixed(1); } - getUnacceleratedFeeRate(tx: Transaction, accelerated: boolean): number { - if (accelerated) { - let ancestorVsize = tx.weight / 4; - let ancestorFee = tx.fee; - for (const ancestor of tx.ancestors || []) { - ancestorVsize += (ancestor.weight / 4); - ancestorFee += ancestor.fee; - } - return Math.min(tx.fee / (tx.weight / 4), (ancestorFee / ancestorVsize)); - } else { - return tx.effectiveFeePerVsize; - } - } - setupGraph() { this.maxInOut = Math.min(this.inOutLimit, Math.max(this.tx?.vin?.length || 1, this.tx?.vout?.length + 1 || 1)); this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80); @@ -900,7 +920,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.urlFragmentSubscription.unsubscribe(); this.mempoolBlocksSubscription.unsubscribe(); this.mempoolPositionSubscription.unsubscribe(); - this.mempoolBlocksSubscription.unsubscribe(); this.blocksSubscription.unsubscribe(); this.miningSubscription?.unsubscribe(); this.auditSubscription?.unsubscribe(); diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index d50d4ba00..c3c7c9efe 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -252,7 +252,7 @@ export interface MempoolPosition { } export interface AccelerationPosition extends MempoolPosition { - pool: string; + poolId: number; offset?: number; } From 05724b9d586f2c4dfd25caceebb688b425475b6c Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 30 May 2024 21:27:10 +0000 Subject: [PATCH 4/5] Integrate multi-pool ETA into pizza tracker --- .../components/tracker/tracker.component.html | 27 +++--- .../components/tracker/tracker.component.ts | 85 ++++++++++++++++--- 2 files changed, 89 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/components/tracker/tracker.component.html b/frontend/src/app/components/tracker/tracker.component.html index 1d1399a07..571c02f96 100644 --- a/frontend/src/app/components/tracker/tracker.component.html +++ b/frontend/src/app/components/tracker/tracker.component.html @@ -54,7 +54,7 @@ }
- @if (tx && !tx.status?.confirmed && mempoolPosition?.block != null) { + @if (tx && !tx.status?.confirmed) {
First seen
@@ -68,16 +68,21 @@
ETA
- - @if (mempoolPosition?.block >= 7) { - In several hours (or more) - } @else { - - } - @if (!showAccelerationSummary && isMobile && paymentType === 'cashapp' && accelerationEligible && !tx.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !tx?.acceleration) { - Accelerate - } - + + + @if (eta.blocks >= 7) { + In several hours (or more) + } @else { + + } + @if (!showAccelerationSummary && isMobile && paymentType === 'cashapp' && accelerationEligible && !tx.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !tx?.acceleration) { + Accelerate + } + + + + +
} @else if (tx && tx.status?.confirmed) { diff --git a/frontend/src/app/components/tracker/tracker.component.ts b/frontend/src/app/components/tracker/tracker.component.ts index 7de851c6e..62ecc9bf0 100644 --- a/frontend/src/app/components/tracker/tracker.component.ts +++ b/frontend/src/app/components/tracker/tracker.component.ts @@ -9,10 +9,11 @@ import { delay, mergeMap, tap, - map + map, + startWith } from 'rxjs/operators'; import { Transaction } from '../../interfaces/electrs.interface'; -import { of, merge, Subscription, Observable, Subject, throwError, combineLatest } from 'rxjs'; +import { of, merge, Subscription, Observable, Subject, throwError, combineLatest, BehaviorSubject } from 'rxjs'; import { StateService } from '../../services/state.service'; import { CacheService } from '../../services/cache.service'; import { WebsocketService } from '../../services/websocket.service'; @@ -21,12 +22,15 @@ import { ApiService } from '../../services/api.service'; import { SeoService } from '../../services/seo.service'; import { seoDescriptionNetwork } from '../../shared/common.utils'; import { Filter } from '../../shared/filters.utils'; -import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition, DifficultyAdjustment, Acceleration } from '../../interfaces/node-api.interface'; +import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition, DifficultyAdjustment, Acceleration, AccelerationPosition } from '../../interfaces/node-api.interface'; import { PriceService } from '../../services/price.service'; import { ServicesApiServices } from '../../services/services-api.service'; import { EnterpriseService } from '../../services/enterprise.service'; import { ZONE_SERVICE } from '../../injection-tokens'; import { TrackerStage } from './tracker-bar.component'; +import { MiningService, MiningStats } from '../../services/mining.service'; +import { ETA, EtaService } from '../../services/eta.service'; +import { getUnacceleratedFeeRate } from '../../shared/transaction.utils'; interface Pool { id: number; @@ -57,6 +61,7 @@ export class TrackerComponent implements OnInit, OnDestroy { txId: string; txInBlockIndex: number; mempoolPosition: MempoolPosition; + accelerationPositions: AccelerationPosition[]; isLoadingTx = true; error: any = undefined; loadingCachedTx = false; @@ -89,11 +94,15 @@ export class TrackerComponent implements OnInit, OnDestroy { isAcceleration: boolean = false; filters: Filter[] = []; showCpfpDetails = false; + miningStats: MiningStats; fetchCpfp$ = new Subject(); fetchRbfHistory$ = new Subject(); fetchCachedTx$ = new Subject(); fetchAcceleration$ = new Subject(); fetchMiningInfo$ = new Subject<{ hash: string, height: number, txid: string }>(); + 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; isCached: boolean = false; now = Date.now(); da$: Observable; @@ -122,6 +131,7 @@ export class TrackerComponent implements OnInit, OnDestroy { private route: ActivatedRoute, private electrsApiService: ElectrsApiService, public stateService: StateService, + private etaService: EtaService, private cacheService: CacheService, private websocketService: WebsocketService, private audioService: AudioService, @@ -130,6 +140,7 @@ export class TrackerComponent implements OnInit, OnDestroy { private seoService: SeoService, private priceService: PriceService, private enterpriseService: EnterpriseService, + private miningService: MiningService, private cd: ChangeDetectorRef, private zone: NgZone, @Inject(ZONE_SERVICE) private zoneService: any, @@ -273,6 +284,7 @@ export class TrackerComponent implements OnInit, OnDestroy { this.transactionTime = tx.firstSeen || 0; this.fetchRbfHistory$.next(this.tx.txid); + this.txChanged$.next(true); } }); @@ -354,10 +366,14 @@ export class TrackerComponent implements OnInit, OnDestroy { this.now = Date.now(); if (txPosition && txPosition.txid === this.txId && txPosition.position) { this.mempoolPosition = txPosition.position; + this.accelerationPositions = txPosition.accelerationPositions; if (this.tx && !this.tx.status.confirmed) { + const txFeePerVSize = getUnacceleratedFeeRate(this.tx, this.tx.acceleration || this.mempoolPosition?.accelerated); this.stateService.markBlock$.next({ txid: txPosition.txid, - mempoolPosition: this.mempoolPosition + txFeePerVSize, + mempoolPosition: this.mempoolPosition, + accelerationPositions: this.accelerationPositions, }); this.txInBlockIndex = this.mempoolPosition.block; @@ -372,13 +388,8 @@ export class TrackerComponent implements OnInit, OnDestroy { if (this.replaced) { this.trackerStage = 'replaced'; - } else if (txPosition.position?.block === 0) { - this.trackerStage = 'next'; - } else if (txPosition.position?.block < 3){ - this.trackerStage = 'soon'; - } else { - this.trackerStage = 'pending'; } + if (txPosition.position?.block > 0 && this.tx.weight < 4000) { this.accelerationEligible = true; if (this.acceleratorAvailable && this.paymentType === 'cashapp') { @@ -388,6 +399,7 @@ export class TrackerComponent implements OnInit, OnDestroy { } } else { this.mempoolPosition = null; + this.accelerationPositions = null; } }); @@ -453,6 +465,7 @@ export class TrackerComponent implements OnInit, OnDestroy { this.adjustedVsize = Math.max(this.tx.weight / 4, this.sigops * 5); } this.tx.feePerVsize = tx.fee / (tx.weight / 4); + this.txChanged$.next(true); this.isLoadingTx = false; this.error = undefined; this.loadingCachedTx = false; @@ -479,11 +492,13 @@ export class TrackerComponent implements OnInit, OnDestroy { }); this.fetchCpfp$.next(this.tx.txid); } else { + const txFeePerVSize = getUnacceleratedFeeRate(this.tx, this.tx.acceleration || this.mempoolPosition?.accelerated); if (tx.cpfpChecked) { this.stateService.markBlock$.next({ txid: tx.txid, - txFeePerVSize: tx.effectiveFeePerVsize, + txFeePerVSize, mempoolPosition: this.mempoolPosition, + accelerationPositions: this.accelerationPositions, }); this.setCpfpInfo({ ancestors: tx.ancestors, @@ -522,6 +537,7 @@ export class TrackerComponent implements OnInit, OnDestroy { block_hash: block.id, block_time: block.timestamp, }; + this.txChanged$.next(true); this.trackerStage = 'confirmed'; this.stateService.markBlock$.next({ blockHeight: block.height }); if (this.tx.acceleration || (this.accelerationInfo && ['accelerating', 'completed_provisional', 'completed'].includes(this.accelerationInfo.status))) { @@ -580,6 +596,38 @@ export class TrackerComponent implements OnInit, OnDestroy { this.txInBlockIndex = 7; } }); + + this.ETA$ = combineLatest([ + this.stateService.mempoolTxPosition$.pipe(startWith(null)), + this.stateService.mempoolBlocks$.pipe(startWith(null)), + this.stateService.difficultyAdjustment$.pipe(startWith(null)), + this.isAccelerated$, + this.txChanged$, + ]).pipe( + map(([position, mempoolBlocks, da, isAccelerated]) => { + return this.etaService.calculateETA( + this.network, + this.tx, + mempoolBlocks, + position, + da, + this.miningStats, + isAccelerated, + this.accelerationPositions, + ); + }), + tap(eta => { + if (this.replaced) { + this.trackerStage = 'replaced' + } else if (eta?.blocks === 0) { + this.trackerStage = 'next'; + } else if (eta?.blocks < 3){ + this.trackerStage = 'soon'; + } else { + this.trackerStage = 'pending'; + } + }) + ) } handleLoadElectrsTransactionError(error: any): Observable { @@ -610,6 +658,7 @@ export class TrackerComponent implements OnInit, OnDestroy { this.hasEffectiveFeeRate = false; return; } + const firstCpfp = this.cpfpInfo == null; // merge ancestors/descendants const relatives = [...(cpfpInfo.ancestors || []), ...(cpfpInfo.descendants || [])]; if (cpfpInfo.bestDescendant && !cpfpInfo.descendants?.length) { @@ -625,12 +674,14 @@ export class TrackerComponent implements OnInit, OnDestroy { relatives.reduce((prev, val) => prev + val.fee, 0); this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4); } else { - this.tx.effectiveFeePerVsize = cpfpInfo.effectiveFeePerVsize; + this.tx.effectiveFeePerVsize = cpfpInfo.effectiveFeePerVsize || this.tx.effectiveFeePerVsize || this.tx.feePerVsize || (this.tx.fee / (this.tx.weight / 4)); } if (cpfpInfo.acceleration) { this.tx.acceleration = cpfpInfo.acceleration; this.tx.acceleratedBy = cpfpInfo.acceleratedBy; + this.setIsAccelerated(firstCpfp); } + this.txChanged$.next(true); this.cpfpInfo = cpfpInfo; if (this.cpfpInfo.adjustedVsize && this.cpfpInfo.sigops != null) { @@ -666,6 +717,14 @@ export class TrackerComponent implements OnInit, OnDestroy { setIsAccelerated(initialState: boolean = false) { this.isAcceleration = (this.tx.acceleration || (this.accelerationInfo && this.pool && this.accelerationInfo.pools.some(pool => (pool === this.pool.id)))); + if (this.isAcceleration) { + // this immediately returns cached stats if we fetched them recently + this.miningService.getMiningStats('1w').subscribe(stats => { + this.miningStats = stats; + this.isAccelerated$.next(this.isAcceleration); // hack to trigger recalculation of ETA without adding another source observable + }); + } + this.isAccelerated$.next(this.isAcceleration); } dismissAccelAlert(): void { @@ -685,6 +744,7 @@ export class TrackerComponent implements OnInit, OnDestroy { resetTransaction() { this.error = undefined; this.tx = null; + this.txChanged$.next(true); this.waitingForTransaction = false; this.isLoadingTx = true; this.rbfTransaction = undefined; @@ -704,6 +764,7 @@ export class TrackerComponent implements OnInit, OnDestroy { this.mempoolPosition = null; this.pool = null; this.auditStatus = null; + this.accelerationPositions = null; this.accelerationEligible = false; this.trackerStage = 'waiting'; document.body.scrollTo(0, 0); From 396b7eb3d30e0126e17145f12e6fa7dea4d9ca00 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 6 Jun 2024 18:26:43 +0000 Subject: [PATCH 5/5] Add expected hashrate pie chart & eta to acceleration preview --- .../accelerate-preview.component.html | 13 +++- .../accelerate-preview.component.scss | 5 ++ .../accelerate-preview.component.ts | 53 ++++++++++++++++- .../active-acceleration-box.component.html | 44 ++++++++------ .../active-acceleration-box.component.ts | 17 +++--- .../transaction/transaction.component.html | 2 +- .../transaction/transaction.component.ts | 4 ++ .../transaction/transaction.module.ts | 4 ++ frontend/src/app/services/eta.service.ts | 59 +++++++++++-------- frontend/src/app/shared/shared.module.ts | 6 -- 10 files changed, 150 insertions(+), 57 deletions(-) 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 ae613f1a5..4c92269d8 100644 --- a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.html +++ b/frontend/src/app/components/accelerate-preview/accelerate-preview.component.html @@ -65,10 +65,21 @@

+
How much faster?
+
+
+ Your transaction will be prioritized by up to {{ hashratePercentage | number : '1.1-1' }}% of miners. + This will reduce your expected waiting time until the first confirmation to +
+
+ +
+
+
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. + Choose the maximum extra transaction fee you're willing to pay.
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 fa598f3a3..1191d882e 100644 --- a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.scss +++ b/frontend/src/app/components/accelerate-preview/accelerate-preview.component.scss @@ -107,6 +107,11 @@ margin-top: 1em; } +.col.pie { + flex-grow: 0; + padding: 0 1em; +} + .item { white-space: initial; } 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 76833bb1a..6d4c88a00 100644 --- a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.ts +++ b/frontend/src/app/components/accelerate-preview/accelerate-preview.component.ts @@ -6,6 +6,9 @@ import { nextRoundNumber } from '../../shared/common.utils'; import { ServicesApiServices } from '../../services/services-api.service'; import { AudioService } from '../../services/audio.service'; import { StateService } from '../../services/state.service'; +import { MiningStats } from '../../services/mining.service'; +import { EtaService } from '../../services/eta.service'; +import { DifficultyAdjustment, MempoolPosition, SinglePoolStats } from '../../interfaces/node-api.interface'; export type AccelerationEstimate = { txSummary: TxSummary; @@ -40,7 +43,9 @@ export const MAX_BID_RATIO = 4; styleUrls: ['accelerate-preview.component.scss'] }) export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges { - @Input() tx: Transaction | undefined; + @Input() tx: Transaction; + @Input() mempoolPosition: MempoolPosition; + @Input() miningStats: MiningStats; @Input() scrollEvent: boolean; math = Math; @@ -48,7 +53,12 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges showSuccess = false; estimateSubscription: Subscription; accelerationSubscription: Subscription; + difficultySubscription: Subscription; + da: DifficultyAdjustment; estimate: any; + hashratePercentage?: number; + ETA?: number; + acceleratedETA?: number; hasAncestors: boolean = false; minExtraCost = 0; minBidAllowed = 0; @@ -67,6 +77,7 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges public stateService: StateService, private servicesApiService: ServicesApiServices, private storageService: StorageService, + private etaService: EtaService, private audioService: AudioService, private cd: ChangeDetectorRef ) { @@ -76,16 +87,24 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges if (this.estimateSubscription) { this.estimateSubscription.unsubscribe(); } + this.difficultySubscription.unsubscribe(); } ngOnInit() { this.accelerationUUID = window.crypto.randomUUID(); + this.difficultySubscription = this.stateService.difficultyAdjustment$.subscribe(da => { + this.da = da; + this.updateETA(); + }) } ngOnChanges(changes: SimpleChanges): void { if (changes.scrollEvent) { this.scrollToPreview('acceleratePreviewAnchor', 'start'); } + if (changes.miningStats || changes.mempoolPosition) { + this.updateETA(); + } } ngAfterViewInit() { @@ -113,6 +132,8 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges } } + this.updateETA(); + this.hasAncestors = this.estimate.txSummary.ancestorCount > 1; // Make min extra fee at least 50% of the current tx fee @@ -157,6 +178,36 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges ).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 */ diff --git a/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.html b/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.html index d009a5e63..711269a47 100644 --- a/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.html +++ b/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.html @@ -1,3 +1,6 @@ +@if (chartOnly) { + +} @else { @@ -12,23 +15,7 @@ @@ -38,4 +25,25 @@ -
-
- @if (tx && (tx.acceleratedBy || accelerationInfo) && miningStats) { -
- } @else { -
-
-
- } -
+
\ No newline at end of file + +} + + +
+ @if (chartOptions && miningStats) { +
+ } @else { +
+
+
+ } +
+
\ No newline at end of file diff --git a/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.ts b/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.ts index 309d2ed1f..2d94cad50 100644 --- a/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.ts +++ b/frontend/src/app/components/acceleration/active-acceleration-box/active-acceleration-box.component.ts @@ -15,10 +15,12 @@ export class ActiveAccelerationBox implements OnChanges { @Input() tx: Transaction; @Input() accelerationInfo: Acceleration; @Input() miningStats: MiningStats; + @Input() pools: number[]; + @Input() chartOnly: boolean = false; acceleratedByPercentage: string = ''; - chartOptions: EChartsOption = {}; + chartOptions: EChartsOption; chartInitOptions = { renderer: 'svg', }; @@ -28,12 +30,13 @@ export class ActiveAccelerationBox implements OnChanges { constructor() {} ngOnChanges(changes: SimpleChanges): void { - if (this.tx && (this.tx.acceleratedBy || this.accelerationInfo) && this.miningStats) { - this.prepareChartOptions(); + const pools = this.pools || this.accelerationInfo?.pools || this.tx.acceleratedBy; + if (pools && this.miningStats) { + this.prepareChartOptions(pools); } } - getChartData() { + getChartData(poolList: number[]) { const data: object[] = []; const pools: { [id: number]: SinglePoolStats } = {}; for (const pool of this.miningStats.pools) { @@ -73,7 +76,7 @@ export class ActiveAccelerationBox implements OnChanges { }); let totalAcceleratedHashrate = 0; - for (const poolId of (this.accelerationInfo?.pools || this.tx.acceleratedBy || [])) { + for (const poolId of poolList || []) { const pool = pools[poolId]; if (!pool) { continue; @@ -96,7 +99,7 @@ export class ActiveAccelerationBox implements OnChanges { return data; } - prepareChartOptions() { + prepareChartOptions(pools: number[]) { this.chartOptions = { animation: false, grid: { @@ -113,7 +116,7 @@ export class ActiveAccelerationBox implements OnChanges { { type: 'pie', radius: '100%', - data: this.getChartData(), + data: this.getChartData(pools), } ] }; diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index f70bd3f0e..2a2925879 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -83,7 +83,7 @@
- +
diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index afbc6d62b..37c83f008 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -682,6 +682,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { return; } + this.miningService.getMiningStats('1w').subscribe(stats => { + this.miningStats = stats; + }); + document.location.hash = '#accelerate'; this.enterpriseService.goal(8); this.showAccelerationSummary = true && this.acceleratorAvailable; diff --git a/frontend/src/app/components/transaction/transaction.module.ts b/frontend/src/app/components/transaction/transaction.module.ts index a1331a463..eb663c9ac 100644 --- a/frontend/src/app/components/transaction/transaction.module.ts +++ b/frontend/src/app/components/transaction/transaction.module.ts @@ -5,6 +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 { AccelerateFeeGraphComponent } from '../accelerate-preview/accelerate-fee-graph.component'; const routes: Routes = [ { @@ -36,6 +38,8 @@ export class TransactionRoutingModule { } ], declarations: [ TransactionComponent, + AcceleratePreviewComponent, + AccelerateFeeGraphComponent, ] }) export class TransactionModule { } diff --git a/frontend/src/app/services/eta.service.ts b/frontend/src/app/services/eta.service.ts index eba311012..467f49554 100644 --- a/frontend/src/app/services/eta.service.ts +++ b/frontend/src/app/services/eta.service.ts @@ -116,38 +116,52 @@ export class EtaService { if (!accelerationPositions) { return null; } - - /** - * **Define parameters** - - Let $\{C_i\}$ be the set of pools. - - $P(C_i)$ is the probability that a random block belongs to pool $C_i$. - - $N(C_i)$ is the number of blocks that need to be mined before a block by pool $C_i$ contains the given transaction. - - $H(n)$ is the proportion of hashrate for which the transaction is in mempool block ≤ $n$ - - $S(n)$ is the probability of the transaction being mined in block $n$ - - by definition, $S(max) = 1$ , where $max$ is the maximum depth of the transaction in any mempool, and therefore $S(n>max) = 0$ - - $Q$ is the expected number of blocks before the transaction is confirmed - - $E$ is the expected time before the transaction is confirmed - **Overall expected confirmation time** - - $S(i) = H(i) \times (1 - \sum_{j=0}^{i-1} S(j))$ - - the probability of mining a block including the transaction at this depth, multiplied by the probability that it hasn't already been mined at an earlier depth. - - $Q = \sum_{i=0}^{max} S(i) \times (i+1)$ - - number of blocks, weighted by the probability that the block includes the transaction - - $E = Q \times T$ - - expected number of blocks, multiplied by the avg time per block - */ const pools: { [id: number]: SinglePoolStats } = {}; for (const pool of miningStats.pools) { pools[pool.poolUniqueId] = pool; } const unacceleratedPosition = this.mempoolPositionFromFees(getUnacceleratedFeeRate(tx, true), mempoolBlocks); - const positions = [unacceleratedPosition, ...accelerationPositions]; - const max = unacceleratedPosition.block; // by definition, assuming no negative fee deltas or out of band txs + let totalAcceleratedHashrate = accelerationPositions.reduce((total, pos) => total + (pools[pos.poolId].lastEstimatedHashrate), 0); + const shares = [ + { + block: unacceleratedPosition.block, + hashrateShare: (1 - (totalAcceleratedHashrate / miningStats.lastEstimatedHashrate)), + }, + ...accelerationPositions.map(pos => ({ + block: pos.block, + hashrateShare: ((pools[pos.poolId].lastEstimatedHashrate) / miningStats.lastEstimatedHashrate) + })) + ]; + return this.calculateETAFromShares(shares, da); + } + } + + /** + * + - Let $\{C_i\}$ be the set of pools. + - $P(C_i)$ is the probability that a random block belongs to pool $C_i$. + - $N(C_i)$ is the number of blocks that need to be mined before a block by pool $C_i$ contains the given transaction. + - $H(n)$ is the proportion of hashrate for which the transaction is in mempool block ≤ $n$ + - $S(n)$ is the probability of the transaction being mined in block $n$ + - by definition, $S(max) = 1$ , where $max$ is the maximum depth of the transaction in any mempool, and therefore $S(n>max) = 0$ + - $Q$ is the expected number of blocks before the transaction is confirmed + - $E$ is the expected time before the transaction is confirmed + + - $S(i) = H(i) \times (1 - \sum_{j=0}^{i-1} S(j))$ + - the probability of mining a block including the transaction at this depth, multiplied by the probability that it hasn't already been mined at an earlier depth. + - $Q = \sum_{i=0}^{max} S(i) \times (i+1)$ + - number of blocks, weighted by the probability that the block includes the transaction + - $E = Q \times T$ + - expected number of blocks, multiplied by the avg time per block + */ + calculateETAFromShares(shares: { block: number, hashrateShare: number }[], da: DifficultyAdjustment, now: number = Date.now()): ETA { + const max = shares.reduce((max, share) => Math.max(max, share.block), 0); let tailProb = 0; let Q = 0; for (let i = 0; i < max; i++) { // find H_i - const H = accelerationPositions.reduce((total, pos) => total + (pos.block <= i ? pools[pos.poolId].lastEstimatedHashrate : 0), 0) / miningStats.lastEstimatedHashrate; + const H = shares.reduce((total, share) => total + (share.block <= i ? share.hashrateShare : 0), 0); // find S_i let S = H * (1 - tailProb); // accumulate sum (S_i x i) @@ -165,6 +179,5 @@ export class EtaService { wait: eta, blocks: Math.ceil(eta / da.adjustedTimeAvg), } - } } } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index ead9060ae..2f7bd4dc4 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -96,8 +96,6 @@ import { ToggleComponent } from './components/toggle/toggle.component'; import { GeolocationComponent } from '../shared/components/geolocation/geolocation.component'; 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 { AccelerationsListComponent } from '../components/acceleration/accelerations-list/accelerations-list.component'; import { PendingStatsComponent } from '../components/acceleration/pending-stats/pending-stats.component'; @@ -212,8 +210,6 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir GeolocationComponent, TestnetAlertComponent, GlobalFooterComponent, - AcceleratePreviewComponent, - AccelerateFeeGraphComponent, CalculatorComponent, BitcoinsatoshisPipe, BlockViewComponent, @@ -355,8 +351,6 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir TestnetAlertComponent, PreviewTitleComponent, GlobalFooterComponent, - AcceleratePreviewComponent, - AccelerateFeeGraphComponent, MempoolErrorComponent, AccelerationsListComponent, AccelerationStatsComponent,