Add basic treasuries dashboard

This commit is contained in:
Mononaut
2025-04-03 07:37:57 +00:00
parent 9560d44abd
commit cbd896b945
13 changed files with 963 additions and 90 deletions

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();
}
});
}
}

View File

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

View File

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

View File

@@ -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'] },

View File

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

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