Merge pull request #6066 from mempool/mononaut/stale-block-page

stale block list page
This commit is contained in:
wiz 2025-10-12 14:57:51 -10:00 committed by GitHub
commit d5329e1d64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 529 additions and 8 deletions

View file

@ -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[] = [];

View file

@ -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<number, StaleTip> = {};
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<void> {
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) {

View file

@ -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;
}

View file

@ -0,0 +1,125 @@
<div class="container-xl" style="min-height: 335px">
<h1 class="float-left" i18n="page.recent-stale-chain-tips">Recent Stale Chain Tips</h1>
<div *ngIf="isLoading" class="spinner-border ml-3" role="status"></div>
<div class="clearfix"></div>
<div class="chain-tips" [ngStyle]="{ 'min-height': '295px', 'opacity': isLoading ? '0.75' : '1' }">
<ng-container *ngIf="chainTips$ | async as chainTips">
<div *ngFor="let chainTip of chainTips" class="chain-tip">
<p class="info">
<span class="height">{{ chainTip.height }}</span>
<span class="badges">
<span class="type" [ngSwitch]="chainTip.status">
<span *ngSwitchCase="'headers-only'" class="badge badge-info" i18n="chain-tips.headers-only">Headers Only</span>
<span *ngSwitchCase="'valid-headers'" class="badge badge-info" i18n="chain-tips.valid-headers">Valid Headers</span>
<span *ngSwitchCase="'valid-fork'" class="badge badge-info" i18n="chain-tips.valid-fork">Valid Fork</span>
<span *ngSwitchCase="'invalid'" class="badge badge-info" i18n="chain-tips.invalid">Invalid</span>
<span *ngSwitchDefault>{{ chainTip.status }}</span>
</span>
<span class="badge badge-secondary depth-badge" i18n="chain-tips.depth">Depth {{ chainTip.branchlen + 1 }}</span>
</span>
</p>
<div class="stale-tip-wrapper">
<div class="block-comparison">
<div class="block-column stale">
<div class="block-label" i18n="chain-tips.stale-block">Stale Block</div>
<div class="block-wrapper">
<div class="bitcoin-block mined-block stale-block" [style.background]="getBlockGradient(chainTip.stale)" [routerLink]="['/block/' + chainTip.stale.id]"></div>
<div class="block-body">
<div *ngIf="chainTip.stale?.extras; else emptyStaleBlockFees" class="fees">
~<app-fee-rate [fee]="chainTip.stale.extras.medianFee" unitClass="" rounding="1.0-0"></app-fee-rate>
</div>
<ng-template #emptyStaleBlockFees>
<div class="fees">
<app-fee-rate unitClass=""></app-fee-rate>
</div>
</ng-template>
<div *ngIf="chainTip.stale?.extras?.minFee != null && chainTip.stale?.extras?.maxFee != null; else emptyStaleBlockFeeSpan" class="fee-span">
<app-fee-rate [fee]="chainTip.stale.extras.minFee" [showUnit]="false" unitClass=""></app-fee-rate>
-
<app-fee-rate [fee]="chainTip.stale.extras.maxFee" unitClass=""></app-fee-rate>
</div>
<ng-template #emptyStaleBlockFeeSpan>
<div class="fee-span">
<app-fee-rate unitClass=""></app-fee-rate>
</div>
</ng-template>
<div class="block-size" *ngIf="chainTip.stale?.size" [innerHTML]="'&lrm;' + (chainTip.stale.size | bytes: 2)"></div>
<div class="transaction-count" *ngIf="chainTip.stale?.tx_count">
{{ chainTip.stale.tx_count | number }} transaction<ng-container *ngIf="chainTip.stale.tx_count !== 1">s</ng-container>
</div>
<div class="time-difference" *ngIf="chainTip.stale?.timestamp">
<app-time kind="since" [time]="chainTip.stale.timestamp" [fastRender]="true" [precision]="1" minUnit="minute"></app-time>
</div>
</div>
</div>
<div class="animated" *ngIf="chainTip.stale?.extras?.pool != undefined">
<a class="badge" [class.miner-name]="chainTip.stale.extras.pool.minerNames?.length > 1 && chainTip.stale.extras.pool.minerNames[1] != ''" [routerLink]="[('/mining/pool/' + chainTip.stale.extras.pool.slug) | relativeUrl]">
<ng-container *ngIf="chainTip.stale.extras.pool.minerNames?.length > 1 && chainTip.stale.extras.pool.minerNames[1] != ''; else staleBlockCentralisedPool">
<img [ngbTooltip]="chainTip.stale.extras.pool.name" class="pool-logo faded" [src]="'/resources/mining-pools/' + chainTip.stale.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + chainTip.stale.extras.pool.name + ' mining pool'">
{{ chainTip.stale.extras.pool.minerNames[1] }}
</ng-container>
<ng-template #staleBlockCentralisedPool>
<img class="pool-logo" [src]="'/resources/mining-pools/' + chainTip.stale.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + chainTip.stale.extras.pool.name + ' mining pool'"> {{ chainTip.stale.extras.pool.name }}
</ng-template>
</a>
</div>
</div>
<div class="vs-label">vs</div>
<div class="block-column canonical">
<div class="block-label" i18n="chain-tips.winning-block">Winning Block</div>
<div class="bitcoin-block mined-block canonical-block" [style.background]="getBlockGradient(chainTip.canonical)" [routerLink]="['/block/' + chainTip.canonical.id]">
<div class="block-body">
<div *ngIf="chainTip.canonical?.extras; else emptyCanonicalBlockFees" class="fees">
~<app-fee-rate [fee]="chainTip.canonical.extras.medianFee" unitClass="" rounding="1.0-0"></app-fee-rate>
</div>
<ng-template #emptyCanonicalBlockFees>
<div class="fees">
<app-fee-rate unitClass=""></app-fee-rate>
</div>
</ng-template>
<div *ngIf="chainTip.canonical?.extras?.minFee != null && chainTip.canonical?.extras?.maxFee != null; else emptyCanonicalBlockFeeSpan" class="fee-span">
<app-fee-rate [fee]="chainTip.canonical.extras.minFee" [showUnit]="false" unitClass=""></app-fee-rate>
-
<app-fee-rate [fee]="chainTip.canonical.extras.maxFee" unitClass=""></app-fee-rate>
</div>
<ng-template #emptyCanonicalBlockFeeSpan>
<div class="fee-span">
<app-fee-rate unitClass=""></app-fee-rate>
</div>
</ng-template>
<div class="block-size" *ngIf="chainTip.canonical?.size" [innerHTML]="'&lrm;' + (chainTip.canonical.size | bytes: 2)"></div>
<div class="transaction-count" *ngIf="chainTip.canonical?.tx_count">
{{ chainTip.canonical.tx_count | number }} transaction<ng-container *ngIf="chainTip.canonical.tx_count !== 1">s</ng-container>
</div>
<div class="time-difference" *ngIf="chainTip.canonical?.timestamp">
<app-time kind="since" [time]="chainTip.canonical.timestamp" [fastRender]="true" [precision]="1" minUnit="minute"></app-time>
</div>
</div>
</div>
<div class="animated" *ngIf="chainTip.canonical?.extras?.pool != undefined">
<a class="badge" [class.miner-name]="chainTip.canonical.extras.pool.minerNames?.length > 1 && chainTip.canonical.extras.pool.minerNames[1] != ''" [routerLink]="[('/mining/pool/' + chainTip.canonical.extras.pool.slug) | relativeUrl]">
<ng-container *ngIf="chainTip.canonical.extras.pool.minerNames?.length > 1 && chainTip.canonical.extras.pool.minerNames[1] != ''; else canonicalBlockCentralisedPool">
<img [ngbTooltip]="chainTip.canonical.extras.pool.name" class="pool-logo faded" [src]="'/resources/mining-pools/' + chainTip.canonical.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + chainTip.canonical.extras.pool.name + ' mining pool'">
{{ chainTip.canonical.extras.pool.minerNames[1] }}
</ng-container>
<ng-template #canonicalBlockCentralisedPool>
<img class="pool-logo" [src]="'/resources/mining-pools/' + chainTip.canonical.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + chainTip.canonical.extras.pool.name + ' mining pool'"> {{ chainTip.canonical.extras.pool.name }}
</ng-template>
</a>
</div>
</div>
</div>
</div>
</div>
<div class="no-chain-tips" *ngIf="!chainTips?.length">
<p i18n="chain-tips.no-stale-blocks-yet">This node hasn't seen any stale blocks yet!</p>
</div>
</ng-container>
</div>
</div>

View file

@ -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);
}
}

View file

@ -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<StaleTip[]>;
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;
}
}

View file

@ -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;
}

View file

@ -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,

View file

@ -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<RbfTree[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || ''));
}
getChainTips$(): Observable<ChainTip[]> {
return this.httpClient.get<ChainTip[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/chain-tips');
}
getStaleTips$(): Observable<StaleTip[]> {
return this.httpClient.get<StaleTip[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/stale-tips');
}
liquidPegs$(): Observable<CurrentPegs> {
return this.httpClient.get<CurrentPegs>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs');
}

View file

@ -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,