Merge branch 'master' into mononaut/sighash-highlighting

This commit is contained in:
wiz
2025-05-08 23:54:31 -10:00
committed by GitHub
17 changed files with 1649 additions and 92 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 + 'treasuries', this.$getTreasuries)
;
}
@@ -26,6 +27,15 @@ class ServicesRoutes {
handleError(req, res, 500, 'Failed to get wallet');
}
}
private async $getTreasuries(req: Request, res: Response): Promise<void> {
try {
const treasuries = await WalletApi.getTreasuries();
res.status(200).send(treasuries);
} catch (e) {
handleError(req, res, 500, 'Failed to get treasuries');
}
}
}
export default new ServicesRoutes();

View File

@@ -26,9 +26,17 @@ interface Wallet {
lastPoll: number;
}
interface Treasury {
id: number,
name: string,
wallet: string,
enterprise: string,
}
const POLL_FREQUENCY = 5 * 60 * 1000; // 5 minutes
class WalletApi {
private treasuries: Treasury[] = [];
private wallets: Record<string, Wallet> = {};
private syncing = false;
private lastSync = 0;
@@ -65,6 +73,8 @@ class WalletApi {
}
this.wallets = data.wallets;
this.treasuries = data.treasuries || [];
// Reset lastSync time to force transaction history refresh
for (const wallet of Object.values(this.wallets)) {
wallet.lastPoll = 0;
@@ -90,6 +100,7 @@ class WalletApi {
const cacheData = {
cacheSchemaVersion: this.cacheSchemaVersion,
wallets: this.wallets,
treasuries: this.treasuries,
};
await fsPromises.writeFile(
@@ -126,6 +137,14 @@ class WalletApi {
}
}
public getWallets(): string[] {
return Object.keys(this.wallets);
}
public getTreasuries(): Treasury[] {
return this.treasuries?.filter(treasury => !!this.wallets[treasury.wallet]) || [];
}
// resync wallet addresses from the services backend
async $syncWallets(): Promise<void> {
if (!config.WALLETS.ENABLED || this.syncing) {
@@ -158,9 +177,26 @@ class WalletApi {
}
}
}
// update list of treasuries
const treasuriesResponse = await axios.get(config.MEMPOOL_SERVICES.API + `/treasuries`);
console.log(treasuriesResponse.data);
this.treasuries = treasuriesResponse.data || [];
} catch (e) {
logger.err(`Error updating active wallets: ${(e instanceof Error ? e.message : e)}`);
}
try {
// update list of active treasuries
this.lastSync = Date.now();
const response = await axios.get(config.MEMPOOL_SERVICES.API + `/treasuries`);
const treasuries: Treasury[] = response.data;
if (treasuries) {
this.treasuries = treasuries;
}
} catch (e) {
logger.err(`Error updating active treasuries: ${(e instanceof Error ? e.message : e)}`);
}
}
for (const walletKey of Object.keys(this.wallets)) {

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,411 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core';
import { 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 { StateService } from '@app/services/state.service';
import { SeriesOption } from 'echarts';
import { WalletStats } from '@app/shared/wallet-stats';
import { chartColors } from '@app/app.constants';
import { Treasury } from '../../../interfaces/node-api.interface';
// export const treasuriesPalette = [
// '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40',
// ];
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() treasuries: Treasury[] = [];
@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[]> = {};
seriesNameToLabel: Record<string, string> = {};
hoverData: any[] = [];
subscription: Subscription;
redraw$: BehaviorSubject<boolean> = new BehaviorSubject(false);
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'svg',
};
error: any;
isLoading = true;
chartInstance: any = undefined;
constructor(
@Inject(LOCALE_ID) public locale: string,
public stateService: StateService,
private amountShortenerPipe: AmountShortenerPipe,
private cd: ChangeDetectorRef,
) {}
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 = {};
this.treasuries.forEach(treasury => {
if (!walletSummaries[treasury.wallet] || !walletSummaries[treasury.wallet].length) return;
const total = this.walletStats[treasury.wallet] ? this.walletStats[treasury.wallet].balance : walletSummaries[treasury.wallet].reduce((acc, tx) => acc + tx.value, 0);
let runningTotal = total;
const processedData = walletSummaries[treasury.wallet].map(tx => {
const balance = runningTotal;
runningTotal -= tx.value;
return {
time: tx.time * 1000,
balance,
tx
};
}).reverse();
this.walletData[treasury.wallet] = 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[treasury.wallet]];
this.walletData[treasury.wallet] = this.walletData[treasury.wallet].filter(d => d[0] >= start);
if (this.walletData[treasury.wallet].length === 0 || this.walletData[treasury.wallet][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[treasury.wallet].unshift([start, startBalance, { placeholder: true }]);
}
}
// Add current point
this.walletData[treasury.wallet].push([Date.now(), total, { current: true }]);
});
}
prepareChartOptions(): void {
// Prepare legend data
this.seriesNameToLabel = {};
for (const treasury of this.treasuries) {
this.seriesNameToLabel[treasury.wallet] = treasury.enterprise || treasury.name;
}
const legendData = this.treasuries.map(treasury => ({
name: treasury.wallet,
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;
this.treasuries.forEach(treasury => {
const data = this.walletData[treasury.wallet];
if (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[] = this.treasuries.map((treasury, index) => ({
name: treasury.wallet,
yAxisIndex: 0,
showSymbol: false,
symbol: 'circle',
symbolSize: 8,
data: this.walletData[treasury.wallet] || [],
areaStyle: undefined,
triggerLineEvent: true,
type: 'line',
smooth: false,
step: 'end'
}));
this.chartOptions = {
color: chartColors,
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: (name) => {
return this.seriesNameToLabel[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 activeTreasuries: { treasury: Treasury, index: number }[] = this.treasuries
.map((treasury, index) => ({ treasury, index }))
.filter(({ treasury }) => this.selectedWallets[treasury.wallet] && this.walletData[treasury.wallet]);
// For each active wallet, find and display the most recent balance
activeTreasuries.forEach(({ treasury, index }) => {
const walletPoints = this.walletData[treasury.wallet];
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)) {
const markerColor = chartColors[index % chartColors.length];
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} ${treasury.enterprise || treasury.name}:</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,7 @@
<div>
<div class="container pb-lg-0">
<div class="chart-widget" *browserOnly [style]="{ height: (height + 'px'), opacity: isLoading ? 0.5 : 1 }" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)">
</div>
</div>
</div>

View File

@@ -0,0 +1,135 @@
.card-header {
border-bottom: 0;
font-size: 18px;
@media (min-width: 465px) {
font-size: 20px;
}
}
.full-container {
padding: 0px 15px;
width: 100%;
height: calc(100% - 140px);
padding-bottom: 20px;
@media (max-width: 992px) {
height: calc(100% - 190px);
};
@media (max-width: 575px) {
height: calc(100% - 230px);
};
}
.chart {
max-height: 400px;
@media (max-width: 767.98px) {
max-height: 230px;
}
margin-bottom: 20px;
}
.chart-widget {
width: 100%;
height: 100%;
@media (max-width: 767px) {
max-height: 240px;
}
@media (max-width: 485px) {
max-height: 200px;
}
}
@media (max-width: 767.98px) {
.pools-table th,
.pools-table td {
padding: .3em !important;
}
}
@media (max-width: 430px) {
.pool-name {
max-width: 110px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.health-column {
display: none;
}
}
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 99;
}
.pool-distribution {
min-height: 56px;
display: block;
@media (min-width: 485px) {
display: flex;
flex-direction: row;
}
h5 {
margin-bottom: 5px;
}
.item {
max-width: 160px;
width: 50%;
display: inline-block;
margin: 0px auto 20px;
&:nth-child(2) {
order: 2;
@media (min-width: 485px) {
order: 3;
}
}
&:nth-child(3) {
width: 50%;
order: 3;
@media (min-width: 485px) {
order: 2;
display: block;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: block;
}
}
.card-title {
font-size: 1rem;
color: var(--title-fg);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-text {
font-size: 18px;
span {
color: var(--transparent-fg);
font-size: 12px;
}
}
}
}
.skeleton-loader {
width: 100%;
display: block;
max-width: 80px;
margin: 15px auto 3px;
}
td {
.difference {
&.positive {
color: rgb(66, 183, 71);
}
&.negative {
color: rgb(183, 66, 66);
}
}
}

View File

@@ -0,0 +1,289 @@
import { ChangeDetectionStrategy, Component, Inject, LOCALE_ID, Input, OnChanges, SimpleChanges, ChangeDetectorRef, EventEmitter, Output } from '@angular/core';
import { EChartsOption, PieSeriesOption } from '@app/graphs/echarts';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { StateService } from '@app/services/state.service';
import { download } from '@app/shared/graphs.utils';
import { isMobile } from '@app/shared/common.utils';
import { WalletStats } from '@app/shared/wallet-stats';
import { AddressTxSummary } from '@interfaces/electrs.interface';
import { chartColors } from '@app/app.constants';
import { formatNumber } from '@angular/common';
import { Treasury } from '@interfaces/node-api.interface';
@Component({
selector: 'app-treasuries-pie',
templateUrl: './treasuries-pie.component.html',
styleUrls: ['./treasuries-pie.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TreasuriesPieComponent implements OnChanges {
@Input() height: number = 300;
@Input() mode: 'relative' | 'all' = 'relative';
@Input() walletStats: Record<string, WalletStats>;
@Input() walletSummaries$: Observable<Record<string, AddressTxSummary[]>>;
@Input() selectedWallets: Record<string, boolean> = {};
@Input() treasuries: Treasury[] = [];
@Output() navigateToTreasury: EventEmitter<Treasury> = new EventEmitter();
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'svg',
};
chartInstance: any = undefined;
error: any;
isLoading = true;
subscription: Subscription;
redraw$: BehaviorSubject<boolean> = new BehaviorSubject(false);
walletBalance: Record<string, number> = {};
constructor(
@Inject(LOCALE_ID) public locale: string,
public stateService: StateService,
private cd: ChangeDetectorRef,
) {
}
ngOnInit(): void {
this.isLoading = true;
this.setupSubscription();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.walletSummaries$ || changes.selectedWallets || changes.mode) {
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$
]).subscribe(([_, walletSummaries]) => {
if (walletSummaries) {
this.error = null;
this.processWalletData(walletSummaries);
this.prepareChartOptions();
}
this.isLoading = false;
this.cd.markForCheck();
});
}
processWalletData(walletSummaries: Record<string, AddressTxSummary[]>): void {
this.walletBalance = {};
for (const treasury of this.treasuries) {
const summary = walletSummaries[treasury.wallet];
if (summary?.length) {
const total = this.walletStats[treasury.wallet] ? this.walletStats[treasury.wallet].balance : summary.reduce((acc, tx) => acc + tx.value, 0);
this.walletBalance[treasury.wallet] = total;
}
}
}
generateChartSeriesData(): PieSeriesOption[] {
let sliceThreshold = 1;
if (isMobile()) {
sliceThreshold = 2;
}
const data: object[] = [];
let edgeDistance: any = '20%';
if (isMobile()) {
edgeDistance = 0;
} else {
edgeDistance = 10;
}
const treasuriesTotal = Object.values(this.walletBalance).reduce((acc, v) => acc + v, 0);
const total = this.mode === 'relative' ? treasuriesTotal : 2099999997690000;
const entries: {
treasury?: any,
id: string,
label: string,
balance: number,
share: number,
color: string,
}[] = this.treasuries.map((treasury, index) => ({
treasury,
id: treasury.wallet,
label: treasury.enterprise || treasury.name,
balance: this.walletBalance[treasury.wallet],
share: (this.walletBalance[treasury.wallet] / total) * 100,
color: chartColors[index % chartColors.length],
}));
if (this.mode === 'all') {
entries.unshift({
id: 'remaining',
label: 'Remaining',
balance: (total - treasuriesTotal),
share: ((total - treasuriesTotal) / total) * 100,
color: 'orange'
});
console.log('ALL! ', entries);
}
const otherEntry = { id: 'other', label: 'Other', balance: 0, share: 0 };
entries.forEach((entry) => {
if (entry.share < sliceThreshold) {
otherEntry.balance += entry.balance;
otherEntry.share = (otherEntry.balance / total) * 100;
return;
}
data.push({
itemStyle: {
color: entry.color,
},
value: entry.share,
name: entry.id,
label: {
overflow: 'none',
color: 'var(--tooltip-grey)',
alignTo: 'edge',
edgeDistance: edgeDistance,
formatter: () => {
return entry.label;
}
},
tooltip: {
show: !isMobile(),
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
textStyle: {
color: 'var(--tooltip-grey)',
},
borderColor: '#000',
formatter: () => {
return `<b style="color: white">${entry.label} (${entry.share.toFixed(2)}%)</b><br>
${formatNumber(entry.balance / 100_000_000, this.locale, '1.3-3')} BTC<br>`;
}
},
data: entry.treasury,
} as PieSeriesOption);
});
const percentage = otherEntry.share.toFixed(2) + '%';
if (otherEntry.share > 0) {
data.push({
itemStyle: {
color: '#6b6b6b',
},
value: otherEntry.share,
name: $localize`Other (${percentage})`,
label: {
overflow: 'none',
color: 'var(--tooltip-grey)',
alignTo: 'edge',
edgeDistance: edgeDistance
},
tooltip: {
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
textStyle: {
color: 'var(--tooltip-grey)',
},
borderColor: '#000',
formatter: () => {
return `<b style="color: white">${otherEntry.id} (${otherEntry.share}%)</b><br>
${formatNumber(otherEntry.balance, this.locale, '1.3-3')}<br>`;
}
},
} as PieSeriesOption);
}
return data;
}
prepareChartOptions(): void {
const pieSize = ['20%', '80%']; // Desktop
this.chartOptions = {
animation: false,
color: chartColors,
tooltip: {
trigger: 'item',
textStyle: {
align: 'left',
}
},
series: [
{
zlevel: 0,
minShowLabelAngle: 1.8,
name: 'Treasuries',
type: 'pie',
radius: pieSize,
data: this.generateChartSeriesData(),
labelLine: {
lineStyle: {
width: 2,
},
},
label: {
fontSize: 14,
},
itemStyle: {
borderRadius: 1,
borderWidth: 1,
borderColor: '#000',
},
emphasis: {
itemStyle: {
shadowBlur: 40,
shadowColor: 'rgba(0, 0, 0, 0.75)',
},
labelLine: {
lineStyle: {
width: 3,
}
}
}
}
],
};
}
onChartInit(ec): void {
if (this.chartInstance !== undefined) {
return;
}
this.chartInstance = ec;
this.chartInstance.on('click', (e) => {
if (!e.data.data) {
return;
}
this.navigateToTreasury.emit(e.data.data);
});
}
onSaveChart(): void {
const now = new Date();
this.chartOptions.backgroundColor = 'var(--active-bg)';
this.chartInstance.setOption(this.chartOptions);
download(this.chartInstance.getDataURL({
pixelRatio: 2,
excludeComponents: ['dataZoom'],
}), `treasuries-pie-${Math.round(now.getTime() / 1000)}.svg`);
this.chartOptions.backgroundColor = 'none';
this.chartInstance.setOption(this.chartOptions);
}
isEllipsisActive(e: HTMLElement): boolean {
return (e.offsetWidth < e.scrollWidth);
}
}

View File

@@ -0,0 +1,100 @@
<div class="container-xl dashboard-container">
<div class="row row-cols-1 row-cols-md-2">
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title" i18n="dashboard.treasury-leaderboard">Treasury Leaderboard</h5>
<table class="table table-striped treasury-leaderboard-table">
<thead>
<th class="table-cell-position"></th>
<th class="table-cell-name" i18n="dashboard.treasury-leaderboard.treasury">Treasury</th>
<th class="table-cell-balance" i18n="dashboard.treasury-leaderboard.balance">BTC Balance</th>
<th class="table-cell-value" i18n="dashboard.treasury-leaderboard.value">USD Value</th>
</thead>
<tbody *ngIf="walletStats$ | async as walletStats; else leaderboardSkeleton">
<ng-container *ngIf="(sortedTreasuries$ | async) as sortedTreasuries">
<tr *ngFor="let treasury of sortedTreasuries.slice(0, 7); let i = index"
(click)="navigateToTreasury(treasury)"
class="clickable-row">
<td class="table-cell-position">
<div class="position-container">
<div class="color-swatch" [style.background-color]="getTreasuryColor(treasury)"></div>
<span class="position-number">{{i + 1}}</span>
</div>
</td>
<td class="table-cell-name">{{ treasury.enterprise || treasury.name }}</td>
<td class="table-cell-balance">
<app-amount [satoshis]="walletStats[treasury.wallet]?.balance || 0" digitsInfo="1.2-4" [noFiat]="true"></app-amount>
</td>
<td class="table-cell-value">
<app-fiat [value]="walletStats[treasury.wallet]?.balance || 0" digitsInfo="1.0-0"></app-fiat>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body pl-2 pr-2">
<h5 class="card-title" i18n="dashboard.treasury-distribution">Treasury Distribution</h5>
<div *ngIf="walletStats$ | async as walletStats">
<app-treasuries-pie
[walletStats]="walletStats"
[walletSummaries$]="walletSummaries$"
[selectedWallets]="selectedWallets"
[treasuries]="currentSortedTreasuries"
[height]="375"
mode="relative"
(navigateToTreasury)="onNavigateToTreasury($event)"
/>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<h5 class="card-title" i18n="dashboard.balance-history">Balance History</h5>
<div *ngIf="walletStats$ | async as walletStats">
<app-treasuries-graph
[walletStats]="walletStats"
[walletSummaries$]="walletSummaries$"
[selectedWallets]="selectedWallets"
[treasuries]="currentSortedTreasuries"
[height]="400"
[right]="10"
[left]="70"
[showLegend]="true"
[showYAxis]="true"
[widget]="false"
[allowZoom]="false"
period="all"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<ng-template #leaderboardSkeleton>
<tbody>
<tr *ngFor="let i of [1,2,3,4,5,6]">
<td class="table-cell-position">
<div class="position-container">
<div class="color-swatch skeleton-loader"></div>
<span class="position-number skeleton-loader"></span>
</div>
</td>
<td class="table-cell-name"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
<td class="table-cell-balance"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
<td class="table-cell-value"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
</tr>
</tbody>
</ng-template>

View File

@@ -0,0 +1,162 @@
.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%;
background-color: var(--secondary);
border-radius: 3px;
}
.skeleton-loader-transactions {
max-width: 250px;
position: relative;
top: 2px;
margin-bottom: -3px;
height: 18px;
}
.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;
}
}
.treasury-leaderboard-table {
width: 100%;
text-align: left;
table-layout: fixed;
tr, td, th {
border: 0px;
padding-top: 0.65rem !important;
padding-bottom: 0.7rem !important;
}
td {
overflow: hidden;
}
.clickable-row {
cursor: pointer;
&:hover {
background-color: var(--secondary);
}
}
.table-cell-position {
width: 10%;
min-width: 50px;
text-align: center;
.position-container {
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
.color-swatch {
width: 16px;
height: 16px;
border-radius: 3px;
display: inline-block;
}
.position-number {
min-width: 20px;
}
}
}
.table-cell-name {
width: 25%;
text-align: left;
}
.table-cell-balance {
width: 25%;
text-align: right;
}
.table-cell-value {
width: 25%;
text-align: right;
}
@media (max-width: 1080px) {
.table-cell-value {
display: none;
}
}
}
.position-container {
.skeleton-loader {
&.color-swatch {
width: 16px;
height: 16px;
min-width: 16px;
}
&.position-number {
width: 20px;
height: 18px;
min-width: 20px;
margin-left: 8px;
}
}
}

View File

@@ -0,0 +1,292 @@
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 { Router } from '@angular/router';
import { chartColors } from '@app/app.constants';
import { Treasury } from '@interfaces/node-api.interface';
@Component({
selector: 'app-treasuries',
templateUrl: './treasuries.component.html',
styleUrls: ['./treasuries.component.scss']
})
export class TreasuriesComponent implements OnInit, OnDestroy {
treasuries: Treasury[] = [];
walletSummaries$: Observable<Record<string, AddressTxSummary[]>>;
selectedWallets: Record<string, boolean> = {};
isLoading = true;
error: any;
walletSubscriptions: Subscription[] = [];
currentSortedTreasuries: Treasury[] = [];
// 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>>;
sortedTreasuries$: Observable<Treasury[]>;
constructor(
private apiService: ApiService,
private stateService: StateService,
private router: Router,
) {}
ngOnInit() {
// Fetch the list of wallets from the API
this.apiService.getTreasuries$().pipe(
catchError(err => {
console.error('Error loading treasuries list:', err);
return of([]);
})
).subscribe(treasuries => {
this.treasuries = treasuries;
// Initialize all wallets as enabled by default
this.treasuries.forEach(treasury => {
this.selectedWallets[treasury.wallet] = true;
});
// Set up wallet data after we have the wallet list
this.setupWalletData();
});
}
private setupWalletData() {
this.treasuries.forEach(treasury => {
this.walletObservables[treasury.wallet] = this.apiService.getWallet$(treasury.wallet).pipe(
catchError((err) => {
console.log(`Error loading wallet ${treasury.wallet}:`, err);
return of({});
}),
shareReplay(1),
);
this.walletAddressesObservables[treasury.wallet] = this.walletObservables[treasury.wallet].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[treasury.wallet] = this.walletObservables[treasury.wallet].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[treasury.wallet] = this.walletObservables[treasury.wallet].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({});
})
);
this.sortedTreasuries$ = this.walletStats$.pipe(
map(walletStats => {
return [...this.treasuries].sort((a, b) => {
const balanceA = walletStats[a.wallet]?.balance || 0;
const balanceB = walletStats[b.wallet]?.balance || 0;
return balanceB - balanceA;
});
}),
tap(sortedWallets => {
// Update selectedWallets to maintain the same order
const newSelectedWallets: Record<string, boolean> = {};
sortedWallets.forEach(treasury => {
newSelectedWallets[treasury.wallet] = this.selectedWallets[treasury.wallet] ?? true;
});
this.selectedWallets = newSelectedWallets;
this.currentSortedTreasuries = sortedWallets;
})
);
}
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;
});
}
getTreasuryColor(treasury: Treasury): string {
const index = this.currentSortedTreasuries.indexOf(treasury);
return chartColors[index % chartColors.length];
}
onNavigateToTreasury(treasury: Treasury): void {
this.navigateToTreasury(treasury);
}
navigateToTreasury(treasury: Treasury): void {
this.router.navigate(['/wallet', treasury.wallet]);
}
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,8 @@ 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 { TreasuriesPieComponent } from '@components/treasuries/treasuries-pie/treasuries-pie.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 +60,7 @@ import { AsmStylerPipe } from '@app/shared/pipes/asm-styler/asm-styler.pipe';
AcceleratorDashboardComponent,
PoolComponent,
PoolRankingComponent,
TreasuriesComponent,
StatisticsComponent,
GraphsComponent,
AccelerationFeesGraphComponent,
@@ -82,6 +85,8 @@ import { AsmStylerPipe } from '@app/shared/pipes/asm-styler/asm-styler.pipe';
HashrateChartPoolsComponent,
BlockHealthGraphComponent,
AddressGraphComponent,
TreasuriesGraphComponent,
TreasuriesPieComponent,
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';
@@ -181,6 +182,21 @@ const routes: Routes = [
},
];
if (window['__env']?.OFFICIAL_MEMPOOL_SPACE) {
routes[0].children?.push({
path: 'treasuries',
component: StartComponent,
children: [{
path: '',
component: TreasuriesComponent,
data: {
networks: ['bitcoin'],
networkSpecific: true,
},
}]
});
}
@NgModule({
imports: [RouterModule.forChild(routes)],
})

View File

@@ -481,3 +481,10 @@ export interface WalletAddress {
stats: ChainStats;
transactions: AddressTxSummary[];
}
export interface Treasury {
id: number,
name: string,
wallet: string,
enterprise: string,
}

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights,
RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult, WalletAddress, SubmitPackageResult } from '../interfaces/node-api.interface';
RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult, WalletAddress, Treasury, SubmitPackageResult } from '../interfaces/node-api.interface';
import { BehaviorSubject, Observable, catchError, filter, map, of, shareReplay, take, tap } from 'rxjs';
import { StateService } from '@app/services/state.service';
import { Transaction } from '@interfaces/electrs.interface';
@@ -420,7 +420,7 @@ export class ApiService {
}
getEnterpriseInfo$(name: string): Observable<any> {
return this.httpClient.get<any>(this.apiBaseUrl + this.apiBasePath + `/api/v1/services/enterprise/info/` + name);
return this.httpClient.get<any>(this.apiBaseUrl + `/api/v1/services/enterprise/info/` + name);
}
getChannelByTxIds$(txIds: string[]): Observable<any[]> {
@@ -523,6 +523,12 @@ export class ApiService {
);
}
getTreasuries$(): Observable<Treasury[]> {
return this.httpClient.get<Treasury[]>(
this.apiBaseUrl + this.apiBasePath + `/api/v1/treasuries`
);
}
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;
}
}