diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 334382a32..c0abdb23f 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -57,6 +57,7 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'chain-tips', this.getChainTips.bind(this)) + .get(config.MEMPOOL.API_URL_PREFIX + 'stale-tips', this.getStaleTips.bind(this)) .post(config.MEMPOOL.API_URL_PREFIX + 'prevouts', this.$getPrevouts) .post(config.MEMPOOL.API_URL_PREFIX + 'cpfp', this.getCpfpLocalTxs) // Temporarily add txs/package endpoint for all backends until esplora supports it @@ -548,6 +549,26 @@ class BitcoinRoutes { } } + private async getStaleTips(req: Request, res: Response) { + try { + if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin + res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); + const tips = await chainTips.getStaleTips(); + if (tips.length > 0) { + res.json(tips); + } else { + handleError(req, res, 503, `Temporarily unavailable`); + return; + } + } else { // Liquid + handleError(req, res, 404, `This API is only available for Bitcoin networks`); + return; + } + } catch (e) { + handleError(req, res, 500, 'Failed to get stale tips'); + } + } + private async getLegacyBlocks(req: Request, res: Response) { try { const returnBlocks: IEsploraApi.Block[] = []; diff --git a/backend/src/api/chain-tips.ts b/backend/src/api/chain-tips.ts index 0b16f6f9f..1400ebfa4 100644 --- a/backend/src/api/chain-tips.ts +++ b/backend/src/api/chain-tips.ts @@ -1,4 +1,5 @@ import logger from '../logger'; +import { BlockExtended } from '../mempool.interfaces'; import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository'; import { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory'; import bitcoinClient from './bitcoin/bitcoin-client'; @@ -13,6 +14,11 @@ export interface ChainTip { status: 'invalid' | 'active' | 'valid-fork' | 'valid-headers' | 'headers-only'; }; +export interface StaleTip extends ChainTip { + stale: BlockExtended; + canonical: BlockExtended; +} + export interface OrphanedBlock { height: number; hash: string; @@ -22,11 +28,14 @@ export interface OrphanedBlock { class ChainTips { private chainTips: ChainTip[] = []; + private staleTips: Record = {}; private orphanedBlocks: { [hash: string]: OrphanedBlock } = {}; private blockCache: { [hash: string]: OrphanedBlock } = {}; private orphansByHeight: { [height: number]: OrphanedBlock[] } = {}; private indexingOrphanedBlocks = false; - private indexingQueue: IEsploraApi.Block[] = []; + private indexingQueue: { block: IEsploraApi.Block, tip: OrphanedBlock }[] = []; + + private staleTipsCacheSize = 50; public async updateOrphanedBlocks(): Promise { try { @@ -54,7 +63,7 @@ class ChainTips { prevhash: block.previousblockhash, }; this.blockCache[hash] = orphan; - this.indexingQueue.push(block); + this.indexingQueue.push({ block, tip: orphan }); } } if (orphan) { @@ -82,6 +91,14 @@ class ChainTips { this.orphansByHeight[orphan.height].push(orphan); } + const heightsToKeep = new Set(this.chainTips.filter(tip => tip.status !== 'active').map(tip => tip.height)); + const heightsToRemove: number[] = Object.keys(this.staleTips).map(Number).filter(height => !heightsToKeep.has(height)); + for (const height of heightsToRemove) { + delete this.staleTips[height]; + } + + this.trimStaleTipsCache(); + // index new orphaned blocks in the background void this.$indexOrphanedBlocks(); @@ -97,25 +114,53 @@ class ChainTips { } this.indexingOrphanedBlocks = true; while (this.indexingQueue.length > 0) { - const block = this.indexingQueue.shift(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { block, tip } = this.indexingQueue.shift()!; if (!block) { continue; } try { + let staleBlock: BlockExtended | undefined; const alreadyIndexed = await BlocksSummariesRepository.$isSummaryIndexed(block.id); + const needToCache = Object.keys(this.staleTips).length < this.staleTipsCacheSize || block.height > Object.keys(this.staleTips).map(Number).sort((a, b) => b - a)[this.staleTipsCacheSize - 1]; if (!alreadyIndexed) { - await blocks.$indexBlock(block.id, block, true); + staleBlock = await blocks.$indexBlock(block.id, block, true); await blocks.$indexBlockSummary(block.id, block.height, true); + // don't DDOS core by indexing too fast + await Common.sleep$(5000); + } else if (needToCache) { + staleBlock = await blocks.$getBlock(block.id) as BlockExtended; + } + + if (staleBlock && needToCache) { + const canonicalBlock = await blocks.$indexBlockByHeight(staleBlock.height); + this.staleTips[staleBlock.height] = { + height: staleBlock.height, + hash: staleBlock.id, + branchlen: tip.height - staleBlock.height, + status: tip.status, + stale: staleBlock, + canonical: canonicalBlock, + }; + this.trimStaleTipsCache(); } } catch (e) { logger.err(`Failed to index orphaned block ${block.id} at height ${block.height}. Reason: ${e instanceof Error ? e.message : e}`); } - // don't DDOS core by indexing too fast - await Common.sleep$(5000); } this.indexingOrphanedBlocks = false; } + private trimStaleTipsCache(): void { + const staleTipHeights = Object.keys(this.staleTips).map(Number).sort((a, b) => b - a); + if (staleTipHeights.length > this.staleTipsCacheSize) { + const heightsToDiscard = staleTipHeights.slice(this.staleTipsCacheSize); + for (const height of heightsToDiscard) { + delete this.staleTips[height]; + } + } + } + public getOrphanedBlocksAtHeight(height: number | undefined): OrphanedBlock[] { if (height === undefined) { return []; @@ -128,6 +173,10 @@ class ChainTips { return this.chainTips; } + public getStaleTips(): StaleTip[] { + return Object.values(this.staleTips).sort((a, b) => b.height - a.height); + } + clearOrphanCacheAboveHeight(height: number): void { for (const h in this.orphansByHeight) { if (Number(h) > height) { diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss index 2b2075afd..e53d2e7ae 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss @@ -162,7 +162,7 @@ width: var(--block-size); height: var(--block-size); z-index: calc(-1 * (var(--stale-index) + 1)); - opacity: 0.5; + opacity: 0.75; cursor: pointer; } diff --git a/frontend/src/app/components/stale-list/stale-list.component.html b/frontend/src/app/components/stale-list/stale-list.component.html new file mode 100644 index 000000000..7511c1dcc --- /dev/null +++ b/frontend/src/app/components/stale-list/stale-list.component.html @@ -0,0 +1,125 @@ +
+

