diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 2748b9ffc..188868b11 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -70,6 +70,26 @@ + + Mining + + + {{ pool.name }} + + + Coinbase + Expected in Block + Seen in Mempool + Not seen in Mempool + Added + Conflict + + + + + + @@ -509,7 +529,7 @@ - + diff --git a/frontend/src/app/components/transaction/transaction.component.scss b/frontend/src/app/components/transaction/transaction.component.scss index d78edf85b..d24d57a93 100644 --- a/frontend/src/app/components/transaction/transaction.component.scss +++ b/frontend/src/app/components/transaction/transaction.component.scss @@ -149,6 +149,10 @@ .btn { display: block; } + + &.wrap-cell { + white-space: normal; + } } } diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 60797a9a1..0167a3d43 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -8,10 +8,11 @@ import { retryWhen, delay, mergeMap, - tap + tap, + map } 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 } from 'rxjs'; import { StateService } from '../../services/state.service'; import { CacheService } from '../../services/cache.service'; import { WebsocketService } from '../../services/websocket.service'; @@ -28,6 +29,22 @@ import { isFeatureActive } from '../../bitcoin.utils'; import { ServicesApiServices } from '../../services/services-api.service'; import { EnterpriseService } from '../../services/enterprise.service'; +interface Pool { + id: number; + name: string; + slug: string; +} + +interface AuditStatus { + seen?: boolean; + expected?: boolean; + added?: boolean; + delayed?: number; + accelerated?: boolean; + conflict?: boolean; + coinbase?: boolean; +} + @Component({ selector: 'app-transaction', templateUrl: './transaction.component.html', @@ -58,6 +75,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { urlFragmentSubscription: Subscription; mempoolBlocksSubscription: Subscription; blocksSubscription: Subscription; + miningSubscription: Subscription; fragmentParams: URLSearchParams; rbfTransaction: undefined | Transaction; replaced: boolean = false; @@ -67,11 +85,14 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { accelerationInfo: Acceleration | null = null; sigops: number | null; adjustedVsize: number | null; + pool: Pool | null; + auditStatus: AuditStatus | null; showCpfpDetails = false; fetchCpfp$ = new Subject(); fetchRbfHistory$ = new Subject(); fetchCachedTx$ = new Subject(); fetchAcceleration$ = new Subject(); + fetchMiningInfo$ = new Subject<{ hash: string, height: number, txid: string }>(); isCached: boolean = false; now = Date.now(); da$: Observable; @@ -100,6 +121,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { acceleratorAvailable: boolean = this.stateService.env.OFFICIAL_MEMPOOL_SPACE && this.stateService.env.ACCELERATOR && this.stateService.network === ''; showAccelerationSummary = false; scrollIntoAccelPreview = false; + auditEnabled: boolean = this.stateService.env.AUDIT && this.stateService.env.BASE_MODULE === 'mempool' && this.stateService.env.MINING_DASHBOARD === true; @ViewChild('graphContainer') graphContainer: ElementRef; @@ -266,6 +288,54 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } }); + this.miningSubscription = this.fetchMiningInfo$.pipe( + filter((target) => target.txid === this.txId), + tap(() => { + this.pool = null; + this.auditStatus = null; + }), + switchMap(({ hash, height, txid }) => { + const foundBlock = this.cacheService.getCachedBlock(height) || null; + const auditAvailable = this.isAuditAvailable(height); + const isCoinbase = this.tx.vin.some(v => v.is_coinbase); + const fetchAudit = auditAvailable && !isCoinbase; + return combineLatest([ + foundBlock ? of(foundBlock.extras.pool) : this.apiService.getBlock$(hash).pipe( + map(block => { + return block.extras.pool; + }), + catchError(() => { + return of(null); + }) + ), + fetchAudit ? this.apiService.getBlockAudit$(hash).pipe( + map(audit => { + const isAdded = audit.addedTxs.includes(txid); + const isAccelerated = audit.acceleratedTxs.includes(txid); + const isConflict = audit.fullrbfTxs.includes(txid); + const isExpected = audit.template.some(tx => tx.txid === txid); + return { + seen: isExpected || !(isAdded || isConflict), + expected: isExpected, + added: isAdded, + conflict: isConflict, + accelerated: isAccelerated, + }; + }), + catchError(() => { + return of(null); + }) + ) : of(isCoinbase ? { coinbase: true } : null) + ]); + }), + catchError(() => { + return of(null); + }) + ).subscribe(([pool, auditStatus]) => { + this.pool = pool; + this.auditStatus = auditStatus; + }); + this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => { this.now = Date.now(); if (txPosition && txPosition.txid === this.txId && txPosition.position) { @@ -396,6 +466,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } } else { this.fetchAcceleration$.next(tx.status.block_hash); + this.fetchMiningInfo$.next({ hash: tx.status.block_hash, height: tx.status.block_height, txid: tx.txid }); this.transactionTime = 0; } @@ -453,6 +524,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.audioService.playSound('magic'); } this.fetchAcceleration$.next(block.id); + this.fetchMiningInfo$.next({ hash: block.id, height: block.height, txid: this.tx.txid }); } }); @@ -606,6 +678,29 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.featuresEnabled = this.segwitEnabled || this.taprootEnabled || this.rbfEnabled; } + isAuditAvailable(blockHeight: number): boolean { + if (!this.auditEnabled) { + return false; + } + switch (this.stateService.network) { + case 'testnet': + if (blockHeight < this.stateService.env.TESTNET_BLOCK_AUDIT_START_HEIGHT) { + return false; + } + break; + case 'signet': + if (blockHeight < this.stateService.env.SIGNET_BLOCK_AUDIT_START_HEIGHT) { + return false; + } + break; + default: + if (blockHeight < this.stateService.env.MAINNET_BLOCK_AUDIT_START_HEIGHT) { + return false; + } + } + return true; + } + resetTransaction() { this.error = undefined; this.tx = null; @@ -625,6 +720,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.accelerationInfo = null; this.txInBlockIndex = null; this.mempoolPosition = null; + this.pool = null; + this.auditStatus = null; document.body.scrollTo(0, 0); this.leaveTransaction(); } @@ -712,6 +809,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.mempoolPositionSubscription.unsubscribe(); this.mempoolBlocksSubscription.unsubscribe(); this.blocksSubscription.unsubscribe(); + this.miningSubscription?.unsubscribe(); this.leaveTransaction(); } }
Fee {{ tx.fee | number }} sat