diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html
index 31dff2fa5..b893d7e22 100644
--- a/frontend/src/app/components/address/address.component.html
+++ b/frontend/src/app/components/address/address.component.html
@@ -94,6 +94,20 @@
+ 2">
+
+
+
Unspent Outputs
+
+
+
+
diff --git a/frontend/src/app/components/address/address.component.ts b/frontend/src/app/components/address/address.component.ts
index 105863a4e..5ce82ef8c 100644
--- a/frontend/src/app/components/address/address.component.ts
+++ b/frontend/src/app/components/address/address.component.ts
@@ -2,12 +2,12 @@ import { Component, OnInit, OnDestroy, HostListener } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { switchMap, filter, catchError, map, tap } from 'rxjs/operators';
-import { Address, ChainStats, Transaction, Vin } from '../../interfaces/electrs.interface';
+import { Address, ChainStats, Transaction, Utxo, Vin } from '../../interfaces/electrs.interface';
import { WebsocketService } from '../../services/websocket.service';
import { StateService } from '../../services/state.service';
import { AudioService } from '../../services/audio.service';
import { ApiService } from '../../services/api.service';
-import { of, merge, Subscription, Observable } from 'rxjs';
+import { of, merge, Subscription, Observable, forkJoin } from 'rxjs';
import { SeoService } from '../../services/seo.service';
import { seoDescriptionNetwork } from '../../shared/common.utils';
import { AddressInformation } from '../../interfaces/node-api.interface';
@@ -104,6 +104,7 @@ export class AddressComponent implements OnInit, OnDestroy {
addressString: string;
isLoadingAddress = true;
transactions: Transaction[];
+ utxos: Utxo[];
isLoadingTransactions = true;
retryLoadMore = false;
error: any;
@@ -159,6 +160,7 @@ export class AddressComponent implements OnInit, OnDestroy {
this.address = null;
this.isLoadingTransactions = true;
this.transactions = null;
+ this.utxos = null;
this.addressInfo = null;
this.exampleChannel = null;
document.body.scrollTo(0, 0);
@@ -212,11 +214,19 @@ export class AddressComponent implements OnInit, OnDestroy {
this.updateChainStats();
this.isLoadingAddress = false;
this.isLoadingTransactions = true;
- return address.is_pubkey
+ const utxoCount = this.chainStats.utxos + this.mempoolStats.utxos;
+ return forkJoin([
+ address.is_pubkey
? this.electrsApiService.getScriptHashTransactions$((address.address.length === 66 ? '21' : '41') + address.address + 'ac')
- : this.electrsApiService.getAddressTransactions$(address.address);
+ : this.electrsApiService.getAddressTransactions$(address.address),
+ utxoCount >= 2 && utxoCount <= 500 ? (address.is_pubkey
+ ? this.electrsApiService.getScriptHashUtxos$((address.address.length === 66 ? '21' : '41') + address.address + 'ac')
+ : this.electrsApiService.getAddressUtxos$(address.address)) : of([])
+ ]);
}),
- switchMap((transactions) => {
+ switchMap(([transactions, utxos]) => {
+ this.utxos = utxos;
+
this.tempTransactions = transactions;
if (transactions.length) {
this.lastTransactionTxId = transactions[transactions.length - 1].txid;
@@ -334,6 +344,23 @@ export class AddressComponent implements OnInit, OnDestroy {
}
}
+ // update utxos in-place
+ for (const vin of transaction.vin) {
+ const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === vin.txid && utxo.vout === vin.vout);
+ if (utxoIndex !== -1) {
+ this.utxos.splice(utxoIndex, 1);
+ }
+ }
+ for (const [index, vout] of transaction.vout.entries()) {
+ if (vout.scriptpubkey_address === this.address.address) {
+ this.utxos.push({
+ txid: transaction.txid,
+ vout: index,
+ value: vout.value,
+ status: JSON.parse(JSON.stringify(transaction.status)),
+ });
+ }
+ }
return true;
}
@@ -346,6 +373,26 @@ export class AddressComponent implements OnInit, OnDestroy {
this.transactions.splice(index, 1);
this.transactions = this.transactions.slice();
+ // update utxos in-place
+ for (const vin of transaction.vin) {
+ if (vin.prevout?.scriptpubkey_address === this.address.address) {
+ this.utxos.push({
+ txid: vin.txid,
+ vout: vin.vout,
+ value: vin.prevout.value,
+ status: { confirmed: true }, // Assuming the input was confirmed
+ });
+ }
+ }
+ for (const [index, vout] of transaction.vout.entries()) {
+ if (vout.scriptpubkey_address === this.address.address) {
+ const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === transaction.txid && utxo.vout === index);
+ if (utxoIndex !== -1) {
+ this.utxos.splice(utxoIndex, 1);
+ }
+ }
+ }
+
return true;
}
diff --git a/frontend/src/app/components/utxo-graph/utxo-graph.component.html b/frontend/src/app/components/utxo-graph/utxo-graph.component.html
new file mode 100644
index 000000000..462e4328e
--- /dev/null
+++ b/frontend/src/app/components/utxo-graph/utxo-graph.component.html
@@ -0,0 +1,21 @@
+
+
+
diff --git a/frontend/src/app/components/utxo-graph/utxo-graph.component.scss b/frontend/src/app/components/utxo-graph/utxo-graph.component.scss
new file mode 100644
index 000000000..1b5e0320d
--- /dev/null
+++ b/frontend/src/app/components/utxo-graph/utxo-graph.component.scss
@@ -0,0 +1,59 @@
+.card-header {
+ border-bottom: 0;
+ font-size: 18px;
+ @media (min-width: 465px) {
+ font-size: 20px;
+ }
+ @media (min-width: 992px) {
+ height: 40px;
+ }
+}
+
+.main-title {
+ position: relative;
+ color: var(--fg);
+ opacity: var(--opacity);
+ margin-top: -13px;
+ font-size: 10px;
+ text-transform: uppercase;
+ font-weight: 500;
+ text-align: center;
+ padding-bottom: 3px;
+}
+
+.full-container {
+ display: flex;
+ flex-direction: column;
+ padding: 0px;
+ width: 100%;
+ height: 400px;
+}
+
+.error-wrapper {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+
+ font-size: 15px;
+ color: grey;
+ font-weight: bold;
+}
+
+.chart {
+ display: flex;
+ flex: 1;
+ width: 100%;
+ padding-right: 10px;
+}
+.chart-widget {
+ width: 100%;
+ height: 100%;
+}
+
+.disabled {
+ pointer-events: none;
+ opacity: 0.5;
+}
\ No newline at end of file
diff --git a/frontend/src/app/components/utxo-graph/utxo-graph.component.ts b/frontend/src/app/components/utxo-graph/utxo-graph.component.ts
new file mode 100644
index 000000000..5e034a700
--- /dev/null
+++ b/frontend/src/app/components/utxo-graph/utxo-graph.component.ts
@@ -0,0 +1,285 @@
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
+import { EChartsOption } from '../../graphs/echarts';
+import { BehaviorSubject, Subscription } from 'rxjs';
+import { Utxo } from '../../interfaces/electrs.interface';
+import { StateService } from '../../services/state.service';
+import { Router } from '@angular/router';
+import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
+import { renderSats } from '../../shared/common.utils';
+
+@Component({
+ selector: 'app-utxo-graph',
+ templateUrl: './utxo-graph.component.html',
+ styleUrls: ['./utxo-graph.component.scss'],
+ styles: [`
+ .loadingGraphs {
+ position: absolute;
+ top: 50%;
+ left: calc(50% - 15px);
+ z-index: 99;
+ }
+ `],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class UtxoGraphComponent implements OnChanges, OnDestroy {
+ @Input() utxos: Utxo[];
+ @Input() height: number = 200;
+ @Input() right: number | string = 10;
+ @Input() left: number | string = 70;
+ @Input() widget: boolean = false;
+
+ subscription: Subscription;
+ redraw$: BehaviorSubject = new BehaviorSubject(false);
+
+ chartOptions: EChartsOption = {};
+ chartInitOptions = {
+ renderer: 'svg',
+ };
+
+ error: any;
+ isLoading = true;
+ chartInstance: any = undefined;
+
+ constructor(
+ public stateService: StateService,
+ private cd: ChangeDetectorRef,
+ private zone: NgZone,
+ private router: Router,
+ private relativeUrlPipe: RelativeUrlPipe,
+ ) {}
+
+ ngOnChanges(changes: SimpleChanges): void {
+ this.isLoading = true;
+ if (!this.utxos) {
+ return;
+ }
+ if (changes.utxos) {
+ this.prepareChartOptions(this.utxos);
+ }
+ }
+
+ prepareChartOptions(utxos: Utxo[]) {
+ if (!utxos || utxos.length === 0) {
+ return;
+ }
+
+ this.isLoading = false;
+
+ // Helper functions
+ const distance = (x1: number, y1: number, x2: number, y2: number): number => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
+ const intersectionPoints = (x1: number, y1: number, r1: number, x2: number, y2: number, r2: number): [number, number][] => {
+ const d = distance(x1, y1, x2, y2);
+ const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d);
+ const h = Math.sqrt(r1 * r1 - a * a);
+ const x3 = x1 + a * (x2 - x1) / d;
+ const y3 = y1 + a * (y2 - y1) / d;
+ return [
+ [x3 + h * (y2 - y1) / d, y3 - h * (x2 - x1) / d],
+ [x3 - h * (y2 - y1) / d, y3 + h * (x2 - x1) / d]
+ ];
+ };
+
+ // Naive algorithm to pack circles as tightly as possible without overlaps
+ const placedCircles: { x: number, y: number, r: number, utxo: Utxo, distances: number[] }[] = [];
+ // Pack in descending order of value, and limit to the top 500 to preserve performance
+ const sortedUtxos = utxos.sort((a, b) => b.value - a.value).slice(0, 500);
+ let centerOfMass = { x: 0, y: 0 };
+ let weightOfMass = 0;
+ sortedUtxos.forEach((utxo, index) => {
+ // area proportional to value
+ const r = Math.sqrt(utxo.value);
+
+ // special cases for the first two utxos
+ if (index === 0) {
+ placedCircles.push({ x: 0, y: 0, r, utxo, distances: [0] });
+ return;
+ }
+ if (index === 1) {
+ const c = placedCircles[0];
+ placedCircles.push({ x: c.r + r, y: 0, r, utxo, distances: [c.r + r, 0] });
+ c.distances.push(c.r + r);
+ return;
+ }
+
+ // The best position will be touching two other circles
+ // generate a list of candidate points by finding all such positions
+ // where the circle can be placed without overlapping other circles
+ const candidates: [number, number, number[]][] = [];
+ const numCircles = placedCircles.length;
+ for (let i = 0; i < numCircles; i++) {
+ for (let j = i + 1; j < numCircles; j++) {
+ const c1 = placedCircles[i];
+ const c2 = placedCircles[j];
+ if (c1.distances[j] > (c1.r + c2.r + r + r)) {
+ // too far apart for new circle to touch both
+ continue;
+ }
+ const points = intersectionPoints(c1.x, c1.y, c1.r + r, c2.x, c2.y, c2.r + r);
+ points.forEach(([x, y]) => {
+ const distances: number[] = [];
+ let valid = true;
+ for (let k = 0; k < numCircles; k++) {
+ const c = placedCircles[k];
+ const d = distance(x, y, c.x, c.y);
+ if (k !== i && k !== j && d < (r + c.r)) {
+ valid = false;
+ break;
+ } else {
+ distances.push(d);
+ }
+ }
+ if (valid) {
+ candidates.push([x, y, distances]);
+ }
+ });
+ }
+ }
+
+ // Pick the candidate closest to the center of mass
+ const [x, y, distances] = candidates.length ? candidates.reduce((closest, candidate) =>
+ distance(candidate[0], candidate[1], centerOfMass[0], centerOfMass[1]) <
+ distance(closest[0], closest[1], centerOfMass[0], centerOfMass[1])
+ ? candidate
+ : closest
+ ) : [0, 0, []];
+
+ placedCircles.push({ x, y, r, utxo, distances });
+ for (let i = 0; i < distances.length; i++) {
+ placedCircles[i].distances.push(distances[i]);
+ }
+ distances.push(0);
+
+ // Update center of mass
+ centerOfMass = {
+ x: (centerOfMass.x * weightOfMass + x) / (weightOfMass + r),
+ y: (centerOfMass.y * weightOfMass + y) / (weightOfMass + r),
+ };
+ weightOfMass += r;
+ });
+
+ // Precompute the bounding box of the graph
+ const minX = Math.min(...placedCircles.map(d => d.x - d.r));
+ const maxX = Math.max(...placedCircles.map(d => d.x + d.r));
+ const minY = Math.min(...placedCircles.map(d => d.y - d.r));
+ const maxY = Math.max(...placedCircles.map(d => d.y + d.r));
+ const width = maxX - minX;
+ const height = maxY - minY;
+
+ const data = placedCircles.map((circle, index) => [
+ circle.utxo,
+ index,
+ circle.x,
+ circle.y,
+ circle.r
+ ]);
+
+ this.chartOptions = {
+ series: [{
+ type: 'custom',
+ coordinateSystem: undefined,
+ data,
+ renderItem: (params, api) => {
+ const idx = params.dataIndex;
+ const datum = data[idx];
+ const utxo = datum[0] as Utxo;
+ const chartWidth = api.getWidth();
+ const chartHeight = api.getHeight();
+ const scale = Math.min(chartWidth / width, chartHeight / height);
+ const scaledWidth = width * scale;
+ const scaledHeight = height * scale;
+ const offsetX = (chartWidth - scaledWidth) / 2 - minX * scale;
+ const offsetY = (chartHeight - scaledHeight) / 2 - minY * scale;
+ const x = datum[2] as number;
+ const y = datum[3] as number;
+ const r = datum[4] as number;
+ if (r * scale < 3) {
+ // skip items too small to render cleanly
+ return;
+ }
+ const valueStr = renderSats(utxo.value, this.stateService.network);
+ const elements: any[] = [
+ {
+ type: 'circle',
+ autoBatch: true,
+ shape: {
+ cx: (x * scale) + offsetX,
+ cy: (y * scale) + offsetY,
+ r: (r * scale) - 1,
+ },
+ style: {
+ fill: '#5470c6',
+ }
+ },
+ ];
+ const labelFontSize = Math.min(36, r * scale * 0.25);
+ if (labelFontSize > 8) {
+ elements.push({
+ type: 'text',
+ x: (x * scale) + offsetX,
+ y: (y * scale) + offsetY,
+ style: {
+ text: valueStr,
+ fontSize: labelFontSize,
+ fill: '#fff',
+ align: 'center',
+ verticalAlign: 'middle',
+ },
+ });
+ }
+ return {
+ type: 'group',
+ children: elements,
+ };
+ }
+ }],
+ tooltip: {
+ backgroundColor: 'rgba(17, 19, 31, 1)',
+ borderRadius: 4,
+ shadowColor: 'rgba(0, 0, 0, 0.5)',
+ textStyle: {
+ color: 'var(--tooltip-grey)',
+ align: 'left',
+ },
+ borderColor: '#000',
+ formatter: (params: any): string => {
+ const utxo = params.data[0] as Utxo;
+ const valueStr = renderSats(utxo.value, this.stateService.network);
+ return `
+ ${utxo.txid.slice(0, 6)}...${utxo.txid.slice(-6)}:${utxo.vout}
+
+ ${valueStr}`;
+ },
+ }
+ };
+
+ this.cd.markForCheck();
+ }
+
+ onChartClick(e): void {
+ if (e.data?.[0]?.txid) {
+ this.zone.run(() => {
+ const url = this.relativeUrlPipe.transform(`/tx/${e.data[0].txid}`);
+ if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) {
+ window.open(url + '?mode=details#vout=' + e.data[0].vout);
+ } else {
+ this.router.navigate([url], { fragment: `vout=${e.data[0].vout}` });
+ }
+ });
+ }
+ }
+
+ onChartInit(ec): void {
+ this.chartInstance = ec;
+ this.chartInstance.on('click', 'series', this.onChartClick.bind(this));
+ }
+
+ ngOnDestroy(): void {
+ if (this.subscription) {
+ this.subscription.unsubscribe();
+ }
+ }
+
+ isMobile(): boolean {
+ return (window.innerWidth <= 767.98);
+ }
+}
diff --git a/frontend/src/app/graphs/echarts.ts b/frontend/src/app/graphs/echarts.ts
index 74fec1e71..67ed7e3b8 100644
--- a/frontend/src/app/graphs/echarts.ts
+++ b/frontend/src/app/graphs/echarts.ts
@@ -1,6 +1,6 @@
// Import tree-shakeable echarts
import * as echarts from 'echarts/core';
-import { LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart } from 'echarts/charts';
+import { LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart, CustomChart } from 'echarts/charts';
import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, GeoComponent, DataZoomComponent, VisualMapComponent, MarkLineComponent } from 'echarts/components';
import { SVGRenderer, CanvasRenderer } from 'echarts/renderers';
// Typescript interfaces
@@ -12,6 +12,7 @@ echarts.use([
TitleComponent, TooltipComponent, GridComponent,
LegendComponent, GeoComponent, DataZoomComponent,
VisualMapComponent, MarkLineComponent,
- LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart
+ LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart,
+ CustomChart,
]);
export { echarts, EChartsOption, TreemapSeriesOption, LineSeriesOption, PieSeriesOption };
\ No newline at end of file
diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts
index de048fd2d..ee51069c5 100644
--- a/frontend/src/app/graphs/graphs.module.ts
+++ b/frontend/src/app/graphs/graphs.module.ts
@@ -36,6 +36,7 @@ import { HashrateChartPoolsComponent } from '../components/hashrates-chart-pools
import { BlockHealthGraphComponent } from '../components/block-health-graph/block-health-graph.component';
import { AddressComponent } from '../components/address/address.component';
import { AddressGraphComponent } from '../components/address-graph/address-graph.component';
+import { UtxoGraphComponent } from '../components/utxo-graph/utxo-graph.component';
import { ActiveAccelerationBox } from '../components/acceleration/active-acceleration-box/active-acceleration-box.component';
import { CommonModule } from '@angular/common';
@@ -76,6 +77,7 @@ import { CommonModule } from '@angular/common';
HashrateChartPoolsComponent,
BlockHealthGraphComponent,
AddressGraphComponent,
+ UtxoGraphComponent,
ActiveAccelerationBox,
],
imports: [
diff --git a/frontend/src/app/interfaces/electrs.interface.ts b/frontend/src/app/interfaces/electrs.interface.ts
index b32a2aae6..5bc5bfc1d 100644
--- a/frontend/src/app/interfaces/electrs.interface.ts
+++ b/frontend/src/app/interfaces/electrs.interface.ts
@@ -233,3 +233,10 @@ interface AssetStats {
peg_out_amount: number;
burn_count: number;
}
+
+export interface Utxo {
+ txid: string;
+ vout: number;
+ value: number;
+ status: Status;
+}
\ No newline at end of file
diff --git a/frontend/src/app/services/electrs-api.service.ts b/frontend/src/app/services/electrs-api.service.ts
index 7faaea87c..8e991782b 100644
--- a/frontend/src/app/services/electrs-api.service.ts
+++ b/frontend/src/app/services/electrs-api.service.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { BehaviorSubject, Observable, catchError, filter, from, of, shareReplay, switchMap, take, tap } from 'rxjs';
-import { Transaction, Address, Outspend, Recent, Asset, ScriptHash, AddressTxSummary } from '../interfaces/electrs.interface';
+import { Transaction, Address, Outspend, Recent, Asset, ScriptHash, AddressTxSummary, Utxo } from '../interfaces/electrs.interface';
import { StateService } from './state.service';
import { BlockExtended } from '../interfaces/node-api.interface';
import { calcScriptHash$ } from '../bitcoin.utils';
@@ -166,6 +166,16 @@ export class ElectrsApiService {
);
}
+ getAddressUtxos$(address: string): Observable {
+ return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/utxo');
+ }
+
+ getScriptHashUtxos$(script: string): Observable {
+ return from(calcScriptHash$(script)).pipe(
+ switchMap(scriptHash => this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/scripthash/' + scriptHash + '/utxo')),
+ );
+ }
+
getAsset$(assetId: string): Observable {
return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId);
}
diff --git a/frontend/src/app/shared/common.utils.ts b/frontend/src/app/shared/common.utils.ts
index 8c69c2319..6bdc3262b 100644
--- a/frontend/src/app/shared/common.utils.ts
+++ b/frontend/src/app/shared/common.utils.ts
@@ -1,5 +1,7 @@
import { MempoolBlockDelta, MempoolBlockDeltaCompressed, MempoolDeltaChange, TransactionCompressed } from "../interfaces/websocket.interface";
import { TransactionStripped } from "../interfaces/node-api.interface";
+import { AmountShortenerPipe } from "./pipes/amount-shortener.pipe";
+const amountShortenerPipe = new AmountShortenerPipe();
export function isMobile(): boolean {
return (window.innerWidth <= 767.98);
@@ -184,6 +186,33 @@ export function uncompressDeltaChange(block: number, delta: MempoolBlockDeltaCom
};
}
+export function renderSats(value: number, network: string, mode: 'sats' | 'btc' | 'auto' = 'auto'): string {
+ let prefix = '';
+ switch (network) {
+ case 'liquid':
+ prefix = 'L';
+ break;
+ case 'liquidtestnet':
+ prefix = 'tL';
+ break;
+ case 'testnet':
+ case 'testnet4':
+ prefix = 't';
+ break;
+ case 'signet':
+ prefix = 's';
+ break;
+ }
+ if (mode === 'btc' || (mode === 'auto' && value >= 1000000)) {
+ return `${amountShortenerPipe.transform(value / 100000000)} ${prefix}BTC`;
+ } else {
+ if (prefix.length) {
+ prefix += '-';
+ }
+ return `${amountShortenerPipe.transform(value)} ${prefix}sats`;
+ }
+}
+
export function insecureRandomUUID(): string {
const hexDigits = '0123456789abcdef';
const uuidLengths = [8, 4, 4, 4, 12];