Merge pull request #5147 from mempool/mononaut/accelerate-preview-hashrate-pie
Acceleration preview hashrate pie chart
This commit is contained in:
		
						commit
						eedfbacf01
					
				| @ -65,10 +65,21 @@ | ||||
|         </div> | ||||
|       </div> | ||||
|       <br> | ||||
|       <h5 *ngIf="estimate?.pools?.length" i18n="accelerator.how-much-faster">How much faster?</h5> | ||||
|       <div class="row"> | ||||
|         <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> | ||||
|           <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> | ||||
|         </div> | ||||
|         <div class="col pie"> | ||||
|           <app-active-acceleration-box [miningStats]="miningStats" [pools]="estimate.pools" [chartOnly]="true"></app-active-acceleration-box> | ||||
|         </div> | ||||
|       </div> | ||||
|       <br> | ||||
|       <h5 i18n="accelerator.pay-how-much">How much more are you willing to pay?</h5> | ||||
|       <div class="row"> | ||||
|         <div class="col"> | ||||
|           <small class="form-text text-muted mb-2" i18n="accelerator.transaction-fee-description">Choose the maximum extra transaction fee you're willing to pay to get into the next block.</small> | ||||
|           <small class="form-text text-muted mb-2" i18n="accelerator.transaction-fee-description">Choose the maximum extra transaction fee you're willing to pay.</small> | ||||
|           <div class="form-group"> | ||||
|             <div class="fee-card"> | ||||
|               <div class="d-flex mb-0"> | ||||
|  | ||||
| @ -107,6 +107,11 @@ | ||||
|   margin-top: 1em; | ||||
| } | ||||
| 
 | ||||
| .col.pie { | ||||
|   flex-grow: 0; | ||||
|   padding: 0 1em; | ||||
| } | ||||
| 
 | ||||
| .item { | ||||
|   white-space: initial; | ||||
| } | ||||
|  | ||||
| @ -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 | ||||
|    */ | ||||
|  | ||||
| @ -1,3 +1,6 @@ | ||||
| @if (chartOnly) { | ||||
|   <ng-container *ngTemplateOutlet="pieChart"></ng-container> | ||||
| } @else { | ||||
| <table> | ||||
|   <tbody> | ||||
|     <tr> | ||||
| @ -12,8 +15,22 @@ | ||||
|         </div> | ||||
|       </td> | ||||
|       <td class="pie-chart" rowspan="2"> | ||||
|         <ng-container *ngTemplateOutlet="pieChart"></ng-container> | ||||
|       </td> | ||||
|     </tr> | ||||
|     <tr> | ||||
|       <td class="td-width field-label" i18n="transaction.accelerated-by-hashrate|Accelerated to hashrate">Accelerated by</td> | ||||
|       <td class="field-value" *ngIf="acceleratedByPercentage"> | ||||
|         {{ acceleratedByPercentage }} <span class="symbol hashrate-label">of hashrate</span> | ||||
|       </td> | ||||
|     </tr> | ||||
|   </tbody> | ||||
| </table> | ||||
| } | ||||
| 
 | ||||
