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> | ||||||
|       </div> |       </div> | ||||||
|       <br> |       <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> |       <h5 i18n="accelerator.pay-how-much">How much more are you willing to pay?</h5> | ||||||
|       <div class="row"> |       <div class="row"> | ||||||
|         <div class="col"> |         <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="form-group"> | ||||||
|             <div class="fee-card"> |             <div class="fee-card"> | ||||||
|               <div class="d-flex mb-0"> |               <div class="d-flex mb-0"> | ||||||
|  | |||||||
| @ -107,6 +107,11 @@ | |||||||
|   margin-top: 1em; |   margin-top: 1em; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .col.pie { | ||||||
|  |   flex-grow: 0; | ||||||
|  |   padding: 0 1em; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .item { | .item { | ||||||
|   white-space: initial; |   white-space: initial; | ||||||
| } | } | ||||||
|  | |||||||
| @ -6,6 +6,9 @@ import { nextRoundNumber } from '../../shared/common.utils'; | |||||||
| import { ServicesApiServices } from '../../services/services-api.service'; | import { ServicesApiServices } from '../../services/services-api.service'; | ||||||
| import { AudioService } from '../../services/audio.service'; | import { AudioService } from '../../services/audio.service'; | ||||||
| import { StateService } from '../../services/state.service'; | import { StateService } from '../../services/state.service'; | ||||||
|  | import { MiningStats } from '../../services/mining.service'; | ||||||
|  | import { EtaService } from '../../services/eta.service'; | ||||||
|  | import { DifficultyAdjustment, MempoolPosition, SinglePoolStats } from '../../interfaces/node-api.interface'; | ||||||
| 
 | 
 | ||||||
| export type AccelerationEstimate = { | export type AccelerationEstimate = { | ||||||
|   txSummary: TxSummary; |   txSummary: TxSummary; | ||||||
| @ -40,7 +43,9 @@ export const MAX_BID_RATIO = 4; | |||||||
|   styleUrls: ['accelerate-preview.component.scss'] |   styleUrls: ['accelerate-preview.component.scss'] | ||||||
| }) | }) | ||||||
| export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges { | export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges { | ||||||
|   @Input() tx: Transaction | undefined; |   @Input() tx: Transaction; | ||||||
|  |   @Input() mempoolPosition: MempoolPosition; | ||||||
|  |   @Input() miningStats: MiningStats; | ||||||
|   @Input() scrollEvent: boolean; |   @Input() scrollEvent: boolean; | ||||||
| 
 | 
 | ||||||
|   math = Math; |   math = Math; | ||||||
| @ -48,7 +53,12 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges | |||||||
|   showSuccess = false; |   showSuccess = false; | ||||||
|   estimateSubscription: Subscription; |   estimateSubscription: Subscription; | ||||||
|   accelerationSubscription: Subscription; |   accelerationSubscription: Subscription; | ||||||
|  |   difficultySubscription: Subscription; | ||||||
|  |   da: DifficultyAdjustment; | ||||||
|   estimate: any; |   estimate: any; | ||||||
|  |   hashratePercentage?: number; | ||||||
|  |   ETA?: number; | ||||||
|  |   acceleratedETA?: number; | ||||||
|   hasAncestors: boolean = false; |   hasAncestors: boolean = false; | ||||||
|   minExtraCost = 0; |   minExtraCost = 0; | ||||||
|   minBidAllowed = 0; |   minBidAllowed = 0; | ||||||
| @ -67,6 +77,7 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges | |||||||
|     public stateService: StateService, |     public stateService: StateService, | ||||||
|     private servicesApiService: ServicesApiServices, |     private servicesApiService: ServicesApiServices, | ||||||
|     private storageService: StorageService, |     private storageService: StorageService, | ||||||
|  |     private etaService: EtaService, | ||||||
|     private audioService: AudioService, |     private audioService: AudioService, | ||||||
|     private cd: ChangeDetectorRef |     private cd: ChangeDetectorRef | ||||||
|   ) { |   ) { | ||||||
| @ -76,16 +87,24 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges | |||||||
|     if (this.estimateSubscription) { |     if (this.estimateSubscription) { | ||||||
|       this.estimateSubscription.unsubscribe(); |       this.estimateSubscription.unsubscribe(); | ||||||
|     } |     } | ||||||
|  |     this.difficultySubscription.unsubscribe(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   ngOnInit() { |   ngOnInit() { | ||||||
|     this.accelerationUUID = window.crypto.randomUUID(); |     this.accelerationUUID = window.crypto.randomUUID(); | ||||||
|  |     this.difficultySubscription = this.stateService.difficultyAdjustment$.subscribe(da => { | ||||||
|  |       this.da = da; | ||||||
|  |       this.updateETA(); | ||||||
|  |     }) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   ngOnChanges(changes: SimpleChanges): void { |   ngOnChanges(changes: SimpleChanges): void { | ||||||
|     if (changes.scrollEvent) { |     if (changes.scrollEvent) { | ||||||
|       this.scrollToPreview('acceleratePreviewAnchor', 'start'); |       this.scrollToPreview('acceleratePreviewAnchor', 'start'); | ||||||
|     } |     } | ||||||
|  |     if (changes.miningStats || changes.mempoolPosition) { | ||||||
|  |       this.updateETA(); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   ngAfterViewInit() { |   ngAfterViewInit() { | ||||||
| @ -113,6 +132,8 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges | |||||||
|             } |             } | ||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|  |           this.updateETA(); | ||||||
|  | 
 | ||||||
|           this.hasAncestors = this.estimate.txSummary.ancestorCount > 1; |           this.hasAncestors = this.estimate.txSummary.ancestorCount > 1; | ||||||
|            |            | ||||||
|           // Make min extra fee at least 50% of the current tx fee
 |           // Make min extra fee at least 50% of the current tx fee
 | ||||||
| @ -157,6 +178,36 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges | |||||||
|     ).subscribe(); |     ).subscribe(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   updateETA(): void { | ||||||
|  |     if (!this.mempoolPosition || !this.estimate?.pools?.length || !this.miningStats || !this.da) { | ||||||
|  |       this.hashratePercentage = undefined; | ||||||
|  |       this.ETA = undefined; | ||||||
|  |       this.acceleratedETA = undefined; | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     const pools: { [id: number]: SinglePoolStats } = {}; | ||||||
|  |     for (const pool of this.miningStats.pools) { | ||||||
|  |       pools[pool.poolUniqueId] = pool; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     let totalAcceleratedHashrate = 0; | ||||||
|  |     for (const poolId of this.estimate.pools) { | ||||||
|  |       const pool = pools[poolId]; | ||||||
|  |       if (!pool) { | ||||||
|  |         continue; | ||||||
|  |       } | ||||||
|  |       totalAcceleratedHashrate += pool.lastEstimatedHashrate; | ||||||
|  |     } | ||||||
|  |     const acceleratingHashrateFraction = (totalAcceleratedHashrate / this.miningStats.lastEstimatedHashrate) | ||||||
|  |     this.hashratePercentage = acceleratingHashrateFraction * 100; | ||||||
|  | 
 | ||||||
|  |     this.ETA = Date.now() + this.da.timeAvg * this.mempoolPosition.block; | ||||||
|  |     this.acceleratedETA = this.etaService.calculateETAFromShares([ | ||||||
|  |       { block: this.mempoolPosition.block, hashrateShare: (1 - acceleratingHashrateFraction) },  | ||||||
|  |       { block: 0, hashrateShare: acceleratingHashrateFraction }, | ||||||
|  |     ], this.da).time; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   /** |   /** | ||||||
|    * User changed his bid |    * User changed his bid | ||||||
|    */ |    */ | ||||||
|  | |||||||
| @ -1,3 +1,6 @@ | |||||||
|  | @if (chartOnly) { | ||||||
|  |   <ng-container *ngTemplateOutlet="pieChart"></ng-container> | ||||||
|  | } @else { | ||||||
| <table> | <table> | ||||||
|   <tbody> |   <tbody> | ||||||
|     <tr> |     <tr> | ||||||
| @ -12,23 +15,7 @@ | |||||||
|         </div> |         </div> | ||||||
|       </td> |       </td> | ||||||
|       <td class="pie-chart" rowspan="2"> |       <td class="pie-chart" rowspan="2"> | ||||||
|         <div class="chart-container"> |         <ng-container *ngTemplateOutlet="pieChart"></ng-container> | ||||||
|           @if (tx && (tx.acceleratedBy || accelerationInfo) && miningStats) { |  | ||||||
|             <div |  | ||||||
|               echarts |  | ||||||
|               *browserOnly |  | ||||||
|               class="chart" |  | ||||||
|               [initOpts]="chartInitOptions" |  | ||||||
|               [options]="chartOptions" |  | ||||||
|               style="height: 72px; width: 72px;" |  | ||||||
|               (chartInit)="onChartInit($event)" |  | ||||||
|             ></div> |  | ||||||
|           } @else { |  | ||||||
|             <div class="chart-loading"> |  | ||||||
|               <div class="spinner-border text-light"></div> |  | ||||||
|             </div> |  | ||||||
|           } |  | ||||||
|         </div> |  | ||||||
|       </td> |       </td> | ||||||
|     </tr> |     </tr> | ||||||
|     <tr> |     <tr> | ||||||
| @ -39,3 +26,24 @@ | |||||||
|     </tr> |     </tr> | ||||||
|   </tbody> |   </tbody> | ||||||
| </table> | </table> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | <ng-template #pieChart> | ||||||
|  |   <div class="chart-container"> | ||||||
|  |     @if (chartOptions && miningStats) { | ||||||
|  |       <div | ||||||
|  |         echarts | ||||||
|  |         *browserOnly | ||||||
|  |         class="chart" | ||||||
|  |         [initOpts]="chartInitOptions" | ||||||
|  |         [options]="chartOptions" | ||||||
|  |         style="height: 72px; width: 72px;" | ||||||
|  |         (chartInit)="onChartInit($event)" | ||||||
|  |       ></div> | ||||||
|  |     } @else { | ||||||
|  |       <div class="chart-loading"> | ||||||
|  |         <div class="spinner-border text-light"></div> | ||||||
|  |       </div> | ||||||
|  |     } | ||||||
|  |   </div> | ||||||
|  | </ng-template> | ||||||
| @ -15,10 +15,12 @@ export class ActiveAccelerationBox implements OnChanges { | |||||||
|   @Input() tx: Transaction; |   @Input() tx: Transaction; | ||||||
|   @Input() accelerationInfo: Acceleration; |   @Input() accelerationInfo: Acceleration; | ||||||
|   @Input() miningStats: MiningStats; |   @Input() miningStats: MiningStats; | ||||||
|  |   @Input() pools: number[]; | ||||||
|  |   @Input() chartOnly: boolean = false; | ||||||
| 
 | 
 | ||||||
|   acceleratedByPercentage: string = ''; |   acceleratedByPercentage: string = ''; | ||||||
| 
 | 
 | ||||||
|   chartOptions: EChartsOption = {}; |   chartOptions: EChartsOption; | ||||||
|   chartInitOptions = { |   chartInitOptions = { | ||||||
|     renderer: 'svg', |     renderer: 'svg', | ||||||
|   }; |   }; | ||||||
| @ -28,12 +30,13 @@ export class ActiveAccelerationBox implements OnChanges { | |||||||
|   constructor() {} |   constructor() {} | ||||||
| 
 | 
 | ||||||
|   ngOnChanges(changes: SimpleChanges): void { |   ngOnChanges(changes: SimpleChanges): void { | ||||||
|     if (this.tx && (this.tx.acceleratedBy || this.accelerationInfo) && this.miningStats) { |     const pools = this.pools || this.accelerationInfo?.pools || this.tx.acceleratedBy; | ||||||
|       this.prepareChartOptions(); |     if (pools && this.miningStats) { | ||||||
|  |       this.prepareChartOptions(pools); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   getChartData() { |   getChartData(poolList: number[]) { | ||||||
|     const data: object[] = []; |     const data: object[] = []; | ||||||
|     const pools: { [id: number]: SinglePoolStats } = {}; |     const pools: { [id: number]: SinglePoolStats } = {}; | ||||||
|     for (const pool of this.miningStats.pools) { |     for (const pool of this.miningStats.pools) { | ||||||
| @ -73,22 +76,22 @@ export class ActiveAccelerationBox implements OnChanges { | |||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     let totalAcceleratedHashrate = 0; |     let totalAcceleratedHashrate = 0; | ||||||
|     for (const poolId of (this.accelerationInfo?.pools || this.tx.acceleratedBy || [])) { |     for (const poolId of poolList || []) { | ||||||
|       const pool = pools[poolId]; |       const pool = pools[poolId]; | ||||||
|       if (!pool) { |       if (!pool) { | ||||||
|         continue; |         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( |     data.push(getDataItem( | ||||||
|       totalAcceleratedHashrate, |       totalAcceleratedHashrate, | ||||||
|       'var(--mainnet-alt)', |       'var(--mainnet-alt)', | ||||||
|       `${this.acceleratedByPercentage} accelerating`, |       `${this.acceleratedByPercentage} accelerating`, | ||||||
|     ) as PieSeriesOption); |     ) 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( |     data.push(getDataItem( | ||||||
|       (parseFloat(this.miningStats.lastEstimatedHashrate) - totalAcceleratedHashrate), |       (this.miningStats.lastEstimatedHashrate - totalAcceleratedHashrate), | ||||||
|       'rgba(127, 127, 127, 0.3)', |       'rgba(127, 127, 127, 0.3)', | ||||||
|       `${notAcceleratedByPercentage} not accelerating`, |       `${notAcceleratedByPercentage} not accelerating`, | ||||||
|     ) as PieSeriesOption); |     ) as PieSeriesOption); | ||||||
| @ -96,7 +99,7 @@ export class ActiveAccelerationBox implements OnChanges { | |||||||
|     return data; |     return data; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   prepareChartOptions() { |   prepareChartOptions(pools: number[]) { | ||||||
|     this.chartOptions = { |     this.chartOptions = { | ||||||
|       animation: false, |       animation: false, | ||||||
|       grid: { |       grid: { | ||||||
| @ -113,7 +116,7 @@ export class ActiveAccelerationBox implements OnChanges { | |||||||
|         { |         { | ||||||
|           type: 'pie', |           type: 'pie', | ||||||
|           radius: '100%', |           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 { Subscription, Observable, of, combineLatest } from 'rxjs'; | ||||||
| import { MempoolBlock } from '../../interfaces/websocket.interface'; | import { MempoolBlock } from '../../interfaces/websocket.interface'; | ||||||
| import { StateService } from '../../services/state.service'; | import { StateService } from '../../services/state.service'; | ||||||
|  | import { EtaService } from '../../services/eta.service'; | ||||||
| import { Router } from '@angular/router'; | import { Router } from '@angular/router'; | ||||||
| import { delay, filter, map, switchMap, tap } from 'rxjs/operators'; | import { delay, filter, map, switchMap, tap } from 'rxjs/operators'; | ||||||
| import { feeLevels } from '../../app.constants'; | import { feeLevels } from '../../app.constants'; | ||||||
| @ -89,6 +90,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|   constructor( |   constructor( | ||||||
|     private router: Router, |     private router: Router, | ||||||
|     public stateService: StateService, |     public stateService: StateService, | ||||||
|  |     private etaService: EtaService, | ||||||
|     private themeService: ThemeService, |     private themeService: ThemeService, | ||||||
|     private cd: ChangeDetectorRef, |     private cd: ChangeDetectorRef, | ||||||
|     private relativeUrlPipe: RelativeUrlPipe, |     private relativeUrlPipe: RelativeUrlPipe, | ||||||
| @ -437,34 +439,9 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|         this.rightPosition = positionOfBlock + positionInBlock; |         this.rightPosition = positionOfBlock + positionInBlock; | ||||||
|       } |       } | ||||||
|     } else { |     } else { | ||||||
|       let found = false; |       const estimatedPosition = this.etaService.mempoolPositionFromFees(this.txFeePerVSize, this.mempoolBlocks); | ||||||
|       for (let txInBlockIndex = 0; txInBlockIndex < this.mempoolBlocks.length && !found; txInBlockIndex++) { |       this.rightPosition = estimatedPosition.block * (this.blockWidth + this.blockPadding) | ||||||
|         const block = this.mempoolBlocks[txInBlockIndex]; |         + ((estimatedPosition.vsize / this.stateService.blockVSize) * this.blockWidth) | ||||||
|         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; |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
|     this.rightPosition = Math.min(this.maxArrowPosition, this.rightPosition); |     this.rightPosition = Math.min(this.maxArrowPosition, this.rightPosition); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -163,7 +163,7 @@ export class PoolRankingComponent implements OnInit { | |||||||
|             const i = pool.blockCount.toString(); |             const i = pool.blockCount.toString(); | ||||||
|             if (this.miningWindowPreference === '24h') { |             if (this.miningWindowPreference === '24h') { | ||||||
|               return `<b style="color: white">${pool.name} (${pool.share}%)</b><br>` + |               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`; |                 `<br>` + $localize`${ i }:INTERPOLATION: blocks`; | ||||||
|             } else { |             } else { | ||||||
|               return `<b style="color: white">${pool.name} (${pool.share}%)</b><br>` + |               return `<b style="color: white">${pool.name} (${pool.share}%)</b><br>` + | ||||||
| @ -291,7 +291,7 @@ export class PoolRankingComponent implements OnInit { | |||||||
|    */ |    */ | ||||||
|   getEmptyMiningStat(): MiningStats { |   getEmptyMiningStat(): MiningStats { | ||||||
|     return { |     return { | ||||||
|       lastEstimatedHashrate: 'Error', |       lastEstimatedHashrate: 0, | ||||||
|       blockCount: 0, |       blockCount: 0, | ||||||
|       totalEmptyBlock: 0, |       totalEmptyBlock: 0, | ||||||
|       totalEmptyBlockRatio: '', |       totalEmptyBlockRatio: '', | ||||||
|  | |||||||
| @ -54,7 +54,7 @@ | |||||||
|         </div> |         </div> | ||||||
|       } |       } | ||||||
|       <div class="data"> |       <div class="data"> | ||||||
|         @if (tx && !tx.status?.confirmed && mempoolPosition?.block != null) { |         @if (tx && !tx.status?.confirmed) { | ||||||
|           <div class="field narrower mt-2"> |           <div class="field narrower mt-2"> | ||||||
|             <div class="label" i18n="transaction.first-seen|Transaction first seen">First seen</div> |             <div class="label" i18n="transaction.first-seen|Transaction first seen">First seen</div> | ||||||
|             <div class="value"> |             <div class="value"> | ||||||
| @ -68,16 +68,21 @@ | |||||||
|           <div class="field narrower"> |           <div class="field narrower"> | ||||||
|             <div class="label" i18n="transaction.eta|Transaction ETA">ETA</div> |             <div class="label" i18n="transaction.eta|Transaction ETA">ETA</div> | ||||||
|             <div class="value"> |             <div class="value"> | ||||||
|               <span class="justify-content-end d-flex align-items-center"> |               <ng-container *ngIf="(ETA$ | async) as eta; else etaSkeleton"> | ||||||
|                 @if (mempoolPosition?.block >= 7) { |                 <span class="justify-content-end d-flex align-items-center"> | ||||||
|                   <span i18n="transaction.eta.in-several-hours|Transaction ETA in several hours or more">In several hours (or more)</span> |                   @if (eta.blocks >= 7) { | ||||||
|                 } @else { |                     <span i18n="transaction.eta.in-several-hours|Transaction ETA in several hours or more">In several hours (or more)</span> | ||||||
|                   <app-time kind="until" *ngIf="(da$ | async) as da;" [time]="da.adjustedTimeAvg * (mempoolPosition.block + 1) + now + da.timeOffset" [fastRender]="false" [fixedRender]="true"></app-time> |                   } @else { | ||||||
|                 } |                     <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> |                   @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> |                   } | ||||||
|  |                 </span> | ||||||
|  |               </ng-container> | ||||||
|  |               <ng-template #etaSkeleton> | ||||||
|  |                 <span class="skeleton-loader"></span> | ||||||
|  |               </ng-template> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|         } @else if (tx && tx.status?.confirmed) { |         } @else if (tx && tx.status?.confirmed) { | ||||||
|  | |||||||
| @ -9,10 +9,11 @@ import { | |||||||
|   delay, |   delay, | ||||||
|   mergeMap, |   mergeMap, | ||||||
|   tap, |   tap, | ||||||
|   map |   map, | ||||||
|  |   startWith | ||||||
| } from 'rxjs/operators'; | } from 'rxjs/operators'; | ||||||
| import { Transaction } from '../../interfaces/electrs.interface'; | 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 { StateService } from '../../services/state.service'; | ||||||
| import { CacheService } from '../../services/cache.service'; | import { CacheService } from '../../services/cache.service'; | ||||||
| import { WebsocketService } from '../../services/websocket.service'; | import { WebsocketService } from '../../services/websocket.service'; | ||||||
| @ -21,12 +22,15 @@ import { ApiService } from '../../services/api.service'; | |||||||
| import { SeoService } from '../../services/seo.service'; | import { SeoService } from '../../services/seo.service'; | ||||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||||
| import { Filter } from '../../shared/filters.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 { PriceService } from '../../services/price.service'; | ||||||
| import { ServicesApiServices } from '../../services/services-api.service'; | import { ServicesApiServices } from '../../services/services-api.service'; | ||||||
| import { EnterpriseService } from '../../services/enterprise.service'; | import { EnterpriseService } from '../../services/enterprise.service'; | ||||||
| import { ZONE_SERVICE } from '../../injection-tokens'; | import { ZONE_SERVICE } from '../../injection-tokens'; | ||||||
| import { TrackerStage } from './tracker-bar.component'; | 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 { | interface Pool { | ||||||
|   id: number; |   id: number; | ||||||
| @ -57,6 +61,7 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
|   txId: string; |   txId: string; | ||||||
|   txInBlockIndex: number; |   txInBlockIndex: number; | ||||||
|   mempoolPosition: MempoolPosition; |   mempoolPosition: MempoolPosition; | ||||||
|  |   accelerationPositions: AccelerationPosition[]; | ||||||
|   isLoadingTx = true; |   isLoadingTx = true; | ||||||
|   error: any = undefined; |   error: any = undefined; | ||||||
|   loadingCachedTx = false; |   loadingCachedTx = false; | ||||||
| @ -89,11 +94,15 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
|   isAcceleration: boolean = false; |   isAcceleration: boolean = false; | ||||||
|   filters: Filter[] = []; |   filters: Filter[] = []; | ||||||
|   showCpfpDetails = false; |   showCpfpDetails = false; | ||||||
|  |   miningStats: MiningStats; | ||||||
|   fetchCpfp$ = new Subject<string>(); |   fetchCpfp$ = new Subject<string>(); | ||||||
|   fetchRbfHistory$ = new Subject<string>(); |   fetchRbfHistory$ = new Subject<string>(); | ||||||
|   fetchCachedTx$ = new Subject<string>(); |   fetchCachedTx$ = new Subject<string>(); | ||||||
|   fetchAcceleration$ = new Subject<string>(); |   fetchAcceleration$ = new Subject<string>(); | ||||||
|   fetchMiningInfo$ = new Subject<{ hash: string, height: number, txid: 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; |   isCached: boolean = false; | ||||||
|   now = Date.now(); |   now = Date.now(); | ||||||
|   da$: Observable<DifficultyAdjustment>; |   da$: Observable<DifficultyAdjustment>; | ||||||
| @ -122,6 +131,7 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
|     private route: ActivatedRoute, |     private route: ActivatedRoute, | ||||||
|     private electrsApiService: ElectrsApiService, |     private electrsApiService: ElectrsApiService, | ||||||
|     public stateService: StateService, |     public stateService: StateService, | ||||||
|  |     private etaService: EtaService, | ||||||
|     private cacheService: CacheService, |     private cacheService: CacheService, | ||||||
|     private websocketService: WebsocketService, |     private websocketService: WebsocketService, | ||||||
|     private audioService: AudioService, |     private audioService: AudioService, | ||||||
| @ -130,6 +140,7 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
|     private seoService: SeoService, |     private seoService: SeoService, | ||||||
|     private priceService: PriceService, |     private priceService: PriceService, | ||||||
|     private enterpriseService: EnterpriseService, |     private enterpriseService: EnterpriseService, | ||||||
|  |     private miningService: MiningService, | ||||||
|     private cd: ChangeDetectorRef, |     private cd: ChangeDetectorRef, | ||||||
|     private zone: NgZone, |     private zone: NgZone, | ||||||
|     @Inject(ZONE_SERVICE) private zoneService: any, |     @Inject(ZONE_SERVICE) private zoneService: any, | ||||||
| @ -273,6 +284,7 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
|         this.transactionTime = tx.firstSeen || 0; |         this.transactionTime = tx.firstSeen || 0; | ||||||
| 
 | 
 | ||||||
|         this.fetchRbfHistory$.next(this.tx.txid); |         this.fetchRbfHistory$.next(this.tx.txid); | ||||||
|  |         this.txChanged$.next(true); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
| @ -354,10 +366,14 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
|       this.now = Date.now(); |       this.now = Date.now(); | ||||||
|       if (txPosition && txPosition.txid === this.txId && txPosition.position) { |       if (txPosition && txPosition.txid === this.txId && txPosition.position) { | ||||||
|         this.mempoolPosition = txPosition.position; |         this.mempoolPosition = txPosition.position; | ||||||
|  |         this.accelerationPositions = txPosition.accelerationPositions; | ||||||
|         if (this.tx && !this.tx.status.confirmed) { |         if (this.tx && !this.tx.status.confirmed) { | ||||||
|  |           const txFeePerVSize = getUnacceleratedFeeRate(this.tx, this.tx.acceleration || this.mempoolPosition?.accelerated); | ||||||
|           this.stateService.markBlock$.next({ |           this.stateService.markBlock$.next({ | ||||||
|             txid: txPosition.txid, |             txid: txPosition.txid, | ||||||
|             mempoolPosition: this.mempoolPosition |             txFeePerVSize, | ||||||
|  |             mempoolPosition: this.mempoolPosition, | ||||||
|  |             accelerationPositions: this.accelerationPositions, | ||||||
|           }); |           }); | ||||||
|           this.txInBlockIndex = this.mempoolPosition.block; |           this.txInBlockIndex = this.mempoolPosition.block; | ||||||
| 
 | 
 | ||||||
| @ -372,13 +388,8 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
| 
 | 
 | ||||||
|           if (this.replaced) { |           if (this.replaced) { | ||||||
|             this.trackerStage = '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) { |           if (txPosition.position?.block > 0 && this.tx.weight < 4000) { | ||||||
|             this.accelerationEligible = true; |             this.accelerationEligible = true; | ||||||
|             if (this.acceleratorAvailable && this.paymentType === 'cashapp') { |             if (this.acceleratorAvailable && this.paymentType === 'cashapp') { | ||||||
| @ -388,6 +399,7 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
|         } |         } | ||||||
|       } else { |       } else { | ||||||
|         this.mempoolPosition = null; |         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.adjustedVsize = Math.max(this.tx.weight / 4, this.sigops * 5); | ||||||
|           } |           } | ||||||
|           this.tx.feePerVsize = tx.fee / (tx.weight / 4); |           this.tx.feePerVsize = tx.fee / (tx.weight / 4); | ||||||
|  |           this.txChanged$.next(true); | ||||||
|           this.isLoadingTx = false; |           this.isLoadingTx = false; | ||||||
|           this.error = undefined; |           this.error = undefined; | ||||||
|           this.loadingCachedTx = false; |           this.loadingCachedTx = false; | ||||||
| @ -479,11 +492,13 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
|             }); |             }); | ||||||
|             this.fetchCpfp$.next(this.tx.txid); |             this.fetchCpfp$.next(this.tx.txid); | ||||||
|           } else { |           } else { | ||||||
|  |             const txFeePerVSize = getUnacceleratedFeeRate(this.tx, this.tx.acceleration || this.mempoolPosition?.accelerated); | ||||||
|             if (tx.cpfpChecked) { |             if (tx.cpfpChecked) { | ||||||
|               this.stateService.markBlock$.next({ |               this.stateService.markBlock$.next({ | ||||||
|                 txid: tx.txid, |                 txid: tx.txid, | ||||||
|                 txFeePerVSize: tx.effectiveFeePerVsize, |                 txFeePerVSize, | ||||||
|                 mempoolPosition: this.mempoolPosition, |                 mempoolPosition: this.mempoolPosition, | ||||||
|  |                 accelerationPositions: this.accelerationPositions, | ||||||
|               }); |               }); | ||||||
|               this.setCpfpInfo({ |               this.setCpfpInfo({ | ||||||
|                 ancestors: tx.ancestors, |                 ancestors: tx.ancestors, | ||||||
| @ -522,6 +537,7 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
|           block_hash: block.id, |           block_hash: block.id, | ||||||
|           block_time: block.timestamp, |           block_time: block.timestamp, | ||||||
|         }; |         }; | ||||||
|  |         this.txChanged$.next(true); | ||||||
|         this.trackerStage = 'confirmed'; |         this.trackerStage = 'confirmed'; | ||||||
|         this.stateService.markBlock$.next({ blockHeight: block.height }); |         this.stateService.markBlock$.next({ blockHeight: block.height }); | ||||||
|         if (this.tx.acceleration || (this.accelerationInfo && ['accelerating', 'completed_provisional', 'completed'].includes(this.accelerationInfo.status))) { |         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.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> { |   handleLoadElectrsTransactionError(error: any): Observable<any> { | ||||||
| @ -610,6 +658,7 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
|       this.hasEffectiveFeeRate = false; |       this.hasEffectiveFeeRate = false; | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |     const firstCpfp = this.cpfpInfo == null; | ||||||
|     // merge ancestors/descendants
 |     // merge ancestors/descendants
 | ||||||
|     const relatives = [...(cpfpInfo.ancestors || []), ...(cpfpInfo.descendants || [])]; |     const relatives = [...(cpfpInfo.ancestors || []), ...(cpfpInfo.descendants || [])]; | ||||||
|     if (cpfpInfo.bestDescendant && !cpfpInfo.descendants?.length) { |     if (cpfpInfo.bestDescendant && !cpfpInfo.descendants?.length) { | ||||||
| @ -625,12 +674,14 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
|         relatives.reduce((prev, val) => prev + val.fee, 0); |         relatives.reduce((prev, val) => prev + val.fee, 0); | ||||||
|       this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4); |       this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4); | ||||||
|     } else { |     } 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) { |     if (cpfpInfo.acceleration) { | ||||||
|       this.tx.acceleration = cpfpInfo.acceleration; |       this.tx.acceleration = cpfpInfo.acceleration; | ||||||
|       this.tx.acceleratedBy = cpfpInfo.acceleratedBy; |       this.tx.acceleratedBy = cpfpInfo.acceleratedBy; | ||||||
|  |       this.setIsAccelerated(firstCpfp); | ||||||
|     } |     } | ||||||
|  |     this.txChanged$.next(true); | ||||||
| 
 | 
 | ||||||
|     this.cpfpInfo = cpfpInfo; |     this.cpfpInfo = cpfpInfo; | ||||||
|     if (this.cpfpInfo.adjustedVsize && this.cpfpInfo.sigops != null) { |     if (this.cpfpInfo.adjustedVsize && this.cpfpInfo.sigops != null) { | ||||||
| @ -666,6 +717,14 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
| 
 | 
 | ||||||
|   setIsAccelerated(initialState: boolean = false) { |   setIsAccelerated(initialState: boolean = false) { | ||||||
|     this.isAcceleration = (this.tx.acceleration || (this.accelerationInfo && this.pool && this.accelerationInfo.pools.some(pool => (pool === this.pool.id)))); |     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 { |   dismissAccelAlert(): void { | ||||||
| @ -685,6 +744,7 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
|   resetTransaction() { |   resetTransaction() { | ||||||
|     this.error = undefined; |     this.error = undefined; | ||||||
|     this.tx = null; |     this.tx = null; | ||||||
|  |     this.txChanged$.next(true); | ||||||
|     this.waitingForTransaction = false; |     this.waitingForTransaction = false; | ||||||
|     this.isLoadingTx = true; |     this.isLoadingTx = true; | ||||||
|     this.rbfTransaction = undefined; |     this.rbfTransaction = undefined; | ||||||
| @ -704,6 +764,7 @@ export class TrackerComponent implements OnInit, OnDestroy { | |||||||
|     this.mempoolPosition = null; |     this.mempoolPosition = null; | ||||||
|     this.pool = null; |     this.pool = null; | ||||||
|     this.auditStatus = null; |     this.auditStatus = null; | ||||||
|  |     this.accelerationPositions = null; | ||||||
|     this.accelerationEligible = false; |     this.accelerationEligible = false; | ||||||
|     this.trackerStage = 'waiting'; |     this.trackerStage = 'waiting'; | ||||||
|     document.body.scrollTo(0, 0); |     document.body.scrollTo(0, 0); | ||||||
|  | |||||||
| @ -83,7 +83,7 @@ | |||||||
|       <div class="clearfix"></div> |       <div class="clearfix"></div> | ||||||
| 
 | 
 | ||||||
|       <div class="box"> |       <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> |       </div> | ||||||
|     </ng-container> |     </ng-container> | ||||||
| 
 | 
 | ||||||
| @ -533,25 +533,28 @@ | |||||||
|       <tr> |       <tr> | ||||||
|         <td class="td-width align-items-center align-middle" i18n="transaction.eta|Transaction ETA">ETA</td> |         <td class="td-width align-items-center align-middle" i18n="transaction.eta|Transaction ETA">ETA</td> | ||||||
|         <td> |         <td> | ||||||
|           @if (this.mempoolPosition?.block == null) { |           <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]="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" [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> |             <span class="skeleton-loader"></span> | ||||||
|           } @else if (this.mempoolPosition.block >= 7) { |           </ng-template> | ||||||
|             <span [class]="(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> |  | ||||||
|           } @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> |  | ||||||
|               @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> |  | ||||||
|           } |  | ||||||
|         </td> |         </td> | ||||||
|       </tr> |       </tr> | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -10,10 +10,11 @@ import { | |||||||
|   mergeMap, |   mergeMap, | ||||||
|   tap, |   tap, | ||||||
|   map, |   map, | ||||||
|   retry |   retry, | ||||||
|  |   startWith | ||||||
| } from 'rxjs/operators'; | } from 'rxjs/operators'; | ||||||
| import { Transaction } from '../../interfaces/electrs.interface'; | 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 { StateService } from '../../services/state.service'; | ||||||
| import { CacheService } from '../../services/cache.service'; | import { CacheService } from '../../services/cache.service'; | ||||||
| import { WebsocketService } from '../../services/websocket.service'; | import { WebsocketService } from '../../services/websocket.service'; | ||||||
| @ -22,9 +23,9 @@ import { ApiService } from '../../services/api.service'; | |||||||
| import { SeoService } from '../../services/seo.service'; | import { SeoService } from '../../services/seo.service'; | ||||||
| import { StorageService } from '../../services/storage.service'; | import { StorageService } from '../../services/storage.service'; | ||||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | 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 { 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 { LiquidUnblinding } from './liquid-ublinding'; | ||||||
| import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; | import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; | ||||||
| import { PriceService } from '../../services/price.service'; | import { PriceService } from '../../services/price.service'; | ||||||
| @ -33,6 +34,7 @@ import { ServicesApiServices } from '../../services/services-api.service'; | |||||||
| import { EnterpriseService } from '../../services/enterprise.service'; | import { EnterpriseService } from '../../services/enterprise.service'; | ||||||
| import { ZONE_SERVICE } from '../../injection-tokens'; | import { ZONE_SERVICE } from '../../injection-tokens'; | ||||||
| import { MiningService, MiningStats } from '../../services/mining.service'; | import { MiningService, MiningStats } from '../../services/mining.service'; | ||||||
|  | import { ETA, EtaService } from '../../services/eta.service'; | ||||||
| 
 | 
 | ||||||
| interface Pool { | interface Pool { | ||||||
|   id: number; |   id: number; | ||||||
| @ -106,6 +108,9 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|   fetchCachedTx$ = new Subject<string>(); |   fetchCachedTx$ = new Subject<string>(); | ||||||
|   fetchAcceleration$ = new Subject<number>(); |   fetchAcceleration$ = new Subject<number>(); | ||||||
|   fetchMiningInfo$ = new Subject<{ hash: string, height: number, txid: 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; |   isCached: boolean = false; | ||||||
|   now = Date.now(); |   now = Date.now(); | ||||||
|   da$: Observable<DifficultyAdjustment>; |   da$: Observable<DifficultyAdjustment>; | ||||||
| @ -155,6 +160,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|     private storageService: StorageService, |     private storageService: StorageService, | ||||||
|     private enterpriseService: EnterpriseService, |     private enterpriseService: EnterpriseService, | ||||||
|     private miningService: MiningService, |     private miningService: MiningService, | ||||||
|  |     private etaService: EtaService, | ||||||
|     private cd: ChangeDetectorRef, |     private cd: ChangeDetectorRef, | ||||||
|     @Inject(ZONE_SERVICE) private zoneService: any, |     @Inject(ZONE_SERVICE) private zoneService: any, | ||||||
|   ) {} |   ) {} | ||||||
| @ -281,6 +287,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|             this.rbfInfo = rbfInfo; |             this.rbfInfo = rbfInfo; | ||||||
|           } |           } | ||||||
|         }); |         }); | ||||||
|  |         this.txChanged$.next(true); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
| @ -365,7 +372,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|       }) |       }) | ||||||
|     ).subscribe(auditStatus => { |     ).subscribe(auditStatus => { | ||||||
|       this.auditStatus = auditStatus; |       this.auditStatus = auditStatus; | ||||||
| 
 |  | ||||||
|       this.setIsAccelerated(); |       this.setIsAccelerated(); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
| @ -375,7 +381,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|         this.mempoolPosition = txPosition.position; |         this.mempoolPosition = txPosition.position; | ||||||
|         this.accelerationPositions = txPosition.accelerationPositions; |         this.accelerationPositions = txPosition.accelerationPositions; | ||||||
|         if (this.tx && !this.tx.status.confirmed) { |         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({ |           this.stateService.markBlock$.next({ | ||||||
|             txid: txPosition.txid, |             txid: txPosition.txid, | ||||||
|             txFeePerVSize, |             txFeePerVSize, | ||||||
| @ -493,6 +499,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|             this.adjustedVsize = Math.max(this.tx.weight / 4, this.sigops * 5); |             this.adjustedVsize = Math.max(this.tx.weight / 4, this.sigops * 5); | ||||||
|           } |           } | ||||||
|           this.tx.feePerVsize = tx.fee / (tx.weight / 4); |           this.tx.feePerVsize = tx.fee / (tx.weight / 4); | ||||||
|  |           this.txChanged$.next(true); | ||||||
|           this.isLoadingTx = false; |           this.isLoadingTx = false; | ||||||
|           this.error = undefined; |           this.error = undefined; | ||||||
|           this.loadingCachedTx = false; |           this.loadingCachedTx = false; | ||||||
| @ -519,7 +526,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|             }); |             }); | ||||||
|             this.fetchCpfp$.next(this.tx.txid); |             this.fetchCpfp$.next(this.tx.txid); | ||||||
|           } else { |           } 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) { |             if (tx.cpfpChecked) { | ||||||
|               this.stateService.markBlock$.next({ |               this.stateService.markBlock$.next({ | ||||||
|                 txid: tx.txid, |                 txid: tx.txid, | ||||||
| @ -566,6 +573,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|           block_hash: block.id, |           block_hash: block.id, | ||||||
|           block_time: block.timestamp, |           block_time: block.timestamp, | ||||||
|         }; |         }; | ||||||
|  |         this.txChanged$.next(true); | ||||||
|         this.stateService.markBlock$.next({ blockHeight: block.height }); |         this.stateService.markBlock$.next({ blockHeight: block.height }); | ||||||
|         if (this.tx.acceleration || (this.accelerationInfo && ['accelerating', 'completed_provisional', 'completed'].includes(this.accelerationInfo.status))) { |         if (this.tx.acceleration || (this.accelerationInfo && ['accelerating', 'completed_provisional', 'completed'].includes(this.accelerationInfo.status))) { | ||||||
|           this.audioService.playSound('wind-chimes-harp-ascend'); |           this.audioService.playSound('wind-chimes-harp-ascend'); | ||||||
| @ -637,6 +645,27 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|         this.txInBlockIndex = 7; |         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 { |   ngAfterViewInit(): void { | ||||||
| @ -653,6 +682,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     this.miningService.getMiningStats('1w').subscribe(stats => { | ||||||
|  |       this.miningStats = stats; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     document.location.hash = '#accelerate'; |     document.location.hash = '#accelerate'; | ||||||
|     this.enterpriseService.goal(8); |     this.enterpriseService.goal(8); | ||||||
|     this.showAccelerationSummary = true && this.acceleratorAvailable; |     this.showAccelerationSummary = true && this.acceleratorAvailable; | ||||||
| @ -715,6 +748,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|       this.tx.acceleratedBy = cpfpInfo.acceleratedBy; |       this.tx.acceleratedBy = cpfpInfo.acceleratedBy; | ||||||
|       this.setIsAccelerated(firstCpfp); |       this.setIsAccelerated(firstCpfp); | ||||||
|     } |     } | ||||||
|  |     this.txChanged$.next(true); | ||||||
| 
 | 
 | ||||||
|     this.cpfpInfo = cpfpInfo; |     this.cpfpInfo = cpfpInfo; | ||||||
|     if (this.cpfpInfo.adjustedVsize && this.cpfpInfo.sigops != null) { |     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 immediately returns cached stats if we fetched them recently
 | ||||||
|       this.miningService.getMiningStats('1w').subscribe(stats => { |       this.miningService.getMiningStats('1w').subscribe(stats => { | ||||||
|         this.miningStats = 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 { |   setFeatures(): void { | ||||||
| @ -780,6 +816,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|     this.firstLoad = false; |     this.firstLoad = false; | ||||||
|     this.error = undefined; |     this.error = undefined; | ||||||
|     this.tx = null; |     this.tx = null; | ||||||
|  |     this.txChanged$.next(true); | ||||||
|     this.setFeatures(); |     this.setFeatures(); | ||||||
|     this.waitingForTransaction = false; |     this.waitingForTransaction = false; | ||||||
|     this.isLoadingTx = true; |     this.isLoadingTx = true; | ||||||
| @ -802,6 +839,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|     this.accelerationPositions = null; |     this.accelerationPositions = null; | ||||||
|     document.body.scrollTo(0, 0); |     document.body.scrollTo(0, 0); | ||||||
|     this.isAcceleration = false; |     this.isAcceleration = false; | ||||||
|  |     this.isAccelerated$.next(this.isAcceleration); | ||||||
|     this.leaveTransaction(); |     this.leaveTransaction(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -814,20 +852,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|     return +(cpfpTx.fee / (cpfpTx.weight / 4)).toFixed(1); |     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() { |   setupGraph() { | ||||||
|     this.maxInOut = Math.min(this.inOutLimit, Math.max(this.tx?.vin?.length || 1, this.tx?.vout?.length + 1 || 1)); |     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); |     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.urlFragmentSubscription.unsubscribe(); | ||||||
|     this.mempoolBlocksSubscription.unsubscribe(); |     this.mempoolBlocksSubscription.unsubscribe(); | ||||||
|     this.mempoolPositionSubscription.unsubscribe(); |     this.mempoolPositionSubscription.unsubscribe(); | ||||||
|     this.mempoolBlocksSubscription.unsubscribe(); |  | ||||||
|     this.blocksSubscription.unsubscribe(); |     this.blocksSubscription.unsubscribe(); | ||||||
|     this.miningSubscription?.unsubscribe(); |     this.miningSubscription?.unsubscribe(); | ||||||
|     this.auditSubscription?.unsubscribe(); |     this.auditSubscription?.unsubscribe(); | ||||||
|  | |||||||
| @ -5,6 +5,8 @@ import { TransactionComponent } from './transaction.component'; | |||||||
| import { SharedModule } from '../../shared/shared.module'; | import { SharedModule } from '../../shared/shared.module'; | ||||||
| import { TxBowtieModule } from '../tx-bowtie-graph/tx-bowtie.module'; | import { TxBowtieModule } from '../tx-bowtie-graph/tx-bowtie.module'; | ||||||
| import { GraphsModule } from '../../graphs/graphs.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 = [ | const routes: Routes = [ | ||||||
|   { |   { | ||||||
| @ -36,6 +38,8 @@ export class TransactionRoutingModule { } | |||||||
|   ], |   ], | ||||||
|   declarations: [ |   declarations: [ | ||||||
|     TransactionComponent, |     TransactionComponent, | ||||||
|  |     AcceleratePreviewComponent, | ||||||
|  |     AccelerateFeeGraphComponent, | ||||||
|   ] |   ] | ||||||
| }) | }) | ||||||
| export class TransactionModule { } | export class TransactionModule { } | ||||||
|  | |||||||
| @ -140,7 +140,7 @@ export interface SinglePoolStats { | |||||||
|   emptyBlocks: number; |   emptyBlocks: number; | ||||||
|   rank: number; |   rank: number; | ||||||
|   share: number; |   share: number; | ||||||
|   lastEstimatedHashrate: string; |   lastEstimatedHashrate: number; | ||||||
|   emptyBlockRatio: string; |   emptyBlockRatio: string; | ||||||
|   logo: string; |   logo: string; | ||||||
|   slug: string; |   slug: string; | ||||||
| @ -252,7 +252,7 @@ export interface MempoolPosition { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface AccelerationPosition extends MempoolPosition { | export interface AccelerationPosition extends MempoolPosition { | ||||||
|   pool: string; |   poolId: number; | ||||||
|   offset?: 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 { | export interface MiningStats { | ||||||
|   lastEstimatedHashrate: string; |   lastEstimatedHashrate: number; | ||||||
|   blockCount: number; |   blockCount: number; | ||||||
|   totalEmptyBlock: number; |   totalEmptyBlock: number; | ||||||
|   totalEmptyBlockRatio: string; |   totalEmptyBlockRatio: string; | ||||||
| @ -111,7 +111,7 @@ export class MiningService { | |||||||
|     const poolsStats = stats.pools.map((poolStat) => { |     const poolsStats = stats.pools.map((poolStat) => { | ||||||
|       return { |       return { | ||||||
|         share: parseFloat((poolStat.blockCount / stats.blockCount * 100).toFixed(2)), |         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), |         emptyBlockRatio: (poolStat.emptyBlocks / poolStat.blockCount * 100).toFixed(2), | ||||||
|         logo: `/resources/mining-pools/` + poolStat.slug + '.svg', |         logo: `/resources/mining-pools/` + poolStat.slug + '.svg', | ||||||
|         ...poolStat |         ...poolStat | ||||||
| @ -119,7 +119,7 @@ export class MiningService { | |||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     return { |     return { | ||||||
|       lastEstimatedHashrate: (stats.lastEstimatedHashrate / hashrateDivider).toFixed(2), |       lastEstimatedHashrate: stats.lastEstimatedHashrate / hashrateDivider, | ||||||
|       blockCount: stats.blockCount, |       blockCount: stats.blockCount, | ||||||
|       totalEmptyBlock: totalEmptyBlock, |       totalEmptyBlock: totalEmptyBlock, | ||||||
|       totalEmptyBlockRatio: totalEmptyBlockRatio, |       totalEmptyBlockRatio: totalEmptyBlockRatio, | ||||||
|  | |||||||
| @ -96,8 +96,6 @@ import { ToggleComponent } from './components/toggle/toggle.component'; | |||||||
| import { GeolocationComponent } from '../shared/components/geolocation/geolocation.component'; | import { GeolocationComponent } from '../shared/components/geolocation/geolocation.component'; | ||||||
| import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert.component'; | import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert.component'; | ||||||
| import { GlobalFooterComponent } from './components/global-footer/global-footer.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 { MempoolErrorComponent } from './components/mempool-error/mempool-error.component'; | ||||||
| import { AccelerationsListComponent } from '../components/acceleration/accelerations-list/accelerations-list.component'; | import { AccelerationsListComponent } from '../components/acceleration/accelerations-list/accelerations-list.component'; | ||||||
| import { PendingStatsComponent } from '../components/acceleration/pending-stats/pending-stats.component'; | import { PendingStatsComponent } from '../components/acceleration/pending-stats/pending-stats.component'; | ||||||
| @ -212,8 +210,6 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir | |||||||
|     GeolocationComponent, |     GeolocationComponent, | ||||||
|     TestnetAlertComponent, |     TestnetAlertComponent, | ||||||
|     GlobalFooterComponent, |     GlobalFooterComponent, | ||||||
|     AcceleratePreviewComponent, |  | ||||||
|     AccelerateFeeGraphComponent, |  | ||||||
|     CalculatorComponent, |     CalculatorComponent, | ||||||
|     BitcoinsatoshisPipe, |     BitcoinsatoshisPipe, | ||||||
|     BlockViewComponent, |     BlockViewComponent, | ||||||
| @ -355,8 +351,6 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir | |||||||
|     TestnetAlertComponent, |     TestnetAlertComponent, | ||||||
|     PreviewTitleComponent, |     PreviewTitleComponent, | ||||||
|     GlobalFooterComponent, |     GlobalFooterComponent, | ||||||
|     AcceleratePreviewComponent, |  | ||||||
|     AccelerateFeeGraphComponent, |  | ||||||
|     MempoolErrorComponent, |     MempoolErrorComponent, | ||||||
|     AccelerationsListComponent, |     AccelerationsListComponent, | ||||||
|     AccelerationStatsComponent, |     AccelerationStatsComponent, | ||||||
|  | |||||||
| @ -443,3 +443,17 @@ export function getTransactionFlags(tx: Transaction, cpfpInfo?: CpfpInfo, replac | |||||||
| 
 | 
 | ||||||
|   return flags; |   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