mirror of
https://github.com/mempool/mempool.git
synced 2025-09-25 18:01:56 +02:00
Add basic treasuries dashboard
This commit is contained in:
@@ -7,6 +7,7 @@ class ServicesRoutes {
|
||||
public initRoutes(app: Application): void {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'wallet/:walletId', this.$getWallet)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'wallets', this.$getWallets)
|
||||
;
|
||||
}
|
||||
|
||||
@@ -26,6 +27,15 @@ class ServicesRoutes {
|
||||
handleError(req, res, 500, 'Failed to get wallet');
|
||||
}
|
||||
}
|
||||
|
||||
private async $getWallets(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const wallets = await WalletApi.getWallets();
|
||||
res.status(200).send(wallets);
|
||||
} catch (e) {
|
||||
handleError(req, res, 500, 'Failed to get wallets');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ServicesRoutes();
|
||||
|
@@ -126,6 +126,10 @@ class WalletApi {
|
||||
}
|
||||
}
|
||||
|
||||
public getWallets(): string[] {
|
||||
return Object.keys(this.wallets);
|
||||
}
|
||||
|
||||
// resync wallet addresses from the services backend
|
||||
async $syncWallets(): Promise<void> {
|
||||
if (!config.WALLETS.ENABLED || this.syncing) {
|
||||
|
@@ -0,0 +1,21 @@
|
||||
<app-indexing-progress *ngIf="!widget"></app-indexing-progress>
|
||||
|
||||
<div [class.full-container]="!widget">
|
||||
<ng-container *ngIf="!error">
|
||||
<div [class]="!widget ? 'chart' : 'chart-widget'" *browserOnly [style]="{ height: widget ? ((height + 20) + 'px') : null, paddingBottom: !widget && !allowZoom ? '10px' : null}" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||
(chartInit)="onChartInit($event)">
|
||||
</div>
|
||||
<div class="text-center loadingGraphs" *ngIf="isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="error">
|
||||
<div class="error-wrapper">
|
||||
<p class="error">{{ error }}</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,59 @@
|
||||
.card-header {
|
||||
border-bottom: 0;
|
||||
font-size: 18px;
|
||||
@media (min-width: 465px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-title {
|
||||
position: relative;
|
||||
color: var(--fg);
|
||||
opacity: var(--opacity);
|
||||
margin-top: -13px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.full-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0px;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.error-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-size: 15px;
|
||||
color: grey;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.chart-widget {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
@@ -0,0 +1,412 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core';
|
||||
import { echarts, EChartsOption } from '@app/graphs/echarts';
|
||||
import { BehaviorSubject, Observable, Subscription, combineLatest } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { AddressTxSummary } from '@interfaces/electrs.interface';
|
||||
import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe';
|
||||
import { Router } from '@angular/router';
|
||||
import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
|
||||
import { StateService } from '@app/services/state.service';
|
||||
import { FiatCurrencyPipe } from '@app/shared/pipes/fiat-currency.pipe';
|
||||
import { SeriesOption } from 'echarts';
|
||||
import { WalletStats } from '@app/shared/wallet-stats';
|
||||
|
||||
const periodSeconds = {
|
||||
'1d': (60 * 60 * 24),
|
||||
'3d': (60 * 60 * 24 * 3),
|
||||
'1w': (60 * 60 * 24 * 7),
|
||||
'1m': (60 * 60 * 24 * 30),
|
||||
'6m': (60 * 60 * 24 * 180),
|
||||
'1y': (60 * 60 * 24 * 365),
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-treasuries-graph',
|
||||
templateUrl: './treasuries-graph.component.html',
|
||||
styleUrls: ['./treasuries-graph.component.scss'],
|
||||
styles: [`
|
||||
.loadingGraphs {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: calc(50% - 15px);
|
||||
z-index: 99;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TreasuriesGraphComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Input() walletStats: Record<string, WalletStats>;
|
||||
@Input() walletSummaries$: Observable<Record<string, AddressTxSummary[]>>;
|
||||
@Input() selectedWallets: Record<string, boolean> = {};
|
||||
@Input() height: number = 400;
|
||||
@Input() right: number | string = 10;
|
||||
@Input() left: number | string = 70;
|
||||
@Input() showLegend: boolean = true;
|
||||
@Input() showYAxis: boolean = true;
|
||||
@Input() widget: boolean = false;
|
||||
@Input() allowZoom: boolean = false;
|
||||
@Input() period: '1d' | '3d' | '1w' | '1m' | '6m' | '1y' | 'all' = 'all';
|
||||
|
||||
adjustedLeft: number = 70;
|
||||
adjustedRight: number = 10;
|
||||
walletData: Record<string, any[]> = {};
|
||||
hoverData: any[] = [];
|
||||
|
||||
subscription: Subscription;
|
||||
redraw$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||
|
||||
chartOptions: EChartsOption = {};
|
||||
chartInitOptions = {
|
||||
renderer: 'svg',
|
||||
};
|
||||
|
||||
error: any;
|
||||
isLoading = true;
|
||||
chartInstance: any = undefined;
|
||||
|
||||
// Color palette for multiple wallets
|
||||
colorPalette = [
|
||||
'#2196F3',
|
||||
'#9C27B0',
|
||||
'#F44336',
|
||||
'#FDD835',
|
||||
'#4CAF50'
|
||||
];
|
||||
|
||||
constructor(
|
||||
@Inject(LOCALE_ID) public locale: string,
|
||||
public stateService: StateService,
|
||||
private router: Router,
|
||||
private amountShortenerPipe: AmountShortenerPipe,
|
||||
private cd: ChangeDetectorRef,
|
||||
private relativeUrlPipe: RelativeUrlPipe,
|
||||
private fiatCurrencyPipe: FiatCurrencyPipe,
|
||||
private zone: NgZone,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.isLoading = true;
|
||||
this.setupSubscription();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
this.adjustedRight = +this.right;
|
||||
this.adjustedLeft = +this.left;
|
||||
|
||||
if (changes.walletSummaries$ || changes.selectedWallets || changes.period) {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
this.setupSubscription();
|
||||
} else {
|
||||
// re-trigger subscription
|
||||
this.redraw$.next(true);
|
||||
}
|
||||
}
|
||||
|
||||
setupSubscription(): void {
|
||||
this.subscription = combineLatest([
|
||||
this.redraw$,
|
||||
this.walletSummaries$
|
||||
]).pipe(
|
||||
tap(([_, walletSummaries]) => {
|
||||
if (walletSummaries) {
|
||||
this.error = null;
|
||||
this.processWalletData(walletSummaries);
|
||||
this.prepareChartOptions();
|
||||
}
|
||||
this.isLoading = false;
|
||||
this.cd.markForCheck();
|
||||
})
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
processWalletData(walletSummaries: Record<string, AddressTxSummary[]>): void {
|
||||
this.walletData = {};
|
||||
|
||||
Object.entries(walletSummaries).forEach(([walletId, summary]) => {
|
||||
if (!summary || !summary.length) return;
|
||||
|
||||
const total = this.walletStats[walletId] ? this.walletStats[walletId].balance : summary.reduce((acc, tx) => acc + tx.value, 0);
|
||||
|
||||
let runningTotal = total;
|
||||
const processedData = summary.map(tx => {
|
||||
const balance = runningTotal;
|
||||
runningTotal -= tx.value;
|
||||
return {
|
||||
time: tx.time * 1000,
|
||||
balance,
|
||||
tx
|
||||
};
|
||||
}).reverse();
|
||||
|
||||
this.walletData[walletId] = processedData
|
||||
.filter(({ tx }) => tx.txid !== undefined)
|
||||
.map(({ time, balance, tx }) => [time, balance, tx]);
|
||||
|
||||
if (this.period !== 'all') {
|
||||
const now = Date.now();
|
||||
const start = now - (periodSeconds[this.period] * 1000);
|
||||
|
||||
const fullData = [...this.walletData[walletId]];
|
||||
|
||||
this.walletData[walletId] = this.walletData[walletId].filter(d => d[0] >= start);
|
||||
|
||||
if (this.walletData[walletId].length === 0 || this.walletData[walletId][0][0] > start) {
|
||||
// Find the most recent balance at or before the period start
|
||||
let startBalance = 0;
|
||||
for (let i = fullData.length - 1; i >= 0; i--) {
|
||||
if (fullData[i][0] <= start) {
|
||||
startBalance = fullData[i][1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add a data point at the period start with the correct historical balance
|
||||
this.walletData[walletId].unshift([start, startBalance, { placeholder: true }]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add current point
|
||||
this.walletData[walletId].push([Date.now(), total, { current: true }]);
|
||||
});
|
||||
}
|
||||
|
||||
prepareChartOptions(): void {
|
||||
// Prepare legend data
|
||||
const legendData = Object.keys(this.walletData).map(walletId => ({
|
||||
name: walletId,
|
||||
inactiveColor: 'var(--grey)',
|
||||
textStyle: {
|
||||
color: 'white',
|
||||
},
|
||||
icon: 'roundRect',
|
||||
}));
|
||||
|
||||
// Calculate min and max values across all wallets
|
||||
let maxValue = 0;
|
||||
let minValue = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
Object.values(this.walletData).forEach(data => {
|
||||
data.forEach(point => {
|
||||
const value = point[1] || (point.value && point.value[1]) || 0;
|
||||
maxValue = Math.max(maxValue, Math.abs(value));
|
||||
minValue = Math.min(minValue, Math.abs(value));
|
||||
});
|
||||
});
|
||||
|
||||
if (minValue === Number.MAX_SAFE_INTEGER) {
|
||||
minValue = 0;
|
||||
}
|
||||
|
||||
// Prepare series data
|
||||
const series: SeriesOption[] = Object.entries(this.walletData).map(([walletId, data], index) => ({
|
||||
name: walletId,
|
||||
yAxisIndex: 0,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
data: data,
|
||||
areaStyle: undefined,
|
||||
triggerLineEvent: true,
|
||||
type: 'line',
|
||||
smooth: false,
|
||||
step: 'end'
|
||||
}));
|
||||
|
||||
this.chartOptions = {
|
||||
color: this.colorPalette,
|
||||
animation: false,
|
||||
grid: {
|
||||
top: 20,
|
||||
bottom: this.allowZoom ? 65 : 20,
|
||||
right: this.adjustedRight,
|
||||
left: this.adjustedLeft,
|
||||
},
|
||||
legend: this.showLegend ? {
|
||||
data: legendData,
|
||||
selected: this.selectedWallets,
|
||||
formatter: function (name) {
|
||||
return name;
|
||||
}
|
||||
} : undefined,
|
||||
tooltip: {
|
||||
show: !this.isMobile(),
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'line'
|
||||
},
|
||||
backgroundColor: 'rgba(17, 19, 31, 1)',
|
||||
borderRadius: 4,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
textStyle: {
|
||||
color: '#b1b1b1',
|
||||
align: 'left',
|
||||
},
|
||||
borderColor: '#000',
|
||||
formatter: function (data) {
|
||||
if (!data.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Get the current x-axis timestamp from the hovered point
|
||||
const tooltipTime = data[0].data[0];
|
||||
|
||||
let tooltip = '<div>';
|
||||
const date = new Date(tooltipTime).toLocaleTimeString(this.locale, {
|
||||
year: 'numeric', month: 'short', day: 'numeric'
|
||||
});
|
||||
|
||||
tooltip += `<div><b style="color: white; margin-left: 2px">${date}</b><br>`;
|
||||
|
||||
// Get all active wallet IDs from the selected wallets
|
||||
const activeWalletIds = Object.keys(this.selectedWallets)
|
||||
.filter(walletId => this.selectedWallets[walletId] && this.walletData[walletId]);
|
||||
|
||||
// For each active wallet, find and display the most recent balance
|
||||
activeWalletIds.forEach((walletId, index) => {
|
||||
const walletPoints = this.walletData[walletId];
|
||||
if (!walletPoints || !walletPoints.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the most recent data point at or before the tooltip time
|
||||
let mostRecentPoint: any = null;
|
||||
for (let i = 0; i < walletPoints.length; i++) {
|
||||
const point: any = walletPoints[i];
|
||||
const pointTime = Array.isArray(point) ? point[0] :
|
||||
(point && typeof point === 'object' && 'value' in point ? point.value[0] : null);
|
||||
|
||||
if (pointTime && pointTime <= tooltipTime) {
|
||||
mostRecentPoint = point;
|
||||
}
|
||||
|
||||
// Stop once we pass the tooltip time
|
||||
if (pointTime && pointTime > tooltipTime) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (mostRecentPoint) {
|
||||
// Extract balance from the point
|
||||
const balance = Array.isArray(mostRecentPoint) ? mostRecentPoint[1] :
|
||||
(mostRecentPoint && typeof mostRecentPoint === 'object' && 'value' in mostRecentPoint ? mostRecentPoint.value[1] : null);
|
||||
|
||||
if (balance !== null && !isNaN(balance)) {
|
||||
// Create a marker for this series using the color from colorPalette
|
||||
const colorIndex = index % this.colorPalette.length;
|
||||
|
||||
// Get color for marker - use direct color from palette
|
||||
const markerColor = this.colorPalette[colorIndex];
|
||||
|
||||
const marker = `<span style="display:inline-block;margin-right:4px;border-radius:10px;width:10px;height:10px;background-color:${markerColor};"></span>`;
|
||||
|
||||
tooltip += `<div style="display: flex; justify-content: space-between;">
|
||||
<span style="text-align: left; margin-right: 10px;">${marker} ${walletId}:</span>
|
||||
<span style="text-align: right;">${this.formatBTC(balance)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tooltip += `</div></div>`;
|
||||
return tooltip;
|
||||
}.bind(this)
|
||||
},
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
splitNumber: this.isMobile() ? 5 : 10,
|
||||
axisLabel: {
|
||||
hideOverlap: true,
|
||||
}
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
position: 'left',
|
||||
axisLabel: {
|
||||
show: this.showYAxis,
|
||||
color: 'rgb(110, 112, 121)',
|
||||
formatter: (val): string => {
|
||||
let valSpan = maxValue - (this.period === 'all' ? 0 : minValue);
|
||||
if (valSpan > 100_000_000_000) {
|
||||
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0, undefined, true)} BTC`;
|
||||
}
|
||||
else if (valSpan > 1_000_000_000) {
|
||||
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2, undefined, true)} BTC`;
|
||||
} else if (valSpan > 100_000_000) {
|
||||
return `${(val / 100_000_000).toFixed(1)} BTC`;
|
||||
} else if (valSpan > 10_000_000) {
|
||||
return `${(val / 100_000_000).toFixed(2)} BTC`;
|
||||
} else if (valSpan > 1_000_000) {
|
||||
return `${(val / 100_000_000).toFixed(3)} BTC`;
|
||||
} else {
|
||||
return `${this.amountShortenerPipe.transform(val, 0, undefined, true)} sats`;
|
||||
}
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
min: this.period === 'all' ? 0 : 'dataMin'
|
||||
}
|
||||
],
|
||||
series: series,
|
||||
dataZoom: this.allowZoom ? [{
|
||||
type: 'inside',
|
||||
realtime: true,
|
||||
zoomLock: true,
|
||||
maxSpan: 100,
|
||||
minSpan: 5,
|
||||
moveOnMouseMove: false,
|
||||
}, {
|
||||
showDetail: false,
|
||||
show: true,
|
||||
type: 'slider',
|
||||
brushSelect: false,
|
||||
realtime: true,
|
||||
left: this.adjustedLeft,
|
||||
right: this.adjustedRight,
|
||||
selectedDataBackground: {
|
||||
lineStyle: {
|
||||
color: '#fff',
|
||||
opacity: 0.45,
|
||||
},
|
||||
},
|
||||
}] : undefined
|
||||
};
|
||||
}
|
||||
|
||||
formatBTC(val: number): string {
|
||||
return `${(val / 100_000_000).toFixed(4)} BTC`;
|
||||
}
|
||||
|
||||
onChartInit(ec) {
|
||||
this.chartInstance = ec;
|
||||
this.chartInstance.on('legendselectchanged', this.onLegendSelectChanged.bind(this));
|
||||
}
|
||||
|
||||
onLegendSelectChanged(e) {
|
||||
this.selectedWallets = e.selected;
|
||||
|
||||
this.chartOptions = {
|
||||
legend: {
|
||||
selected: this.selectedWallets,
|
||||
}
|
||||
};
|
||||
|
||||
if (this.chartInstance) {
|
||||
this.chartInstance.setOption(this.chartOptions);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
isMobile() {
|
||||
return (window.innerWidth <= 767.98);
|
||||
}
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
<div class="container-xl dashboard-container">
|
||||
<div class="box">
|
||||
<div class="row">
|
||||
<div class="col-md" *ngIf="walletStats$ | async as walletStats">
|
||||
<app-treasuries-graph [walletStats]="walletStats" [walletSummaries$]="walletSummaries$" [selectedWallets]="selectedWallets" [height]="400" [right]="10" [left]="70" [showLegend]="true" [showYAxis]="true" [widget]="false" [allowZoom]="false" period="1m" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,78 @@
|
||||
.dashboard-container {
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
.col {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--bg);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--title-fg);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
background-color: var(--secondary);
|
||||
height: 1.1rem;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.bg-warning {
|
||||
background-color: #b58800 !important;
|
||||
}
|
||||
|
||||
.skeleton-loader {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.more-padding {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.graph-card {
|
||||
height: 100%;
|
||||
@media (min-width: 768px) {
|
||||
height: 415px;
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
height: 510px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-title {
|
||||
position: relative;
|
||||
color: #ffffff91;
|
||||
margin-top: -13px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.card-wrapper {
|
||||
.card {
|
||||
height: auto !important;
|
||||
}
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex: inherit;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
padding: 22px 20px;
|
||||
&.liquid {
|
||||
height: 124.5px;
|
||||
}
|
||||
}
|
||||
.less-padding {
|
||||
padding: 20px 20px;
|
||||
}
|
||||
}
|
257
frontend/src/app/components/treasuries/treasuries.component.ts
Normal file
257
frontend/src/app/components/treasuries/treasuries.component.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Observable, combineLatest, of, Subscription } from 'rxjs';
|
||||
import { AddressTxSummary, Transaction, Address } from '@interfaces/electrs.interface';
|
||||
import { ApiService } from '@app/services/api.service';
|
||||
import { StateService } from '@app/services/state.service';
|
||||
import { catchError, map, scan, shareReplay, startWith, switchMap, tap } from 'rxjs/operators';
|
||||
import { WalletStats } from '@app/shared/wallet-stats';
|
||||
import { ElectrsApiService } from '@app/services/electrs-api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-treasuries',
|
||||
templateUrl: './treasuries.component.html',
|
||||
styleUrls: ['./treasuries.component.scss']
|
||||
})
|
||||
export class TreasuriesComponent implements OnInit, OnDestroy {
|
||||
wallets: string[] = [];
|
||||
walletSummaries$: Observable<Record<string, AddressTxSummary[]>>;
|
||||
selectedWallets: Record<string, boolean> = {};
|
||||
isLoading = true;
|
||||
error: any;
|
||||
walletSubscriptions: Subscription[] = [];
|
||||
|
||||
// Individual wallet data
|
||||
walletObservables: Record<string, Observable<Record<string, any>>> = {};
|
||||
walletAddressesObservables: Record<string, Observable<Record<string, Address>>> = {};
|
||||
individualWalletSummaries: Record<string, Observable<AddressTxSummary[]>> = {};
|
||||
walletStatsObservables: Record<string, Observable<WalletStats>> = {};
|
||||
walletStats$: Observable<Record<string, WalletStats>>;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private stateService: StateService,
|
||||
private electrsApiService: ElectrsApiService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
// Fetch the list of wallets from the API
|
||||
this.apiService.getWallets$().pipe(
|
||||
catchError(err => {
|
||||
console.error('Error loading wallets list:', err);
|
||||
return of([]);
|
||||
})
|
||||
).subscribe(wallets => {
|
||||
this.wallets = wallets;
|
||||
|
||||
// Initialize all wallets as enabled by default
|
||||
this.wallets.forEach(wallet => {
|
||||
this.selectedWallets[wallet] = true;
|
||||
});
|
||||
|
||||
// Set up wallet data after we have the wallet list
|
||||
this.setupWalletData();
|
||||
});
|
||||
}
|
||||
|
||||
private setupWalletData() {
|
||||
this.wallets.forEach(walletName => {
|
||||
this.walletObservables[walletName] = this.apiService.getWallet$(walletName).pipe(
|
||||
catchError((err) => {
|
||||
console.log(`Error loading wallet ${walletName}:`, err);
|
||||
return of({});
|
||||
}),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
this.walletAddressesObservables[walletName] = this.walletObservables[walletName].pipe(
|
||||
map(wallet => {
|
||||
const walletInfo: Record<string, Address> = {};
|
||||
for (const address of Object.keys(wallet || {})) {
|
||||
walletInfo[address] = {
|
||||
address,
|
||||
chain_stats: wallet[address]?.stats || {
|
||||
funded_txo_count: 0,
|
||||
funded_txo_sum: 0,
|
||||
spent_txo_count: 0,
|
||||
spent_txo_sum: 0,
|
||||
tx_count: 0
|
||||
},
|
||||
mempool_stats: {
|
||||
funded_txo_count: 0,
|
||||
funded_txo_sum: 0,
|
||||
spent_txo_count: 0,
|
||||
spent_txo_sum: 0,
|
||||
tx_count: 0
|
||||
},
|
||||
};
|
||||
}
|
||||
return walletInfo;
|
||||
}),
|
||||
switchMap(initial => this.stateService.walletTransactions$.pipe(
|
||||
startWith(null),
|
||||
scan((wallet, walletTransactions) => {
|
||||
for (const tx of (walletTransactions || [])) {
|
||||
const funded: Record<string, number> = {};
|
||||
const spent: Record<string, number> = {};
|
||||
const fundedCount: Record<string, number> = {};
|
||||
const spentCount: Record<string, number> = {};
|
||||
for (const vin of tx.vin || []) {
|
||||
const address = vin.prevout?.scriptpubkey_address;
|
||||
if (address && wallet[address]) {
|
||||
spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0);
|
||||
spentCount[address] = (spentCount[address] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
for (const vout of tx.vout || []) {
|
||||
const address = vout.scriptpubkey_address;
|
||||
if (address && wallet[address]) {
|
||||
funded[address] = (funded[address] ?? 0) + (vout.value ?? 0);
|
||||
fundedCount[address] = (fundedCount[address] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
for (const address of Object.keys({ ...funded, ...spent })) {
|
||||
// update address stats
|
||||
wallet[address].chain_stats.tx_count++;
|
||||
wallet[address].chain_stats.funded_txo_count += fundedCount[address] || 0;
|
||||
wallet[address].chain_stats.spent_txo_count += spentCount[address] || 0;
|
||||
wallet[address].chain_stats.funded_txo_sum += funded[address] || 0;
|
||||
wallet[address].chain_stats.spent_txo_sum += spent[address] || 0;
|
||||
}
|
||||
}
|
||||
return wallet;
|
||||
}, initial)
|
||||
)),
|
||||
);
|
||||
|
||||
this.individualWalletSummaries[walletName] = this.walletObservables[walletName].pipe(
|
||||
switchMap(wallet => this.stateService.walletTransactions$.pipe(
|
||||
startWith([]),
|
||||
scan((summaries, newTransactions: Transaction[]) => {
|
||||
const newSummaries: AddressTxSummary[] = [];
|
||||
for (const tx of newTransactions || []) {
|
||||
const funded: Record<string, number> = {};
|
||||
const spent: Record<string, number> = {};
|
||||
const fundedCount: Record<string, number> = {};
|
||||
const spentCount: Record<string, number> = {};
|
||||
for (const vin of tx.vin || []) {
|
||||
const address = vin.prevout?.scriptpubkey_address;
|
||||
if (address && wallet[address]) {
|
||||
spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0);
|
||||
spentCount[address] = (spentCount[address] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
for (const vout of tx.vout || []) {
|
||||
const address = vout.scriptpubkey_address;
|
||||
if (address && wallet[address]) {
|
||||
funded[address] = (funded[address] ?? 0) + (vout.value ?? 0);
|
||||
fundedCount[address] = (fundedCount[address] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
for (const address of Object.keys({ ...funded, ...spent })) {
|
||||
// add tx to summary
|
||||
const txSummary: AddressTxSummary = {
|
||||
txid: tx.txid,
|
||||
value: (funded[address] ?? 0) - (spent[address] ?? 0),
|
||||
height: tx.status.block_height,
|
||||
time: tx.status.block_time,
|
||||
};
|
||||
if (wallet[address]?.transactions) {
|
||||
wallet[address].transactions.push(txSummary);
|
||||
} else if (wallet[address]) {
|
||||
wallet[address].transactions = [txSummary];
|
||||
}
|
||||
newSummaries.push(txSummary);
|
||||
}
|
||||
}
|
||||
return this.deduplicateWalletTransactions([...summaries, ...newSummaries]);
|
||||
}, this.deduplicateWalletTransactions(
|
||||
Object.values(wallet || {}).flatMap(address => address?.transactions || [])
|
||||
))
|
||||
))
|
||||
);
|
||||
|
||||
this.walletStatsObservables[walletName] = this.walletObservables[walletName].pipe(
|
||||
switchMap(wallet => {
|
||||
const walletStats = new WalletStats(
|
||||
Object.values(wallet || {}).map(w => w?.stats || {}),
|
||||
Object.keys(wallet || {})
|
||||
);
|
||||
return of(walletStats);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const walletSummaryKeys = Object.keys(this.individualWalletSummaries);
|
||||
const walletSummaryObservables = walletSummaryKeys.map(key => this.individualWalletSummaries[key]);
|
||||
|
||||
this.walletSummaries$ = combineLatest(walletSummaryObservables).pipe(
|
||||
map((summaries) => {
|
||||
const result: Record<string, AddressTxSummary[]> = {};
|
||||
summaries.forEach((summary, index) => {
|
||||
if (summary && summary.length > 0) {
|
||||
result[walletSummaryKeys[index]] = summary;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
tap((data) => {
|
||||
this.selectedWallets = {};
|
||||
Object.keys(data).forEach(wallet => {
|
||||
this.selectedWallets[wallet] = true;
|
||||
});
|
||||
this.isLoading = false;
|
||||
}),
|
||||
shareReplay(1),
|
||||
catchError(err => {
|
||||
this.error = err;
|
||||
console.log(err);
|
||||
return of({});
|
||||
})
|
||||
);
|
||||
|
||||
const walletStatsKeys = Object.keys(this.walletStatsObservables);
|
||||
const walletStatsObservables = walletStatsKeys.map(key => this.walletStatsObservables[key]);
|
||||
|
||||
this.walletStats$ = combineLatest(walletStatsObservables).pipe(
|
||||
map((stats) => {
|
||||
const result: Record<string, WalletStats> = {};
|
||||
stats.forEach((stat, index) => {
|
||||
result[walletStatsKeys[index]] = stat;
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
shareReplay(1),
|
||||
catchError(err => {
|
||||
this.error = err;
|
||||
console.log(err);
|
||||
return of({});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] {
|
||||
const transactions = new Map<string, AddressTxSummary>();
|
||||
for (const tx of walletTransactions) {
|
||||
if (transactions.has(tx.txid)) {
|
||||
transactions.get(tx.txid).value += tx.value;
|
||||
} else {
|
||||
transactions.set(tx.txid, tx);
|
||||
}
|
||||
}
|
||||
return Array.from(transactions.values()).sort((a, b) => {
|
||||
if (a.height === b.height) {
|
||||
return (b.tx_position ?? 0) - (a.tx_position ?? 0);
|
||||
}
|
||||
return b.height - a.height;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
// Clean up subscriptions
|
||||
this.walletSubscriptions.forEach(sub => {
|
||||
if (sub) {
|
||||
sub.unsubscribe();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@@ -12,95 +12,7 @@ import { WalletAddress } from '@interfaces/node-api.interface';
|
||||
import { ElectrsApiService } from '@app/services/electrs-api.service';
|
||||
import { AudioService } from '@app/services/audio.service';
|
||||
import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
|
||||
|
||||
|
||||
class WalletStats implements ChainStats {
|
||||
addresses: string[];
|
||||
funded_txo_count: number;
|
||||
funded_txo_sum: number;
|
||||
spent_txo_count: number;
|
||||
spent_txo_sum: number;
|
||||
tx_count: number;
|
||||
|
||||
constructor (stats: ChainStats[], addresses: string[]) {
|
||||
Object.assign(this, stats.reduce((acc, stat) => {
|
||||
acc.funded_txo_count += stat.funded_txo_count;
|
||||
acc.funded_txo_sum += stat.funded_txo_sum;
|
||||
acc.spent_txo_count += stat.spent_txo_count;
|
||||
acc.spent_txo_sum += stat.spent_txo_sum;
|
||||
acc.tx_count += stat.tx_count;
|
||||
return acc;
|
||||
}, {
|
||||
funded_txo_count: 0,
|
||||
funded_txo_sum: 0,
|
||||
spent_txo_count: 0,
|
||||
spent_txo_sum: 0,
|
||||
tx_count: 0,
|
||||
})
|
||||
);
|
||||
this.addresses = addresses;
|
||||
}
|
||||
|
||||
public addTx(tx: Transaction): void {
|
||||
for (const vin of tx.vin) {
|
||||
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
|
||||
this.spendTxo(vin.prevout.value);
|
||||
}
|
||||
}
|
||||
for (const vout of tx.vout) {
|
||||
if (this.addresses.includes(vout.scriptpubkey_address)) {
|
||||
this.fundTxo(vout.value);
|
||||
}
|
||||
}
|
||||
this.tx_count++;
|
||||
}
|
||||
|
||||
public removeTx(tx: Transaction): void {
|
||||
for (const vin of tx.vin) {
|
||||
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
|
||||
this.unspendTxo(vin.prevout.value);
|
||||
}
|
||||
}
|
||||
for (const vout of tx.vout) {
|
||||
if (this.addresses.includes(vout.scriptpubkey_address)) {
|
||||
this.unfundTxo(vout.value);
|
||||
}
|
||||
}
|
||||
this.tx_count--;
|
||||
}
|
||||
|
||||
private fundTxo(value: number): void {
|
||||
this.funded_txo_sum += value;
|
||||
this.funded_txo_count++;
|
||||
}
|
||||
|
||||
private unfundTxo(value: number): void {
|
||||
this.funded_txo_sum -= value;
|
||||
this.funded_txo_count--;
|
||||
}
|
||||
|
||||
private spendTxo(value: number): void {
|
||||
this.spent_txo_sum += value;
|
||||
this.spent_txo_count++;
|
||||
}
|
||||
|
||||
private unspendTxo(value: number): void {
|
||||
this.spent_txo_sum -= value;
|
||||
this.spent_txo_count--;
|
||||
}
|
||||
|
||||
get balance(): number {
|
||||
return this.funded_txo_sum - this.spent_txo_sum;
|
||||
}
|
||||
|
||||
get totalReceived(): number {
|
||||
return this.funded_txo_sum;
|
||||
}
|
||||
|
||||
get utxos(): number {
|
||||
return this.funded_txo_count - this.spent_txo_count;
|
||||
}
|
||||
}
|
||||
import { WalletStats } from '@app/shared/wallet-stats';
|
||||
|
||||
@Component({
|
||||
selector: 'app-wallet',
|
||||
|
@@ -30,6 +30,7 @@ import { DashboardComponent } from '@app/dashboard/dashboard.component';
|
||||
import { CustomDashboardComponent } from '@components/custom-dashboard/custom-dashboard.component';
|
||||
import { MiningDashboardComponent } from '@components/mining-dashboard/mining-dashboard.component';
|
||||
import { AcceleratorDashboardComponent } from '@components/acceleration/accelerator-dashboard/accelerator-dashboard.component';
|
||||
import { TreasuriesComponent } from '@components/treasuries/treasuries.component';
|
||||
import { HashrateChartComponent } from '@components/hashrate-chart/hashrate-chart.component';
|
||||
import { HashrateChartPoolsComponent } from '@components/hashrates-chart-pools/hashrate-chart-pools.component';
|
||||
import { BlockHealthGraphComponent } from '@components/block-health-graph/block-health-graph.component';
|
||||
@@ -37,6 +38,7 @@ import { AddressComponent } from '@components/address/address.component';
|
||||
import { WalletComponent } from '@components/wallet/wallet.component';
|
||||
import { WalletPreviewComponent } from '@components/wallet/wallet-preview.component';
|
||||
import { AddressGraphComponent } from '@components/address-graph/address-graph.component';
|
||||
import { TreasuriesGraphComponent } from '@components/treasuries/treasuries-graph/treasuries-graph.component';
|
||||
import { UtxoGraphComponent } from '@components/utxo-graph/utxo-graph.component';
|
||||
import { ActiveAccelerationBox } from '@components/acceleration/active-acceleration-box/active-acceleration-box.component';
|
||||
import { AddressesTreemap } from '@components/addresses-treemap/addresses-treemap.component';
|
||||
@@ -57,7 +59,7 @@ import { AsmStylerPipe } from '@app/shared/pipes/asm-styler/asm-styler.pipe';
|
||||
AcceleratorDashboardComponent,
|
||||
PoolComponent,
|
||||
PoolRankingComponent,
|
||||
|
||||
TreasuriesComponent,
|
||||
StatisticsComponent,
|
||||
GraphsComponent,
|
||||
AccelerationFeesGraphComponent,
|
||||
@@ -82,6 +84,7 @@ import { AsmStylerPipe } from '@app/shared/pipes/asm-styler/asm-styler.pipe';
|
||||
HashrateChartPoolsComponent,
|
||||
BlockHealthGraphComponent,
|
||||
AddressGraphComponent,
|
||||
TreasuriesGraphComponent,
|
||||
UtxoGraphComponent,
|
||||
ActiveAccelerationBox,
|
||||
AddressesTreemap,
|
||||
|
@@ -18,6 +18,7 @@ import { StartComponent } from '@components/start/start.component';
|
||||
import { StatisticsComponent } from '@components/statistics/statistics.component';
|
||||
import { DashboardComponent } from '@app/dashboard/dashboard.component';
|
||||
import { CustomDashboardComponent } from '@components/custom-dashboard/custom-dashboard.component';
|
||||
import { TreasuriesComponent } from '@components/treasuries/treasuries.component';
|
||||
import { AccelerationFeesGraphComponent } from '@components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component';
|
||||
import { AccelerationsListComponent } from '@components/acceleration/accelerations-list/accelerations-list.component';
|
||||
import { AddressComponent } from '@components/address/address.component';
|
||||
@@ -97,6 +98,18 @@ const routes: Routes = [
|
||||
networkSpecific: true,
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'treasuries',
|
||||
component: StartComponent,
|
||||
children: [{
|
||||
path: '',
|
||||
component: TreasuriesComponent,
|
||||
data: {
|
||||
networks: ['bitcoin'],
|
||||
networkSpecific: true,
|
||||
},
|
||||
}]
|
||||
},
|
||||
{
|
||||
path: 'graphs',
|
||||
data: { networks: ['bitcoin', 'liquid'] },
|
||||
|
@@ -523,6 +523,12 @@ export class ApiService {
|
||||
);
|
||||
}
|
||||
|
||||
getWallets$(): Observable<string[]> {
|
||||
return this.httpClient.get<string[]>(
|
||||
this.apiBaseUrl + this.apiBasePath + `/api/v1/wallets`
|
||||
);
|
||||
}
|
||||
|
||||
getWallet$(walletName: string): Observable<Record<string, WalletAddress>> {
|
||||
return this.httpClient.get<Record<string, WalletAddress>>(
|
||||
this.apiBaseUrl + this.apiBasePath + `/api/v1/wallet/${walletName}`
|
||||
|
89
frontend/src/app/shared/wallet-stats.ts
Normal file
89
frontend/src/app/shared/wallet-stats.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { ChainStats, Transaction } from '@interfaces/electrs.interface';
|
||||
|
||||
export class WalletStats implements ChainStats {
|
||||
addresses: string[];
|
||||
funded_txo_count: number;
|
||||
funded_txo_sum: number;
|
||||
spent_txo_count: number;
|
||||
spent_txo_sum: number;
|
||||
tx_count: number;
|
||||
|
||||
constructor (stats: ChainStats[], addresses: string[]) {
|
||||
Object.assign(this, stats.reduce((acc, stat) => {
|
||||
acc.funded_txo_count += stat.funded_txo_count;
|
||||
acc.funded_txo_sum += stat.funded_txo_sum;
|
||||
acc.spent_txo_count += stat.spent_txo_count;
|
||||
acc.spent_txo_sum += stat.spent_txo_sum;
|
||||
acc.tx_count += stat.tx_count;
|
||||
return acc;
|
||||
}, {
|
||||
funded_txo_count: 0,
|
||||
funded_txo_sum: 0,
|
||||
spent_txo_count: 0,
|
||||
spent_txo_sum: 0,
|
||||
tx_count: 0,
|
||||
})
|
||||
);
|
||||
this.addresses = addresses;
|
||||
}
|
||||
|
||||
public addTx(tx: Transaction): void {
|
||||
for (const vin of tx.vin) {
|
||||
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
|
||||
this.spendTxo(vin.prevout.value);
|
||||
}
|
||||
}
|
||||
for (const vout of tx.vout) {
|
||||
if (this.addresses.includes(vout.scriptpubkey_address)) {
|
||||
this.fundTxo(vout.value);
|
||||
}
|
||||
}
|
||||
this.tx_count++;
|
||||
}
|
||||
|
||||
public removeTx(tx: Transaction): void {
|
||||
for (const vin of tx.vin) {
|
||||
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
|
||||
this.unspendTxo(vin.prevout.value);
|
||||
}
|
||||
}
|
||||
for (const vout of tx.vout) {
|
||||
if (this.addresses.includes(vout.scriptpubkey_address)) {
|
||||
this.unfundTxo(vout.value);
|
||||
}
|
||||
}
|
||||
this.tx_count--;
|
||||
}
|
||||
|
||||
private fundTxo(value: number): void {
|
||||
this.funded_txo_sum += value;
|
||||
this.funded_txo_count++;
|
||||
}
|
||||
|
||||
private unfundTxo(value: number): void {
|
||||
this.funded_txo_sum -= value;
|
||||
this.funded_txo_count--;
|
||||
}
|
||||
|
||||
private spendTxo(value: number): void {
|
||||
this.spent_txo_sum += value;
|
||||
this.spent_txo_count++;
|
||||
}
|
||||
|
||||
private unspendTxo(value: number): void {
|
||||
this.spent_txo_sum -= value;
|
||||
this.spent_txo_count--;
|
||||
}
|
||||
|
||||
get balance(): number {
|
||||
return this.funded_txo_sum - this.spent_txo_sum;
|
||||
}
|
||||
|
||||
get totalReceived(): number {
|
||||
return this.funded_txo_sum;
|
||||
}
|
||||
|
||||
get utxos(): number {
|
||||
return this.funded_txo_count - this.spent_txo_count;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user