mirror of
https://github.com/mempool/mempool.git
synced 2025-09-22 19:21:30 +02:00
Merge branch 'master' into mononaut/sighash-highlighting
This commit is contained in:
@@ -7,6 +7,7 @@ class ServicesRoutes {
|
||||
public initRoutes(app: Application): void {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'wallet/:walletId', this.$getWallet)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + '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();
|
||||
|
@@ -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)) {
|
||||
|
@@ -0,0 +1,21 @@
|
||||
<app-indexing-progress *ngIf="!widget"></app-indexing-progress>
|
||||
|
||||
<div [class.full-container]="!widget">
|
||||
<ng-container *ngIf="!error">
|
||||
<div [class]="!widget ? 'chart' : 'chart-widget'" *browserOnly [style]="{ height: widget ? ((height + 20) + 'px') : null, paddingBottom: !widget && !allowZoom ? '10px' : null}" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||
(chartInit)="onChartInit($event)">
|
||||
</div>
|
||||
<div class="text-center loadingGraphs" *ngIf="isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="error">
|
||||
<div class="error-wrapper">
|
||||
<p class="error">{{ error }}</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,59 @@
|
||||
.card-header {
|
||||
border-bottom: 0;
|
||||
font-size: 18px;
|
||||
@media (min-width: 465px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-title {
|
||||
position: relative;
|
||||
color: var(--fg);
|
||||
opacity: var(--opacity);
|
||||
margin-top: -13px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.full-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0px;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.error-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-size: 15px;
|
||||
color: grey;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.chart-widget {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
100
frontend/src/app/components/treasuries/treasuries.component.html
Normal file
100
frontend/src/app/components/treasuries/treasuries.component.html
Normal 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>
|
162
frontend/src/app/components/treasuries/treasuries.component.scss
Normal file
162
frontend/src/app/components/treasuries/treasuries.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
292
frontend/src/app/components/treasuries/treasuries.component.ts
Normal file
292
frontend/src/app/components/treasuries/treasuries.component.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@@ -12,95 +12,7 @@ import { WalletAddress } from '@interfaces/node-api.interface';
|
||||
import { ElectrsApiService } from '@app/services/electrs-api.service';
|
||||
import { AudioService } from '@app/services/audio.service';
|
||||
import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
|
||||
|
||||
|
||||
class WalletStats implements ChainStats {
|
||||
addresses: string[];
|
||||
funded_txo_count: number;
|
||||
funded_txo_sum: number;
|
||||
spent_txo_count: number;
|
||||
spent_txo_sum: number;
|
||||
tx_count: number;
|
||||
|
||||
constructor (stats: ChainStats[], addresses: string[]) {
|
||||
Object.assign(this, stats.reduce((acc, stat) => {
|
||||
acc.funded_txo_count += stat.funded_txo_count;
|
||||
acc.funded_txo_sum += stat.funded_txo_sum;
|
||||
acc.spent_txo_count += stat.spent_txo_count;
|
||||
acc.spent_txo_sum += stat.spent_txo_sum;
|
||||
acc.tx_count += stat.tx_count;
|
||||
return acc;
|
||||
}, {
|
||||
funded_txo_count: 0,
|
||||
funded_txo_sum: 0,
|
||||
spent_txo_count: 0,
|
||||
spent_txo_sum: 0,
|
||||
tx_count: 0,
|
||||
})
|
||||
);
|
||||
this.addresses = addresses;
|
||||
}
|
||||
|
||||
public addTx(tx: Transaction): void {
|
||||
for (const vin of tx.vin) {
|
||||
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
|
||||
this.spendTxo(vin.prevout.value);
|
||||
}
|
||||
}
|
||||
for (const vout of tx.vout) {
|
||||
if (this.addresses.includes(vout.scriptpubkey_address)) {
|
||||
this.fundTxo(vout.value);
|
||||
}
|
||||
}
|
||||
this.tx_count++;
|
||||
}
|
||||
|
||||
public removeTx(tx: Transaction): void {
|
||||
for (const vin of tx.vin) {
|
||||
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
|
||||
this.unspendTxo(vin.prevout.value);
|
||||
}
|
||||
}
|
||||
for (const vout of tx.vout) {
|
||||
if (this.addresses.includes(vout.scriptpubkey_address)) {
|
||||
this.unfundTxo(vout.value);
|
||||
}
|
||||
}
|
||||
this.tx_count--;
|
||||
}
|
||||
|
||||
private fundTxo(value: number): void {
|
||||
this.funded_txo_sum += value;
|
||||
this.funded_txo_count++;
|
||||
}
|
||||
|
||||
private unfundTxo(value: number): void {
|
||||
this.funded_txo_sum -= value;
|
||||
this.funded_txo_count--;
|
||||
}
|
||||
|
||||
private spendTxo(value: number): void {
|
||||
this.spent_txo_sum += value;
|
||||
this.spent_txo_count++;
|
||||
}
|
||||
|
||||
private unspendTxo(value: number): void {
|
||||
this.spent_txo_sum -= value;
|
||||
this.spent_txo_count--;
|
||||
}
|
||||
|
||||
get balance(): number {
|
||||
return this.funded_txo_sum - this.spent_txo_sum;
|
||||
}
|
||||
|
||||
get totalReceived(): number {
|
||||
return this.funded_txo_sum;
|
||||
}
|
||||
|
||||
get utxos(): number {
|
||||
return this.funded_txo_count - this.spent_txo_count;
|
||||
}
|
||||
}
|
||||
import { WalletStats } from '@app/shared/wallet-stats';
|
||||
|
||||
@Component({
|
||||
selector: 'app-wallet',
|
||||
|
@@ -30,6 +30,7 @@ import { DashboardComponent } from '@app/dashboard/dashboard.component';
|
||||
import { CustomDashboardComponent } from '@components/custom-dashboard/custom-dashboard.component';
|
||||
import { MiningDashboardComponent } from '@components/mining-dashboard/mining-dashboard.component';
|
||||
import { AcceleratorDashboardComponent } from '@components/acceleration/accelerator-dashboard/accelerator-dashboard.component';
|
||||
import { TreasuriesComponent } from '@components/treasuries/treasuries.component';
|
||||
import { HashrateChartComponent } from '@components/hashrate-chart/hashrate-chart.component';
|
||||
import { HashrateChartPoolsComponent } from '@components/hashrates-chart-pools/hashrate-chart-pools.component';
|
||||
import { BlockHealthGraphComponent } from '@components/block-health-graph/block-health-graph.component';
|
||||
@@ -37,6 +38,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,
|
||||
|
@@ -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)],
|
||||
})
|
||||
|
@@ -481,3 +481,10 @@ export interface WalletAddress {
|
||||
stats: ChainStats;
|
||||
transactions: AddressTxSummary[];
|
||||
}
|
||||
|
||||
export interface Treasury {
|
||||
id: number,
|
||||
name: string,
|
||||
wallet: string,
|
||||
enterprise: string,
|
||||
}
|
||||
|
@@ -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}`
|
||||
|
89
frontend/src/app/shared/wallet-stats.ts
Normal file
89
frontend/src/app/shared/wallet-stats.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { ChainStats, Transaction } from '@interfaces/electrs.interface';
|
||||
|
||||
export class WalletStats implements ChainStats {
|
||||
addresses: string[];
|
||||
funded_txo_count: number;
|
||||
funded_txo_sum: number;
|
||||
spent_txo_count: number;
|
||||
spent_txo_sum: number;
|
||||
tx_count: number;
|
||||
|
||||
constructor (stats: ChainStats[], addresses: string[]) {
|
||||
Object.assign(this, stats.reduce((acc, stat) => {
|
||||
acc.funded_txo_count += stat.funded_txo_count;
|
||||
acc.funded_txo_sum += stat.funded_txo_sum;
|
||||
acc.spent_txo_count += stat.spent_txo_count;
|
||||
acc.spent_txo_sum += stat.spent_txo_sum;
|
||||
acc.tx_count += stat.tx_count;
|
||||
return acc;
|
||||
}, {
|
||||
funded_txo_count: 0,
|
||||
funded_txo_sum: 0,
|
||||
spent_txo_count: 0,
|
||||
spent_txo_sum: 0,
|
||||
tx_count: 0,
|
||||
})
|
||||
);
|
||||
this.addresses = addresses;
|
||||
}
|
||||
|
||||
public addTx(tx: Transaction): void {
|
||||
for (const vin of tx.vin) {
|
||||
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
|
||||
this.spendTxo(vin.prevout.value);
|
||||
}
|
||||
}
|
||||
for (const vout of tx.vout) {
|
||||
if (this.addresses.includes(vout.scriptpubkey_address)) {
|
||||
this.fundTxo(vout.value);
|
||||
}
|
||||
}
|
||||
this.tx_count++;
|
||||
}
|
||||
|
||||
public removeTx(tx: Transaction): void {
|
||||
for (const vin of tx.vin) {
|
||||
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
|
||||
this.unspendTxo(vin.prevout.value);
|
||||
}
|
||||
}
|
||||
for (const vout of tx.vout) {
|
||||
if (this.addresses.includes(vout.scriptpubkey_address)) {
|
||||
this.unfundTxo(vout.value);
|
||||
}
|
||||
}
|
||||
this.tx_count--;
|
||||
}
|
||||
|
||||
private fundTxo(value: number): void {
|
||||
this.funded_txo_sum += value;
|
||||
this.funded_txo_count++;
|
||||
}
|
||||
|
||||
private unfundTxo(value: number): void {
|
||||
this.funded_txo_sum -= value;
|
||||
this.funded_txo_count--;
|
||||
}
|
||||
|
||||
private spendTxo(value: number): void {
|
||||
this.spent_txo_sum += value;
|
||||
this.spent_txo_count++;
|
||||
}
|
||||
|
||||
private unspendTxo(value: number): void {
|
||||
this.spent_txo_sum -= value;
|
||||
this.spent_txo_count--;
|
||||
}
|
||||
|
||||
get balance(): number {
|
||||
return this.funded_txo_sum - this.spent_txo_sum;
|
||||
}
|
||||
|
||||
get totalReceived(): number {
|
||||
return this.funded_txo_sum;
|
||||
}
|
||||
|
||||
get utxos(): number {
|
||||
return this.funded_txo_count - this.spent_txo_count;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user