From 752eba767addb7042437d3f79a01f6b76c636e92 Mon Sep 17 00:00:00 2001 From: natsee Date: Sun, 21 Jan 2024 13:19:02 +0100 Subject: [PATCH] Liquid: add BTC reserves to L-BTC widget and make it dynamic --- .../lbtc-pegs-graph.component.ts | 43 +++-- .../app/dashboard/dashboard.component.html | 14 +- .../app/dashboard/dashboard.component.scss | 6 + .../src/app/dashboard/dashboard.component.ts | 153 ++++++++++++++---- .../src/app/interfaces/node-api.interface.ts | 29 ++++ frontend/src/app/services/api.service.ts | 34 +++- 6 files changed, 233 insertions(+), 46 deletions(-) diff --git a/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts b/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts index c4e8cbf91..f8d0843b1 100644 --- a/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts +++ b/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts @@ -41,20 +41,24 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { } ngOnChanges() { - if (!this.data) { + if (!this.data?.liquidPegs) { return; } - this.pegsChartOptions = this.createChartOptions(this.data.series, this.data.labels); + if (!this.data.liquidReserves || this.data.liquidReserves?.series.length !== this.data.liquidPegs.series.length) { + this.pegsChartOptions = this.createChartOptions(this.data.liquidPegs.series, this.data.liquidPegs.labels); + } else { + this.pegsChartOptions = this.createChartOptions(this.data.liquidPegs.series, this.data.liquidPegs.labels, this.data.liquidReserves.series); + } } rendered() { - if (!this.data) { + if (!this.data.liquidPegs) { return; } this.isLoading = false; } - createChartOptions(series: number[], labels: string[]): EChartsOption { + createChartOptions(pegSeries: number[], labels: string[], reservesSeries?: number[],): EChartsOption { return { grid: { height: this.height, @@ -99,17 +103,18 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { type: 'line', }, formatter: (params: any) => { - const colorSpan = (color: string) => ``; + const colorSpan = (color: string) => ``; let itemFormatted = '
' + params[0].axisValue + '
'; - params.map((item: any, index: number) => { + for (let index = params.length - 1; index >= 0; index--) { + const item = params[index]; if (index < 26) { itemFormatted += `
${colorSpan(item.color)}
-
-
${formatNumber(item.value, this.locale, '1.2-2')} L-BTC
+
+
${formatNumber(item.value, this.locale, '1.2-2')} ${item.seriesName}
`; } - }); + }; return `
${itemFormatted}
`; } }, @@ -138,20 +143,34 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { }, series: [ { - data: series, + data: pegSeries, + name: 'L-BTC', + color: '#116761', type: 'line', stack: 'total', - smooth: false, + smooth: true, showSymbol: false, areaStyle: { opacity: 0.2, color: '#116761', }, lineStyle: { - width: 3, + width: 2, color: '#116761', }, }, + { + data: reservesSeries, + name: 'BTC', + color: '#EA983B', + type: 'line', + smooth: true, + showSymbol: false, + lineStyle: { + width: 2, + color: '#EA983B', + }, + }, ], }; } diff --git a/frontend/src/app/dashboard/dashboard.component.html b/frontend/src/app/dashboard/dashboard.component.html index 12ce14512..9603c8d93 100644 --- a/frontend/src/app/dashboard/dashboard.component.html +++ b/frontend/src/app/dashboard/dashboard.component.html @@ -33,7 +33,7 @@ - + @@ -270,8 +270,16 @@
L-BTC in circulation
- -

{{ liquidPegsMonth.series.slice(-1)[0] | number: '1.2-2' }} L-BTC

+ +

{{ (+currentPeg.amount) / 100000000 | number: '1.2-2' }} L-BTC

+
+
+
+ +
BTC Reserves
+
+ +

{{ +(currentReserves.amount) / 100000000 | number: '1.2-2' }} BTC

diff --git a/frontend/src/app/dashboard/dashboard.component.scss b/frontend/src/app/dashboard/dashboard.component.scss index 884ba1027..f10c4957f 100644 --- a/frontend/src/app/dashboard/dashboard.component.scss +++ b/frontend/src/app/dashboard/dashboard.component.scss @@ -97,6 +97,12 @@ color: #ffffff66; font-size: 12px; } + .liquid-color { + color: #116761; + } + .bitcoin-color { + color: #b86d12; + } } .progress { width: 90%; diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts index 8a34bf768..2f97b23a0 100644 --- a/frontend/src/app/dashboard/dashboard.component.ts +++ b/frontend/src/app/dashboard/dashboard.component.ts @@ -1,7 +1,7 @@ import { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; -import { combineLatest, merge, Observable, of, Subscription } from 'rxjs'; -import { catchError, filter, map, scan, share, switchMap, tap } from 'rxjs/operators'; -import { BlockExtended, OptimizedMempoolStats } from '../interfaces/node-api.interface'; +import { combineLatest, concat, EMPTY, interval, merge, Observable, of, Subscription } from 'rxjs'; +import { catchError, delay, filter, map, mergeMap, scan, share, skip, startWith, switchMap, tap } from 'rxjs/operators'; +import { AuditStatus, BlockExtended, CurrentPegs, OptimizedMempoolStats } from '../interfaces/node-api.interface'; import { MempoolInfo, TransactionStripped, ReplacementInfo } from '../interfaces/websocket.interface'; import { ApiService } from '../services/api.service'; import { StateService } from '../services/state.service'; @@ -47,8 +47,15 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit { transactionsWeightPerSecondOptions: any; isLoadingWebSocket$: Observable; liquidPegsMonth$: Observable; + currentPeg$: Observable; + auditStatus$: Observable; + liquidReservesMonth$: Observable; + currentReserves$: Observable; + fullHistory$: Observable; currencySubscription: Subscription; currency: string; + private lastPegBlockUpdate: number = 0; + private lastReservesBlockUpdate: number = 0; constructor( public stateService: StateService, @@ -82,35 +89,35 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit { this.stateService.mempoolInfo$, this.stateService.vbytesPerSecond$ ]) - .pipe( - map(([mempoolInfo, vbytesPerSecond]) => { - const percent = Math.round((Math.min(vbytesPerSecond, this.vBytesPerSecondLimit) / this.vBytesPerSecondLimit) * 100); + .pipe( + map(([mempoolInfo, vbytesPerSecond]) => { + const percent = Math.round((Math.min(vbytesPerSecond, this.vBytesPerSecondLimit) / this.vBytesPerSecondLimit) * 100); - let progressColor = 'bg-success'; - if (vbytesPerSecond > 1667) { - progressColor = 'bg-warning'; - } - if (vbytesPerSecond > 3000) { - progressColor = 'bg-danger'; - } + let progressColor = 'bg-success'; + if (vbytesPerSecond > 1667) { + progressColor = 'bg-warning'; + } + if (vbytesPerSecond > 3000) { + progressColor = 'bg-danger'; + } - const mempoolSizePercentage = (mempoolInfo.usage / mempoolInfo.maxmempool * 100); - let mempoolSizeProgress = 'bg-danger'; - if (mempoolSizePercentage <= 50) { - mempoolSizeProgress = 'bg-success'; - } else if (mempoolSizePercentage <= 75) { - mempoolSizeProgress = 'bg-warning'; - } + const mempoolSizePercentage = (mempoolInfo.usage / mempoolInfo.maxmempool * 100); + let mempoolSizeProgress = 'bg-danger'; + if (mempoolSizePercentage <= 50) { + mempoolSizeProgress = 'bg-success'; + } else if (mempoolSizePercentage <= 75) { + mempoolSizeProgress = 'bg-warning'; + } - return { - memPoolInfo: mempoolInfo, - vBytesPerSecond: vbytesPerSecond, - progressWidth: percent + '%', - progressColor: progressColor, - mempoolSizeProgress: mempoolSizeProgress, - }; - }) - ); + return { + memPoolInfo: mempoolInfo, + vBytesPerSecond: vbytesPerSecond, + progressWidth: percent + '%', + progressColor: progressColor, + mempoolSizeProgress: mempoolSizeProgress, + }; + }) + ); this.mempoolBlocksData$ = this.stateService.mempoolBlocks$ .pipe( @@ -204,8 +211,11 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit { ); if (this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') { - this.liquidPegsMonth$ = this.apiService.listLiquidPegsMonth$() + ////////// Pegs historical data ////////// + this.liquidPegsMonth$ = interval(60 * 60 * 1000) .pipe( + startWith(0), + switchMap(() => this.apiService.listLiquidPegsMonth$()), map((pegs) => { const labels = pegs.map(stats => stats.date); const series = pegs.map(stats => parseFloat(stats.amount) / 100000000); @@ -217,6 +227,89 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit { }), share(), ); + + this.currentPeg$ = concat( + // We fetch the current peg when the page load and + // wait for the API response before listening to websocket blocks + this.apiService.liquidPegs$() + .pipe( + tap((currentPeg) => this.lastPegBlockUpdate = currentPeg.lastBlockUpdate) + ), + // Or when we receive a newer block, we wait 2 seconds so that the backend updates and we fetch the current peg + this.stateService.blocks$ + .pipe( + delay(2000), + switchMap((_) => this.apiService.liquidPegs$()), + filter((currentPeg) => currentPeg.lastBlockUpdate > this.lastPegBlockUpdate), + tap((currentPeg) => this.lastPegBlockUpdate = currentPeg.lastBlockUpdate) + ) + ).pipe( + share() + ); + + ////////// BTC Reserves historical data ////////// + this.auditStatus$ = concat( + this.apiService.federationAuditSynced$().pipe(share()), + this.stateService.blocks$.pipe( + skip(1), + delay(2000), + switchMap(() => this.apiService.federationAuditSynced$()), + share() + ) + ); + + this.liquidReservesMonth$ = interval(60 * 60 * 1000).pipe( + startWith(0), + mergeMap(() => this.apiService.federationAuditSynced$()), + switchMap((auditStatus) => { + return auditStatus.isAuditSynced ? this.apiService.listLiquidReservesMonth$() : EMPTY; + }), + map(reserves => { + const labels = reserves.map(stats => stats.date); + const series = reserves.map(stats => parseFloat(stats.amount) / 100000000); + return { + series, + labels + }; + }), + share() + ); + + this.currentReserves$ = this.auditStatus$.pipe( + filter(auditStatus => auditStatus.isAuditSynced === true), + switchMap(_ => + this.apiService.liquidReserves$().pipe( + filter((currentReserves) => currentReserves.lastBlockUpdate > this.lastReservesBlockUpdate), + tap((currentReserves) => { + this.lastReservesBlockUpdate = currentReserves.lastBlockUpdate; + }) + ) + ), + share() + ); + + this.fullHistory$ = combineLatest([this.liquidPegsMonth$, this.currentPeg$, this.liquidReservesMonth$.pipe(startWith(null)), this.currentReserves$.pipe(startWith(null))]) + .pipe( + map(([liquidPegs, currentPeg, liquidReserves, currentReserves]) => { + liquidPegs.series[liquidPegs.series.length - 1] = parseFloat(currentPeg.amount) / 100000000; + + if (liquidPegs.series.length === liquidReserves?.series.length) { + liquidReserves.series[liquidReserves.series.length - 1] = parseFloat(currentReserves?.amount) / 100000000; + } else if (liquidPegs.series.length === liquidReserves?.series.length + 1) { + liquidReserves.series.push(parseFloat(currentReserves?.amount) / 100000000); + } else { + liquidReserves = { + series: [], + labels: [] + }; + } + + return { + liquidPegs, + liquidReserves + }; + }) + ); } this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => { diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 9d936722d..5989be7fa 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -76,6 +76,35 @@ export interface LiquidPegs { date: string; } +export interface CurrentPegs { + amount: string; + lastBlockUpdate: number; + hash: string; +} + +export interface FederationAddress { + address: string; + balance: string; +} + +export interface FederationUtxo { + txid: string; + txindex: number; + bitcoinaddress: string; + amount: number; + blocknumber: number; + blocktime: number; + pegtxid: string; + pegindex: number; +} + +export interface AuditStatus { + bitcoinBlocks: number; + bitcoinHeaders: number; + lastBlockAudit: number; + isAuditSynced: boolean; +} + export interface ITranslators { [language: string]: string; } /** diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 854d15c2a..42dd6a26e 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, Acceleration, AccelerationHistoryParams } from '../interfaces/node-api.interface'; + PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree, BlockAudit, Acceleration, AccelerationHistoryParams, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo } from '../interfaces/node-api.interface'; import { BehaviorSubject, Observable, catchError, filter, of, shareReplay, take, tap } from 'rxjs'; import { StateService } from './state.service'; import { IBackendInfo, WebsocketResponse } from '../interfaces/websocket.interface'; @@ -178,10 +178,42 @@ export class ApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || '')); } + liquidPegs$(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs'); + } + listLiquidPegsMonth$(): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs/month'); } + liquidReserves$(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves'); + } + + listLiquidReservesMonth$(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/month'); + } + + federationAuditSynced$(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/status'); + } + + federationAddresses$(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/addresses'); + } + + federationUtxos$(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/utxos'); + } + + federationAddressesOneMonthAgo$(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/addresses/previous-month'); + } + + federationUtxosOneMonthAgo$(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/utxos/previous-month'); + } + listFeaturedAssets$(): Observable { return this.httpClient.get(this.apiBaseUrl + '/api/v1/assets/featured'); }