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

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

{{ error }}

+
+
+ +
+
+
+
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];