From cbd896b9453ea53bb4e6ee49a85dbf7ede07bfb0 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 3 Apr 2025 07:37:57 +0000 Subject: [PATCH] Add basic treasuries dashboard --- backend/src/api/services/services-routes.ts | 10 + backend/src/api/services/wallets.ts | 4 + .../treasuries-graph.component.html | 21 + .../treasuries-graph.component.scss | 59 +++ .../treasuries-graph.component.ts | 412 ++++++++++++++++++ .../treasuries/treasuries.component.html | 9 + .../treasuries/treasuries.component.scss | 78 ++++ .../treasuries/treasuries.component.ts | 257 +++++++++++ .../app/components/wallet/wallet.component.ts | 90 +--- frontend/src/app/graphs/graphs.module.ts | 5 +- .../src/app/graphs/graphs.routing.module.ts | 13 + frontend/src/app/services/api.service.ts | 6 + frontend/src/app/shared/wallet-stats.ts | 89 ++++ 13 files changed, 963 insertions(+), 90 deletions(-) create mode 100644 frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.html create mode 100644 frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.scss create mode 100644 frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.ts create mode 100644 frontend/src/app/components/treasuries/treasuries.component.html create mode 100644 frontend/src/app/components/treasuries/treasuries.component.scss create mode 100644 frontend/src/app/components/treasuries/treasuries.component.ts create mode 100644 frontend/src/app/shared/wallet-stats.ts diff --git a/backend/src/api/services/services-routes.ts b/backend/src/api/services/services-routes.ts index 281bb1602..95c5989fe 100644 --- a/backend/src/api/services/services-routes.ts +++ b/backend/src/api/services/services-routes.ts @@ -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 { + 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(); diff --git a/backend/src/api/services/wallets.ts b/backend/src/api/services/wallets.ts index b68a2e3c4..a03df9c5d 100644 --- a/backend/src/api/services/wallets.ts +++ b/backend/src/api/services/wallets.ts @@ -126,6 +126,10 @@ class WalletApi { } } + public getWallets(): string[] { + return Object.keys(this.wallets); + } + // resync wallet addresses from the services backend async $syncWallets(): Promise { if (!config.WALLETS.ENABLED || this.syncing) { diff --git a/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.html b/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.html new file mode 100644 index 000000000..c9dd072c8 --- /dev/null +++ b/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.html @@ -0,0 +1,21 @@ + + +
+ +
+
+
+
+
+
+ +
+

{{ error }}

+
+
+ +
+
+
+
diff --git a/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.scss b/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.scss new file mode 100644 index 000000000..1b5e0320d --- /dev/null +++ b/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.scss @@ -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; +} \ No newline at end of file diff --git a/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.ts b/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.ts new file mode 100644 index 000000000..53628781f --- /dev/null +++ b/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.ts @@ -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; + @Input() walletSummaries$: Observable>; + @Input() selectedWallets: Record = {}; + @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 = {}; + hoverData: any[] = []; + + subscription: Subscription; + redraw$: BehaviorSubject = 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): 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 = '
'; + const date = new Date(tooltipTime).toLocaleTimeString(this.locale, { + year: 'numeric', month: 'short', day: 'numeric' + }); + + tooltip += `
${date}
`; + + // 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 = ``; + + tooltip += `
+ ${marker} ${walletId}: + ${this.formatBTC(balance)} +
`; + } + } + }); + + tooltip += `
`; + 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); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/treasuries/treasuries.component.html b/frontend/src/app/components/treasuries/treasuries.component.html new file mode 100644 index 000000000..59433f521 --- /dev/null +++ b/frontend/src/app/components/treasuries/treasuries.component.html @@ -0,0 +1,9 @@ +
+
+
+
+ +
+
+
+
\ No newline at end of file diff --git a/frontend/src/app/components/treasuries/treasuries.component.scss b/frontend/src/app/components/treasuries/treasuries.component.scss new file mode 100644 index 000000000..b56bb3ead --- /dev/null +++ b/frontend/src/app/components/treasuries/treasuries.component.scss @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/treasuries/treasuries.component.ts b/frontend/src/app/components/treasuries/treasuries.component.ts new file mode 100644 index 000000000..bce51aea6 --- /dev/null +++ b/frontend/src/app/components/treasuries/treasuries.component.ts @@ -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>; + selectedWallets: Record = {}; + isLoading = true; + error: any; + walletSubscriptions: Subscription[] = []; + + // Individual wallet data + walletObservables: Record>> = {}; + walletAddressesObservables: Record>> = {}; + individualWalletSummaries: Record> = {}; + walletStatsObservables: Record> = {}; + walletStats$: Observable>; + + 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 = {}; + 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 = {}; + const spent: Record = {}; + const fundedCount: Record = {}; + const spentCount: Record = {}; + 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 = {}; + const spent: Record = {}; + const fundedCount: Record = {}; + const spentCount: Record = {}; + 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 = {}; + 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 = {}; + 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(); + 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(); + } + }); + } +} diff --git a/frontend/src/app/components/wallet/wallet.component.ts b/frontend/src/app/components/wallet/wallet.component.ts index 883404231..7df5ef695 100644 --- a/frontend/src/app/components/wallet/wallet.component.ts +++ b/frontend/src/app/components/wallet/wallet.component.ts @@ -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', diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index 73f8f4cfc..fe6f41b55 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -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, diff --git a/frontend/src/app/graphs/graphs.routing.module.ts b/frontend/src/app/graphs/graphs.routing.module.ts index e8dbaece3..1bfdfb808 100644 --- a/frontend/src/app/graphs/graphs.routing.module.ts +++ b/frontend/src/app/graphs/graphs.routing.module.ts @@ -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'] }, diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index d958bfa25..dcd09f0fe 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -523,6 +523,12 @@ export class ApiService { ); } + getWallets$(): Observable { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + `/api/v1/wallets` + ); + } + getWallet$(walletName: string): Observable> { return this.httpClient.get>( this.apiBaseUrl + this.apiBasePath + `/api/v1/wallet/${walletName}` diff --git a/frontend/src/app/shared/wallet-stats.ts b/frontend/src/app/shared/wallet-stats.ts new file mode 100644 index 000000000..98052f9b6 --- /dev/null +++ b/frontend/src/app/shared/wallet-stats.ts @@ -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; + } +} \ No newline at end of file