Recent Stale Chain Tips

+
+ +
+ +
+ +
+

+ {{ chainTip.height }} + + + Headers Only + Valid Headers + Valid Fork + Invalid + {{ chainTip.status }} + + Depth {{ chainTip.branchlen + 1 }} + +

+
+
+
+
Stale Block
+
+
+
+
+ ~ +
+ +
+ +
+
+
+ + - + +
+ +
+ +
+
+
+
+ {{ chainTip.stale.tx_count | number }} transactions +
+
+ +
+
+
+ +
+ +
vs
+ +
+
Winning Block
+
+
+
+ ~ +
+ +
+ +
+
+
+ + - + +
+ +
+ +
+
+
+
+ {{ chainTip.canonical.tx_count | number }} transactions +
+
+ +
+
+
+ +
+
+
+
+ +
+

This node hasn't seen any stale blocks yet!

+
+
+
+ +
diff --git a/frontend/src/app/components/stale-list/stale-list.component.scss b/frontend/src/app/components/stale-list/stale-list.component.scss new file mode 100644 index 000000000..a27bf4725 --- /dev/null +++ b/frontend/src/app/components/stale-list/stale-list.component.scss @@ -0,0 +1,201 @@ +.spinner-border { + height: 25px; + width: 25px; + margin-top: 13px; +} + +.chain-tips { + .info { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: baseline; + margin: 0; + margin-bottom: 0.5em; + + .type { + .badge { + margin-left: .5em; + } + } + + .depth-badge { + margin-left: .5em; + } + } + + .chain-tip { + margin-bottom: 2em; + background: var(--box-bg); + border-radius: 5px; + padding: 0.5em 1em; + } + + .no-chain-tips { + margin: 1em; + text-align: center; + } +} + +.stale-tip-wrapper { + background: var(--stat-box-bg); + padding: 1em; + border-radius: 8px; +} + +.block-comparison { + display: flex; + flex-direction: row; + justify-content: center; + gap: 4em; + align-items: center; + + @media screen and (max-width: 540px) { + gap: 1.8em; + } + @media screen and (max-width: 420px) { + .vs-label { + display: none; + } + } +} + +.block-column { + display: flex; + flex-direction: column; + align-items: center; + gap: 1em; +} + +.block-label { + font-size: 14px; + font-weight: 600; + color: var(--fg); + margin-bottom: 1.5em; + transform: translateX(-12px); +} + +.bitcoin-block { + --block-size: 125px; + width: var(--block-size); + height: var(--block-size); + position: relative; + cursor: pointer; +} + +.block-wrapper { + position: relative; + width: var(--block-size); + height: var(--block-size); +} + +.block-column.stale { + .bitcoin-block { + opacity: 0.6; + box-shadow: 0 0 10px rgba(255, 0, 0, 0.25), 0 0 10px rgba(255, 0, 0, 0.25); + filter: drop-shadow(0 0 10px rgba(255, 0, 0, 0.25)); + } + + .block-body { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 10; + } +} + +.block-column.canonical { + .bitcoin-block { + box-shadow: 0 0 10px rgba(0, 255, 0, 0.25), 0 0 10px rgba(0, 255, 0, 0.25); + filter: drop-shadow(0 0 10px rgba(0, 255, 0, 0.25)); + } +} + +.bitcoin-block::after { + content: ''; + width: var(--block-size); + height: calc(0.192 * var(--block-size)); + position: absolute; + top: calc(-0.192 * var(--block-size)); + left: calc(-0.16 * var(--block-size)); + background-color: #232838; + transform: skew(40deg); + transform-origin: top; +} + +.bitcoin-block::before { + content: ''; + width: calc(0.16 * var(--block-size)); + height: var(--block-size); + position: absolute; + top: calc(-0.096 * var(--block-size)); + left: calc(-0.16 * var(--block-size)); + background-color: #191c27; + transform: skewY(50deg); + transform-origin: top; +} + +.block-body { + text-align: center; + position: relative; + z-index: 1; +} + +.fees { + font-size: 12px; + margin-top: 10px; + margin-bottom: 2px; +} + +.fee-span { + font-size: 11px; + margin-bottom: 5px; + color: var(--yellow); +} + +.block-size { + font-size: 16px; + font-weight: bold; +} + +.transaction-count { + font-size: 10px; + margin-top: 3px; + margin-bottom: 4px; +} + +.time-difference { + font-size: 13px; +} + +.animated { + transition: all 0.15s ease-in-out; + white-space: nowrap; + + .badge { + position: relative; + color: #FFF; + overflow: hidden; + text-overflow: ellipsis; + max-width: 145px; + + &.miner-name { + max-width: 125px; + } + } +} + +.pool-logo { + width: 15px; + height: 15px; + position: relative; + top: -1px; + margin-right: 2px; + + &.faded { + filter: grayscale(100%) brightness(1.5); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/stale-list/stale-list.component.ts b/frontend/src/app/components/stale-list/stale-list.component.ts new file mode 100644 index 000000000..8aa7d421a --- /dev/null +++ b/frontend/src/app/components/stale-list/stale-list.component.ts @@ -0,0 +1,97 @@ +import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; +import { StaleTip, BlockExtended } from '@interfaces/node-api.interface'; +import { ApiService } from '@app/services/api.service'; +import { StateService } from '@app/services/state.service'; +import { SeoService } from '@app/services/seo.service'; +import { seoDescriptionNetwork } from '@app/shared/common.utils'; + +@Component({ + selector: 'app-stale-list', + templateUrl: './stale-list.component.html', + styleUrls: ['./stale-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StaleList implements OnInit { + chainTips$: Observable; + nextChainTipSubject = new BehaviorSubject(null); + urlFragmentSubscription: Subscription; + isLoading = true; + + gradientColors = { + '': ['var(--mainnet-alt)', 'var(--primary)'], + liquid: ['var(--liquid)', 'var(--testnet-alt)'], + 'liquidtestnet': ['var(--liquidtestnet)', 'var(--liquidtestnet-alt)'], + testnet: ['var(--testnet)', 'var(--testnet-alt)'], + testnet4: ['var(--testnet)', 'var(--testnet-alt)'], + signet: ['var(--signet)', 'var(--signet-alt)'], + }; + + constructor( + private apiService: ApiService, + public stateService: StateService, + private seoService: SeoService, + ) { } + + ngOnInit(): void { + this.chainTips$ = this.apiService.getStaleTips$().pipe( + map((chainTips) => { + const filtered = chainTips.filter((chainTip) => chainTip.status !== 'active') as StaleTip[]; + + filtered.forEach((chainTip) => { + if (chainTip.stale?.extras) { + chainTip.stale.extras.minFee = this.getMinBlockFee(chainTip.stale); + chainTip.stale.extras.maxFee = this.getMaxBlockFee(chainTip.stale); + } + if (chainTip.canonical?.extras) { + chainTip.canonical.extras.minFee = this.getMinBlockFee(chainTip.canonical); + chainTip.canonical.extras.maxFee = this.getMaxBlockFee(chainTip.canonical); + } + }); + + return filtered; + }), + tap(() => { + this.isLoading = false; + }) + ); + + this.seoService.setTitle($localize`:@@page.stale-chain-tips:Stale Chain Tips`); + this.seoService.setDescription($localize`:@@meta.description.stale-chain-tips:See the most recent stale chain tips on the Bitcoin${seoDescriptionNetwork(this.stateService.network)} network.`); + } + + getBlockGradient(block: BlockExtended): string { + if (!block || !block.weight) { + return 'var(--secondary)'; + } + + const backgroundHeight = 100 - (block.weight / this.stateService.env.BLOCK_WEIGHT_UNITS) * 100; + const network = this.stateService.network || ''; + + return `repeating-linear-gradient( + var(--secondary), + var(--secondary) ${backgroundHeight}%, + ${this.gradientColors[network][0]} ${Math.max(backgroundHeight, 0)}%, + ${this.gradientColors[network][1]} 100% + )`; + } + + getMinBlockFee(block: BlockExtended): number { + if (block?.extras?.feeRange) { + if (block.extras.medianFee === block.extras.feeRange[3]) { + return block.extras.feeRange[1]; + } else { + return block.extras.feeRange[0]; + } + } + return 0; + } + + getMaxBlockFee(block: BlockExtended): number { + if (block?.extras?.feeRange) { + return block.extras.feeRange[block.extras.feeRange.length - 1]; + } + return 0; + } +} diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index f4d380921..eacd9df4b 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -496,3 +496,15 @@ export interface Treasury { verifiedAddresses: string[]; balances?: { balance: number, time: number }[]; } + +export interface ChainTip { + height: number; + hash: string; + branchlen: number; + status: 'invalid' | 'active' | 'valid-fork' | 'valid-headers' | 'headers-only'; +} + +export interface StaleTip extends ChainTip { + stale: BlockExtended; + canonical: BlockExtended; +} \ No newline at end of file diff --git a/frontend/src/app/master-page.module.ts b/frontend/src/app/master-page.module.ts index 69bfbe121..b16a2de1b 100644 --- a/frontend/src/app/master-page.module.ts +++ b/frontend/src/app/master-page.module.ts @@ -10,6 +10,7 @@ import { TestTransactionsComponent } from '@components/test-transactions/test-tr import { CalculatorComponent } from '@components/calculator/calculator.component'; import { BlocksList } from '@components/blocks-list/blocks-list.component'; import { RbfList } from '@components/rbf-list/rbf-list.component'; +import { StaleList } from '@components/stale-list/stale-list.component'; import { StratumList } from '@components/stratum/stratum-list/stratum-list.component'; import { ServerHealthComponent } from '@components/server-health/server-health.component'; import { ServerStatusComponent } from '@components/server-health/server-status.component'; @@ -59,6 +60,10 @@ const routes: Routes = [ path: 'rbf', component: RbfList, }, + { + path: 'stale', + component: StaleList, + }, ...(browserWindowEnv.STRATUM_ENABLED ? [{ path: 'stratum', component: StartComponent, diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index bc98b2417..78626ff23 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, - RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult, WalletAddress, Treasury, SubmitPackageResult } from '@interfaces/node-api.interface'; + RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult, WalletAddress, Treasury, SubmitPackageResult, ChainTip, StaleTip } from '@interfaces/node-api.interface'; import { BehaviorSubject, Observable, catchError, filter, map, of, shareReplay, take, tap } from 'rxjs'; import { StateService } from '@app/services/state.service'; import { Transaction } from '@interfaces/electrs.interface'; @@ -168,6 +168,14 @@ export class ApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || '')); } + getChainTips$(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/chain-tips'); + } + + getStaleTips$(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/stale-tips'); + } + liquidPegs$(): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs'); } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 05ac17e25..8ffd0c297 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -85,6 +85,7 @@ import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe'; import { DifficultyAdjustmentsTable } from '@components/difficulty-adjustments-table/difficulty-adjustments-table.components'; import { BlocksList } from '@components/blocks-list/blocks-list.component'; import { RbfList } from '@components/rbf-list/rbf-list.component'; +import { StaleList } from '@components/stale-list/stale-list.component'; import { StratumList } from '@components/stratum/stratum-list/stratum-list.component'; import { RewardStatsComponent } from '@components/reward-stats/reward-stats.component'; import { DataCyDirective } from '@app/data-cy.directive'; @@ -209,6 +210,7 @@ import { GithubLogin } from '@components/github-login.component/github-login.com DifficultyAdjustmentsTable, BlocksList, RbfList, + StaleList, StratumList, DataCyDirective, RewardStatsComponent, @@ -358,6 +360,7 @@ import { GithubLogin } from '@components/github-login.component/github-login.com AmountShortenerPipe, DifficultyAdjustmentsTable, BlocksList, + StaleList, StratumList, DataCyDirective, RewardStatsComponent,