| <ng-template #pieChart> | ||||
|   <div class="chart-container"> | ||||
|           @if (tx && (tx.acceleratedBy || accelerationInfo) && miningStats) { | ||||
|     @if (chartOptions && miningStats) { | ||||
|       <div | ||||
|         echarts | ||||
|         *browserOnly | ||||
| @ -29,13 +46,4 @@ | ||||
|       </div> | ||||
|     } | ||||
|   </div> | ||||
|       </td> | ||||
|     </tr> | ||||
|     <tr> | ||||
|       <td class="td-width field-label" i18n="transaction.accelerated-by-hashrate|Accelerated to hashrate">Accelerated by</td> | ||||
|       <td class="field-value" *ngIf="acceleratedByPercentage"> | ||||
|         {{ acceleratedByPercentage }} <span class="symbol hashrate-label">of hashrate</span> | ||||
|       </td> | ||||
|     </tr> | ||||
|   </tbody> | ||||
| </table> | ||||
| </ng-template> | ||||
| @ -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,22 +76,22 @@ 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; | ||||
|       } | ||||
|       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); | ||||
| @ -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), | ||||
|         } | ||||
|       ] | ||||
|     }; | ||||
|  | ||||
| @ -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); | ||||
|   } | ||||
|  | ||||
| @ -163,7 +163,7 @@ export class PoolRankingComponent implements OnInit { | ||||
|             const i = pool.blockCount.toString(); | ||||
|             if (this.miningWindowPreference === '24h') { | ||||
|               return `<b style="color: white">${pool.name} (${pool.share}%)</b><br>` + | ||||
|                 pool.lastEstimatedHashrate.toString() + ' ' + miningStats.miningUnits.hashrateUnit + | ||||
|                 pool.lastEstimatedHashrate.toFixed(2) + ' ' + miningStats.miningUnits.hashrateUnit + | ||||
|                 `<br>` + $localize`${ i }:INTERPOLATION: blocks`; | ||||
|             } else { | ||||
|               return `<b style="color: white">${pool.name} (${pool.share}%)</b><br>` + | ||||
| @ -291,7 +291,7 @@ export class PoolRankingComponent implements OnInit { | ||||
|    */ | ||||
|   getEmptyMiningStat(): MiningStats { | ||||
|     return { | ||||
|       lastEstimatedHashrate: 'Error', | ||||
|       lastEstimatedHashrate: 0, | ||||
|       blockCount: 0, | ||||
|       totalEmptyBlock: 0, | ||||
|       totalEmptyBlockRatio: '', | ||||
|  | ||||
| @ -54,7 +54,7 @@ | ||||
|         </div> | ||||
|       } | ||||
|       <div class="data"> | ||||
|         @if (tx && !tx.status?.confirmed && mempoolPosition?.block != null) { | ||||
|         @if (tx && !tx.status?.confirmed) { | ||||
|           <div class="field narrower mt-2"> | ||||
|             <div class="label" i18n="transaction.first-seen|Transaction first seen">First seen</div> | ||||
|             <div class="value"> | ||||
| @ -68,16 +68,21 @@ | ||||
|           <div class="field narrower"> | ||||
|             <div class="label" i18n="transaction.eta|Transaction ETA">ETA</div> | ||||
|             <div class="value"> | ||||
|               <ng-container *ngIf="(ETA$ | async) as eta; else etaSkeleton"> | ||||
|                 <span class="justify-content-end d-flex align-items-center"> | ||||
|                 @if (mempoolPosition?.block >= 7) { | ||||
|                   @if (eta.blocks >= 7) { | ||||
|                     <span i18n="transaction.eta.in-several-hours|Transaction ETA in several hours or more">In several hours (or more)</span> | ||||
|                   } @else { | ||||
|                   <app-time kind="until" *ngIf="(da$ | async) as da;" [time]="da.adjustedTimeAvg * (mempoolPosition.block + 1) + now + da.timeOffset" [fastRender]="false" [fixedRender]="true"></app-time> | ||||
|                     <app-time kind="until" [time]="eta.time" [fastRender]="false" [fixedRender]="true"></app-time> | ||||
|                   } | ||||
|                   @if (!showAccelerationSummary && isMobile && paymentType === 'cashapp' && accelerationEligible && !tx.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !tx?.acceleration) { | ||||
|                     <a class="btn btn-sm accelerate btn-small-height" i18n="transaction.accelerate|Accelerate button label" (click)="onAccelerateClicked()">Accelerate</a> | ||||
|                   } | ||||
|                 </span> | ||||
|               </ng-container> | ||||
|               <ng-template #etaSkeleton> | ||||
|                 <span class="skeleton-loader"></span> | ||||
|               </ng-template> | ||||
|             </div> | ||||
|           </div> | ||||
|         } @else if (tx && tx.status?.confirmed) { | ||||
|  | ||||
| @ -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<string>(); | ||||
|   fetchRbfHistory$ = new Subject<string>(); | ||||
|   fetchCachedTx$ = new Subject<string>(); | ||||
|   fetchAcceleration$ = new Subject<string>(); | ||||
|   fetchMiningInfo$ = new Subject<{ hash: string, height: number, txid: string }>(); | ||||
|   txChanged$ = new BehaviorSubject<boolean>(false); // triggered whenever this.tx changes (long term, we should refactor to make this.tx an observable itself)
 | ||||
|   isAccelerated$ = new BehaviorSubject<boolean>(false); // refactor this to make isAccelerated an observable itself
 | ||||
|   ETA$: Observable<ETA | null>; | ||||
|   isCached: boolean = false; | ||||
|   now = Date.now(); | ||||
|   da$: Observable<DifficultyAdjustment>; | ||||
| @ -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<any> { | ||||
| @ -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); | ||||
|  | ||||
| @ -83,7 +83,7 @@ | ||||
|       <div class="clearfix"></div> | ||||
| 
 | ||||
|       <div class="box"> | ||||
|         <app-accelerate-preview [tx]="tx" [scrollEvent]="scrollIntoAccelPreview"></app-accelerate-preview> | ||||
|         <app-accelerate-preview [tx]="tx" [miningStats]="miningStats" [mempoolPosition]="mempoolPosition" [scrollEvent]="scrollIntoAccelPreview"></app-accelerate-preview> | ||||
|       </div> | ||||
|     </ng-container> | ||||
| 
 | ||||
| @ -533,25 +533,28 @@ | ||||
|       <tr> | ||||
|         <td class="td-width align-items-center align-middle" i18n="transaction.eta|Transaction ETA">ETA</td> | ||||
|         <td> | ||||
|           @if (this.mempoolPosition?.block == null) { | ||||
|             <span class="skeleton-loader"></span> | ||||
|           } @else if (this.mempoolPosition.block >= 7) { | ||||
|             <span [class]="(acceleratorAvailable && accelerateCtaType === 'button') ? 'etaDeepMempool d-flex justify-content-end align-items-center' : ''"> | ||||
|           <ng-container *ngIf="(ETA$ | async) as eta; else etaSkeleton"> | ||||
|             @if (eta.blocks >= 7) { | ||||
|               <span [class]="(!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button') ? 'etaDeepMempool d-flex justify-content-end align-items-center' : ''"> | ||||
|                 <span i18n="transaction.eta.in-several-hours|Transaction ETA in several hours or more">In several hours (or more)</span> | ||||
|                 @if (!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button') { | ||||
|                   <a class="btn btn-sm accelerateDeepMempool btn-small-height" i18n="transaction.accelerate|Accelerate button label" (click)="onAccelerateClicked()">Accelerate</a> | ||||
|                 } | ||||
|               </span> | ||||
|             } @else if (network === 'liquid' || network === 'liquidtestnet') { | ||||
|             <app-time kind="until" [time]="(60 * 1000 * this.mempoolPosition.block) + now" [fastRender]="false" [fixedRender]="true"></app-time> | ||||
|               <app-time kind="until" [time]="eta.time" [fastRender]="false" [fixedRender]="true"></app-time> | ||||
|             } @else { | ||||
|               <span class="eta justify-content-end" [class]="(acceleratorAvailable && accelerateCtaType === 'button') ? 'd-flex align-items-center' : ''"> | ||||
|               <app-time kind="until" *ngIf="(da$ | async) as da;" [time]="da.adjustedTimeAvg * (this.mempoolPosition.block + 1) + now + da.timeOffset" [fastRender]="false" [fixedRender]="true"></app-time> | ||||
|                 <app-time kind="until" [time]="eta.time" [fastRender]="false" [fixedRender]="true"></app-time> | ||||
|                 @if (!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button') { | ||||
|                   <a class="btn btn-sm accelerate btn-small-height" i18n="transaction.accelerate|Accelerate button label" (click)="onAccelerateClicked()">Accelerate</a> | ||||
|                 } | ||||
|               </span> | ||||
|             } | ||||
|           </ng-container> | ||||
|           <ng-template #etaSkeleton> | ||||
|             <span class="skeleton-loader"></span> | ||||
|           </ng-template> | ||||
|         </td> | ||||
|       </tr> | ||||
|     } | ||||
|  | ||||
| @ -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<string>(); | ||||
|   fetchAcceleration$ = new Subject<number>(); | ||||
|   fetchMiningInfo$ = new Subject<{ hash: string, height: number, txid: string }>(); | ||||
|   txChanged$ = new BehaviorSubject<boolean>(false); // triggered whenever this.tx changes (long term, we should refactor to make this.tx an observable itself)
 | ||||
|   isAccelerated$ = new BehaviorSubject<boolean>(false); // refactor this to make isAccelerated an observable itself
 | ||||
|   ETA$: Observable<ETA | null>; | ||||
|   isCached: boolean = false; | ||||
|   now = Date.now(); | ||||
|   da$: Observable<DifficultyAdjustment>; | ||||
| @ -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 { | ||||
| @ -653,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; | ||||
| @ -715,6 +748,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 +768,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 +816,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 +839,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 +852,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 +924,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(); | ||||
|  | ||||
| @ -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 { } | ||||
|  | ||||
| @ -140,7 +140,7 @@ export interface SinglePoolStats { | ||||
|   emptyBlocks: number; | ||||
|   rank: number; | ||||
|   share: number; | ||||
|   lastEstimatedHashrate: string; | ||||
|   lastEstimatedHashrate: number; | ||||
|   emptyBlockRatio: string; | ||||
|   logo: string; | ||||
|   slug: string; | ||||
| @ -252,7 +252,7 @@ export interface MempoolPosition { | ||||
| } | ||||
| 
 | ||||
| export interface AccelerationPosition extends MempoolPosition { | ||||
|   pool: string; | ||||
|   poolId: number; | ||||
|   offset?: number; | ||||
| } | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										183
									
								
								frontend/src/app/services/eta.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								frontend/src/app/services/eta.service.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,183 @@ | ||||
| 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; | ||||
|       } | ||||
|       const pools: { [id: number]: SinglePoolStats } = {}; | ||||
|       for (const pool of miningStats.pools) { | ||||
|         pools[pool.poolUniqueId] = pool; | ||||
|       } | ||||
|       const unacceleratedPosition = this.mempoolPositionFromFees(getUnacceleratedFeeRate(tx, true), mempoolBlocks); | ||||
|       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 = 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)
 | ||||
|         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), | ||||
|       } | ||||
|   } | ||||
| } | ||||
| @ -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, | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -443,3 +443,17 @@ 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; | ||||
|   } | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user