From c6100101cb0d56c4126dda0e96e008678613ae19 Mon Sep 17 00:00:00 2001 From: mononaut Date: Thu, 14 May 2026 05:07:09 +0000 Subject: [PATCH 01/23] fix tx page autoscroll behavior --- .../transaction/transaction.component.ts | 82 +++++++++++++------ 1 file changed, 55 insertions(+), 27 deletions(-) diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 7db51e983..c273459c8 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -173,6 +173,9 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { graphContainer: ElementRef; private txList: TransactionsListComponent; + private fragmentAnchor: string | null = null; + private scrolledFragmentAnchor: string | null = null; + private firstFragmentScroll = true; @ViewChild('txList') set txListSetter(component: TransactionsListComponent | undefined) { @@ -609,7 +612,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId], { queryParamsHandling: 'merge', - fragment: this.fragmentParams.toString(), + fragment: this.formatFragment(this.fragmentParams), }); } else { this.txId = urlMatch[0]; @@ -620,7 +623,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.fragmentParams.delete('vin'); this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId], { queryParamsHandling: 'merge', - fragment: this.fragmentParams.toString(), + fragment: this.formatFragment(this.fragmentParams), }); } } @@ -630,7 +633,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { if (window.innerWidth <= 767.98) { this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId], { queryParamsHandling: 'merge', - preserveFragment: true, + fragment: this.formatFragment(this.fragmentParams, this.fragmentAnchor), queryParams: { mode: 'details' }, replaceUrl: true, }); @@ -888,7 +891,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { relativeTo: this.route, queryParams: { showDetails: this.isDetailsOpen ? 'true' : null }, queryParamsHandling: 'merge', - preserveFragment: true, + fragment: this.formatFragment(this.fragmentParams), replaceUrl: true, }); this.txList?.setDetailsOpen(this.isDetailsOpen); @@ -1081,6 +1084,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { resetTransaction() { this.firstLoad = false; + this.firstFragmentScroll = this.fragmentAnchor !== null; + this.scrolledFragmentAnchor = null; this.gotInitialPosition = false; this.error = undefined; this.tx = null; @@ -1133,7 +1138,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { relativeTo: this.route, queryParams: { cpfp: this.cpfpMode ? 'true' : null }, queryParamsHandling: 'merge', - fragment: this.getCpfpFragment(), + fragment: this.formatFragment(this.fragmentParams, this.cpfpMode ? 'cluster' : null), replaceUrl: true, }); } else { @@ -1141,7 +1146,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { relativeTo: this.route, queryParams: { cpfp: this.cpfpMode ? 'simple' : null }, queryParamsHandling: 'merge', - preserveFragment: true, + fragment: this.formatFragment(this.fragmentParams), replaceUrl: true, }); } @@ -1151,18 +1156,17 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { return cpfpParam === 'true' || cpfpParam === 'advanced' || cpfpParam === 'simple'; } - private getCpfpFragment(): string | null { - const currentParams = new URLSearchParams(this.fragmentParams?.toString() || this.route.snapshot.fragment || ''); - const fragmentParams = new URLSearchParams(); - if (this.cpfpMode) { - fragmentParams.set('cluster', ''); - } - for (const [key, value] of currentParams.entries()) { - if (key !== 'cluster') { - fragmentParams.set(key, value); + private formatFragment(fragmentParams: URLSearchParams, anchor: string | null = null): string | null { + const params = new URLSearchParams(fragmentParams.toString()); + for (const [key, value] of Array.from(params.entries())) { + if (value === '') { + params.delete(key); } } - return fragmentParams.toString() || null; + if (anchor) { + params.set(anchor, ''); + } + return params.toString() || null; } toggleGraph() { @@ -1172,7 +1176,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { relativeTo: this.route, queryParams: { showFlow: showFlow }, queryParamsHandling: 'merge', - fragment: 'flow' + fragment: this.formatFragment(this.fragmentParams, showFlow ? 'flow' : null) }); } @@ -1192,11 +1196,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { // simulate normal anchor fragment behavior applyFragment(): void { - const anchor = Array.from(this.fragmentParams.entries()).find(([frag, value]) => value === ''); - if (anchor?.length) { - const anchorElement = document.getElementById(anchor[0]); + if (this.fragmentAnchor && this.scrolledFragmentAnchor !== this.fragmentAnchor) { + const anchorElement = document.getElementById(this.fragmentAnchor); if (anchorElement) { - anchorElement.scrollIntoView({ behavior: 'smooth' }); + anchorElement.scrollIntoView({ behavior: this.firstFragmentScroll ? 'auto' : 'smooth' }); + this.firstFragmentScroll = false; + this.scrolledFragmentAnchor = this.fragmentAnchor; } } } @@ -1205,12 +1210,35 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.fragmentParams = new URLSearchParams(fragment || ''); const vin = parseInt(this.fragmentParams.get('vin'), 10); const vout = parseInt(this.fragmentParams.get('vout'), 10); - this.inputIndex = (!isNaN(vin) && vin >= 0) ? vin : null; - this.outputIndex = (!isNaN(vout) && vout >= 0) ? vout : null; + const inputIndex = (!isNaN(vin) && vin >= 0) ? vin : null; + const outputIndex = (!isNaN(vout) && vout >= 0) ? vout : null; + const selectionChanged = inputIndex !== this.inputIndex || outputIndex !== this.outputIndex; + const anchor = Array.from(this.fragmentParams.entries()).find(([, value]) => value === '')?.[0] || null; + this.inputIndex = inputIndex; + this.outputIndex = outputIndex; if (this.fragmentParams.has('accelerate')) { this.forceAccelerationSummary = true; } - setTimeout(() => { this.applyFragment(); }, 0); + if (!anchor && !this.fragmentAnchor) { + this.firstFragmentScroll = false; + } + if (selectionChanged && anchor) { + this.scrolledFragmentAnchor = null; + } + if (anchor !== this.fragmentAnchor) { + this.fragmentAnchor = anchor; + this.scrolledFragmentAnchor = null; + if (!this.fragmentAnchor) { + this.firstFragmentScroll = false; + } + } + if (this.scrolledFragmentAnchor !== this.fragmentAnchor) { + if (this.fragmentAnchor) { + setTimeout(() => { this.applyFragment(); }, 0); + } else { + this.firstFragmentScroll = false; + } + } } setHasAccelerationDetails(hasDetails: boolean): void { @@ -1237,20 +1265,20 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } onAccelerationCompleted(): void { - this.router.navigate([], { fragment: null, queryParamsHandling: 'merge' }); + this.router.navigate([], { fragment: this.formatFragment(this.fragmentParams), queryParamsHandling: 'merge' }); this.accelerationFlowCompleted = true; this.forceAccelerationSummary = false; } closeAccelerator(): void { - this.router.navigate([], { fragment: null, queryParamsHandling: 'merge' }); + this.router.navigate([], { fragment: this.formatFragment(this.fragmentParams), queryParamsHandling: 'merge' }); this.hideAccelerationSummary = true; this.forceAccelerationSummary = false; this.storageService.setValue('hide-accelerator-pref', 'true'); } openAccelerator(): void { - this.router.navigate([], { fragment: 'accelerate', queryParamsHandling: 'merge' }); + this.router.navigate([], { fragment: this.formatFragment(this.fragmentParams, 'accelerate'), queryParamsHandling: 'merge' }); this.accelerationFlowCompleted = false; this.hideAccelerationSummary = false; this.storageService.setValue('hide-accelerator-pref', 'false'); From 95eb1372f474c6002bda444a1dd3f0df96c184ba Mon Sep 17 00:00:00 2001 From: mononaut Date: Thu, 14 May 2026 05:10:09 +0000 Subject: [PATCH 02/23] remove cpfp/cluster autoscroll --- .../transaction/transaction-raw.component.ts | 6 +-- .../transaction/transaction.component.html | 1 - .../transaction/transaction.component.ts | 38 ++++--------------- 3 files changed, 9 insertions(+), 36 deletions(-) diff --git a/frontend/src/app/components/transaction/transaction-raw.component.ts b/frontend/src/app/components/transaction/transaction-raw.component.ts index 625cbf605..4886f86a9 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.ts +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -90,7 +90,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.seoService.setTitle($localize`:@@d7f92e6fe26fba6fff568cbdae5db4a5c8c6a55c:Preview Transaction`); this.seoService.setDescription($localize`:@@meta.description.preview-tx:Preview a transaction to the Bitcoin${seoDescriptionNetwork(this.stateService.network)} network using the transaction's raw hex data.`); this.websocketService.want(['blocks', 'mempool-blocks']); - this.cpfpMode = this.isCpfpParamEnabled(this.route.snapshot.queryParams['cpfp']); + this.cpfpMode = this.route.snapshot.queryParams['cpfp'] === 'true'; this.pushTxForm = this.formBuilder.group({ txRaw: ['', Validators.required], }); @@ -380,10 +380,6 @@ export class TransactionRawComponent implements OnInit, OnDestroy { } } - private isCpfpParamEnabled(cpfpParam: string | undefined): boolean { - return cpfpParam === 'true' || cpfpParam === 'advanced' || cpfpParam === 'simple'; - } - setupGraph() { this.maxInOut = Math.min(this.inOutLimit, Math.max(this.transaction?.vin?.length || 1, this.transaction?.vout?.length + 1 || 1)); this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80); diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index bc9145759..04da15d9b 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -82,7 +82,6 @@ -

Cluster

diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index c273459c8..4510bab5a 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -200,13 +200,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } } - @ViewChild('cluster') - set clusterAnchor(element: ElementRef | null | undefined) { - if (element) { - setTimeout(() => { this.applyFragment(); }, 0); - } - } - constructor( private route: ActivatedRoute, private router: Router, @@ -231,7 +224,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { ngOnInit() { this.enterpriseService.page(); this.isDetailsOpen = this.route.snapshot.queryParams['showDetails'] === 'true'; - this.cpfpMode = this.isCpfpParamEnabled(this.route.snapshot.queryParams['cpfp']); + this.cpfpMode = this.route.snapshot.queryParams['cpfp'] === 'true'; const urlParams = new URLSearchParams(window.location.search); this.forceAccelerationSummary = !!urlParams.get('cash_request_id'); @@ -1113,7 +1106,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.auditStatus = null; this.accelerationPositions = null; this.isDetailsOpen = this.route.snapshot.queryParams['showDetails'] === 'true'; - this.cpfpMode = this.isCpfpParamEnabled(this.route.snapshot.queryParams['cpfp']); + this.cpfpMode = this.route.snapshot.queryParams['cpfp'] === 'true'; document.body.scrollTo(0, 0); this.isAcceleration = false; this.isAccelerated$.next(this.isAcceleration); @@ -1133,27 +1126,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { toggleCpfp() { this.cpfpMode = !this.cpfpMode; - if (this.cpfpInfo?.cluster) { - this.router.navigate([], { - relativeTo: this.route, - queryParams: { cpfp: this.cpfpMode ? 'true' : null }, - queryParamsHandling: 'merge', - fragment: this.formatFragment(this.fragmentParams, this.cpfpMode ? 'cluster' : null), - replaceUrl: true, - }); - } else { - this.router.navigate([], { - relativeTo: this.route, - queryParams: { cpfp: this.cpfpMode ? 'simple' : null }, - queryParamsHandling: 'merge', - fragment: this.formatFragment(this.fragmentParams), - replaceUrl: true, - }); - } - } - - private isCpfpParamEnabled(cpfpParam: string | undefined): boolean { - return cpfpParam === 'true' || cpfpParam === 'advanced' || cpfpParam === 'simple'; + this.router.navigate([], { + relativeTo: this.route, + queryParams: { cpfp: this.cpfpMode ? 'true' : null }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); } private formatFragment(fragmentParams: URLSearchParams, anchor: string | null = null): string | null { From a4b46c2b48e0df6b7db6bcfb6672b4419e492960 Mon Sep 17 00:00:00 2001 From: mononaut Date: Thu, 14 May 2026 07:25:16 +0000 Subject: [PATCH 03/23] improve cluster diagram mobile UX --- .../cluster-diagram.component.html | 51 +++++++++-- .../cluster-diagram.component.scss | 19 ++-- .../cluster-diagram.component.ts | 89 ++++++++++++++++--- .../transaction-details.component.html | 3 +- .../transaction-raw.component.html | 4 +- .../transaction/transaction.component.html | 4 +- 6 files changed, 142 insertions(+), 28 deletions(-) diff --git a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.html b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.html index 59dbe36de..1848d1456 100644 --- a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.html +++ b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.html @@ -48,10 +48,12 @@ [class.inactive]="outline.chunkIndex !== effectiveActiveChunk" [attr.d]="outline.path" [ngbTooltip]="chunkLabelTooltip" + [disableTooltip]="isMobile" container="body" placement="top" (mouseenter)="onChunkEnter(outline.chunkIndex)" - (mouseleave)="onChunkLeave()" /> + (mouseleave)="onChunkLeave()" + (click)="onChunkClick(outline.chunkIndex, $event)" /> + (mouseleave)="onChunkLeave()" + (click)="onChunkClick(outline.chunkIndex, $event)"> {{ outline.feerate | feeRounding }} sat/vB @@ -76,7 +80,8 @@ stroke-width="12" (mouseenter)="onEdgeEnter(i, $event)" (mousemove)="onEdgeMove($event)" - (mouseleave)="onEdgeLeave()" /> + (mouseleave)="onEdgeLeave()" + (click)="onEdgeClick(i, $event)" /> + (click)="onNodeClick(node, $event)"> @@ -154,3 +159,39 @@
child
+ +
+ + +
+ + + + + + + + + + + + + +
Fee{{ hoverNode.tx.fee | number }} sats
Size
Fee rate{{ hoverNode.feerate | feeRounding }} sat/vB
+
+
parents
+
children
+
+
+
+
+
parent
+
child
+
+
diff --git a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.scss b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.scss index 603a5f999..18d716f94 100644 --- a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.scss +++ b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.scss @@ -144,17 +144,14 @@ font-weight: 700; } -.cluster-tooltip { - position: absolute; +.cluster-tooltip, +.cluster-mobile-panel { background: color-mix(in srgb, var(--active-bg) 95%, transparent); border-radius: 4px; box-shadow: 1px 1px 10px rgba(0, 0, 0, 0.5); color: var(--tooltip-grey); padding: 8px 12px; text-align: left; - pointer-events: none; - max-width: 360px; - white-space: nowrap; .tx-id-row { display: flex; @@ -225,3 +222,15 @@ &.descendant { background: var(--cluster-descendant-color); } } } + +.cluster-tooltip { + position: absolute; + pointer-events: none; + max-width: 360px; + white-space: nowrap; +} + +.cluster-mobile-panel { + margin-top: 8px; + white-space: normal; +} diff --git a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.ts b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.ts index df14efcba..070d3e46b 100644 --- a/frontend/src/app/components/cluster-diagram/cluster-diagram.component.ts +++ b/frontend/src/app/components/cluster-diagram/cluster-diagram.component.ts @@ -22,6 +22,7 @@ export class ClusterDiagramComponent implements OnChanges, AfterViewInit, OnDest @Input() cluster: { txs: CpfpClusterTx[]; chunks: CpfpClusterChunk[]; chunkIndex: number }; @Input() txid: string; @Input() preview = false; + @Input() isMobile = false; @ViewChild('graphContainer', { static: true }) graphContainer: ElementRef; @ViewChild('tooltip') tooltipElement: ElementRef; @@ -125,8 +126,17 @@ export class ClusterDiagramComponent implements OnChanges, AfterViewInit, OnDest } onNodeEnter(node: RenderedNode, event: MouseEvent): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } + this.applyNodeHighlight(node); + this.updateTooltipPosition(event); + this.cd.markForCheck(); + } + + private applyNodeHighlight(node: RenderedNode): void { this.hoverNode = node; + this.hoverEdge = null; + this.hoverChunkIndex = null; + this.applyEffectiveChunk(); this.clearHighlights(); node.hovered = true; for (const edge of this.edges) { @@ -140,58 +150,63 @@ export class ClusterDiagramComponent implements OnChanges, AfterViewInit, OnDest edge.highlightKind = 'ancestor'; } } - this.updateTooltipPosition(event); - this.cd.markForCheck(); } onNodeMove(event: MouseEvent): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } this.updateTooltipPosition(event); this.cd.markForCheck(); } onNodeLeave(): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } this.hoverNode = null; this.clearHighlights(); this.cd.markForCheck(); } onEdgeEnter(edgeIndex: number, event: MouseEvent): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } + this.applyEdgeHighlight(edgeIndex); + this.updateTooltipPosition(event); + this.cd.markForCheck(); + } + + private applyEdgeHighlight(edgeIndex: number): void { this.clearHighlights(); + this.hoverNode = null; + this.hoverChunkIndex = null; + this.applyEffectiveChunk(); const edge = this.edges[edgeIndex]; edge.highlighted = true; edge.highlightKind = 'direct'; this.nodes[edge.parentIndex].relation = 'ancestor'; this.nodes[edge.childIndex].relation = 'descendant'; this.hoverEdge = edge; - this.updateTooltipPosition(event); - this.cd.markForCheck(); } onEdgeMove(event: MouseEvent): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } this.updateTooltipPosition(event); this.cd.markForCheck(); } onEdgeLeave(): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } this.hoverEdge = null; this.clearHighlights(); this.cd.markForCheck(); } onChunkEnter(chunkIndex: number): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } this.hoverChunkIndex = chunkIndex; this.applyEffectiveChunk(); this.cd.markForCheck(); } onChunkLeave(): void { - if (this.preview) { return; } + if (this.preview || this.isMobile) { return; } this.hoverChunkIndex = null; this.applyEffectiveChunk(); this.cd.markForCheck(); @@ -212,13 +227,61 @@ export class ClusterDiagramComponent implements OnChanges, AfterViewInit, OnDest } } - onNodeClick(node: RenderedNode): void { + onNodeClick(node: RenderedNode, event: MouseEvent): void { if (this.preview) { return; } + if (this.isMobile) { + event.stopPropagation(); + if (this.hoverNode?.index === node.index) { + this.clearMobileSelection(); + } else { + this.applyNodeHighlight(node); + this.cd.markForCheck(); + } + return; + } const network = this.stateService.network; const prefix = network && network !== 'mainnet' ? `/${network}` : ''; this.router.navigate([prefix + '/tx/', node.tx.txid]); } + onEdgeClick(edgeIndex: number, event: MouseEvent): void { + if (this.preview || !this.isMobile) { return; } + event.stopPropagation(); + const edge = this.edges[edgeIndex]; + if (this.hoverEdge === edge) { + this.clearMobileSelection(); + } else { + this.applyEdgeHighlight(edgeIndex); + this.cd.markForCheck(); + } + } + + onChunkClick(chunkIndex: number, event: MouseEvent): void { + if (this.preview || !this.isMobile) { return; } + event.stopPropagation(); + this.hoverNode = null; + this.hoverEdge = null; + this.clearHighlights(); + this.hoverChunkIndex = chunkIndex; + this.applyEffectiveChunk(); + this.cd.markForCheck(); + } + + @HostListener('click') + onBackgroundClick(): void { + if (this.preview || !this.isMobile) { return; } + this.clearMobileSelection(); + } + + private clearMobileSelection(): void { + this.hoverNode = null; + this.hoverEdge = null; + this.hoverChunkIndex = null; + this.clearHighlights(); + this.applyEffectiveChunk(); + this.cd.markForCheck(); + } + private clearHighlights(): void { for (const node of this.nodes) { node.hovered = false; diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html index f6decfacf..41951982b 100644 --- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html +++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html @@ -248,13 +248,14 @@
- +
diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html index 228aedcb9..ee2242099 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.html +++ b/frontend/src/app/components/transaction/transaction-raw.component.html @@ -104,7 +104,7 @@
+ [cluster]="cpfpInfo.cluster" [txid]="transaction.txid" [isMobile]="isMobile">
@@ -248,4 +248,4 @@ } - \ No newline at end of file + diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 04da15d9b..800d3b54e 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -92,7 +92,7 @@
+ [cluster]="cpfpInfo.cluster" [txid]="tx.txid" [isMobile]="isMobile">
@@ -393,4 +393,4 @@
- \ No newline at end of file + From 1173d6bdc464c616233e9dc537577c797fbdf40e Mon Sep 17 00:00:00 2001 From: mononaut Date: Thu, 14 May 2026 07:25:51 +0000 Subject: [PATCH 04/23] fix cluster diagram preview alignment --- .../cluster-diagram/cluster-renderer.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/components/cluster-diagram/cluster-renderer.ts b/frontend/src/app/components/cluster-diagram/cluster-renderer.ts index 69f30d580..8ccefbd1c 100644 --- a/frontend/src/app/components/cluster-diagram/cluster-renderer.ts +++ b/frontend/src/app/components/cluster-diagram/cluster-renderer.ts @@ -121,6 +121,7 @@ const PREVIEW_DIMENSIONS: RenderDimensions = { }; const PREVIEW_VIEWPORT_HEIGHT = 48; +const PREVIEW_SAFE_INSET = 16; const OUTLINE_PAD = 6; export function renderLayout(layout: GridLayout, params: RenderParams): RenderResult { @@ -175,7 +176,7 @@ export function renderLayout(layout: GridLayout, params: RenderParams): RenderRe let cellW: number; if (params.preview) { const activeCols = activeChunkColCount(layout, params.activeChunkIndex); - const denom = Math.max(1, activeCols); + const denom = Math.max(1, activeCols + 1); cellW = Math.max(dim.minCellW, Math.min(dim.maxCellW, (params.containerWidth - dim.marginX * 2) / denom)); } else if (layout.cols > 0) { @@ -222,15 +223,21 @@ export function renderLayout(layout: GridLayout, params: RenderParams): RenderRe activeMaxY = Math.max(activeMaxY, n.rectY + n.height); } } - const pad = dim.marginX; + const pad = dim.marginX + PREVIEW_SAFE_INSET; const chunkFits = isFinite(activeMinX) && (activeMaxX - activeMinX) + 2 * pad <= vbWidth; - const vbX = chunkFits + let vbX = chunkFits ? (activeMinX + activeMaxX) / 2 - vbWidth / 2 : selected.x - vbWidth / 2; + if (!chunkFits && isFinite(activeMinX)) { + vbX = Math.max(activeMinX - pad, Math.min(activeMaxX + pad - vbWidth, vbX)); + } const chunkFitsVertically = isFinite(activeMinY) && (activeMaxY - activeMinY) + 2 * dim.marginY <= vbHeight; - const vbY = chunkFitsVertically + let vbY = chunkFitsVertically ? (activeMinY + activeMaxY) / 2 - vbHeight / 2 : selected.y - vbHeight / 2; + if (!chunkFitsVertically && isFinite(activeMinY)) { + vbY = Math.max(activeMinY - dim.marginY, Math.min(activeMaxY + dim.marginY - vbHeight, vbY)); + } return { nodes, edges, chunkOutlines, From 1469503993754e6ac07b2e3a154688a56bddf0fa Mon Sep 17 00:00:00 2001 From: mononaut Date: Thu, 14 May 2026 08:57:19 +0000 Subject: [PATCH 05/23] fix missing fragment param in cpfp link --- frontend/src/app/components/transaction/transaction.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 4510bab5a..459914f91 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -1130,6 +1130,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { relativeTo: this.route, queryParams: { cpfp: this.cpfpMode ? 'true' : null }, queryParamsHandling: 'merge', + fragment: this.formatFragment(this.fragmentParams), replaceUrl: true, }); } From 67b8c2c9b3a11952682ee82ec60d6bcf69146642 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 2 Apr 2026 09:55:58 +0000 Subject: [PATCH 06/23] shill the accelerator harder --- .../transaction-details.component.html | 5 ++ .../transaction-details.component.ts | 66 +++++++++++++++++-- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html index f6decfacf..89655db13 100644 --- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html +++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html @@ -46,6 +46,11 @@ + @if (showAcceleratorSavingsMsg) { + + Could have saved using Mempool Accelerator Pro™ + + } @if (!isLoadingTx && !tx?.status?.confirmed && isAcceleration) { } @else { diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts index 39272b684..20af39a58 100644 --- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts +++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts @@ -1,11 +1,14 @@ -import { Component, OnInit, Input, ChangeDetectionStrategy, Output, EventEmitter } from '@angular/core'; +import { Component, OnChanges, SimpleChanges, Input, ChangeDetectionStrategy, ChangeDetectorRef, Output, EventEmitter } from '@angular/core'; import { Transaction } from '@interfaces/electrs.interface'; -import { Acceleration, CpfpInfo } from '@interfaces/node-api.interface'; +import { Acceleration, BlockExtended, CpfpInfo } from '@interfaces/node-api.interface'; import { Pool, TxAuditStatus } from '@components/transaction/transaction.component'; import { Observable } from 'rxjs'; +import { first, timeout } from 'rxjs/operators'; import { ETA } from '@app/services/eta.service'; import { MiningStats } from '@app/services/mining.service'; -import { Filter } from '@app/shared/filters.utils'; +import { Filter, TransactionFlags } from '@app/shared/filters.utils'; +import { StateService } from '@app/services/state.service'; +import { CacheService } from '@app/services/cache.service'; @Component({ selector: 'app-transaction-details', @@ -14,7 +17,7 @@ import { Filter } from '@app/shared/filters.utils'; standalone: false, changeDetection: ChangeDetectionStrategy.OnPush }) -export class TransactionDetailsComponent implements OnInit { +export class TransactionDetailsComponent implements OnChanges { @Input() network: string; @Input() tx: Transaction; @Input() isLoadingTx: boolean; @@ -45,9 +48,60 @@ export class TransactionDetailsComponent implements OnInit { @Output() accelerateClicked = new EventEmitter(); @Output() toggleCpfp$ = new EventEmitter(); - constructor() {} + acceleratorSavingsSats = 0; + officialMempoolSpace: boolean; - ngOnInit(): void {} + constructor( + private stateService: StateService, + private cacheService: CacheService, + private cd: ChangeDetectorRef, + ) { + this.officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE; + } + + ngOnChanges(changes: SimpleChanges): void { + if (!changes.tx) { + return; + } + this.acceleratorSavingsSats = 0; + const hasIneligibleFlags = this.tx?.flags && (this.tx.flags & (TransactionFlags.inscription | TransactionFlags.sighash_none | TransactionFlags.sighash_single | TransactionFlags.sighash_acp)) > 0n; + if (this.officialMempoolSpace + && this.tx?.status?.confirmed + && !this.tx.acceleration && !this.accelerationInfo + && this.tx.weight <= 4000 + && !hasIneligibleFlags + && Math.min(...this.tx.vout.map(o => o.value)) <= 1000000 + ) { + const block = this.cacheService.getCachedBlock(this.tx.status.block_height); + if (block) { + this.calculateAcceleratorSavings(block); + } else { + const txid = this.tx.txid; + this.cacheService.loadBlock(this.tx.status.block_height); + this.cacheService.loadedBlocks$.pipe( + first(b => b.height === this.tx.status.block_height), + timeout({ each: 30000, with: () => [] }), + ).subscribe((block) => { + if (this.tx?.txid === txid) { + this.calculateAcceleratorSavings(block); + this.cd.markForCheck(); + } + }); + } + } + } + + calculateAcceleratorSavings(block: BlockExtended): void { + const minBlockRate = block?.extras?.feeRange?.[0]; + if (minBlockRate) { + const vsize = this.tx.weight / 4; + this.acceleratorSavingsSats = Math.max(0, this.tx.fee - Math.ceil(minBlockRate * vsize) - 75000); + } + } + + get showAcceleratorSavingsMsg(): boolean { + return this.acceleratorSavingsSats > 0 && this.cpfpInfo != null && !this.hasCpfp; + } onAccelerateClicked(): void { this.accelerateClicked.emit(true); From 51d0fd8c8353ab0724674a78802ae048fb6867bf Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 3 Apr 2026 01:35:45 +0000 Subject: [PATCH 07/23] fix copilot nits --- .../transaction-details/transaction-details.component.html | 2 +- .../transaction-details/transaction-details.component.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html index 89655db13..cf28d0960 100644 --- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html +++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html @@ -48,7 +48,7 @@ @if (showAcceleratorSavingsMsg) { - Could have saved using Mempool Accelerator Pro™ + Could have saved using Mempool Accelerator Pro™ } @if (!isLoadingTx && !tx?.status?.confirmed && isAcceleration) { diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts index 20af39a58..4b3c7806e 100644 --- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts +++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts @@ -64,7 +64,7 @@ export class TransactionDetailsComponent implements OnChanges { return; } this.acceleratorSavingsSats = 0; - const hasIneligibleFlags = this.tx?.flags && (this.tx.flags & (TransactionFlags.inscription | TransactionFlags.sighash_none | TransactionFlags.sighash_single | TransactionFlags.sighash_acp)) > 0n; + const hasIneligibleFlags = ((this.tx?.flags ?? 0n) & (TransactionFlags.inscription | TransactionFlags.sighash_none | TransactionFlags.sighash_single | TransactionFlags.sighash_acp)) > 0n; if (this.officialMempoolSpace && this.tx?.status?.confirmed && !this.tx.acceleration && !this.accelerationInfo @@ -93,14 +93,14 @@ export class TransactionDetailsComponent implements OnChanges { calculateAcceleratorSavings(block: BlockExtended): void { const minBlockRate = block?.extras?.feeRange?.[0]; - if (minBlockRate) { + if (minBlockRate !== undefined) { const vsize = this.tx.weight / 4; this.acceleratorSavingsSats = Math.max(0, this.tx.fee - Math.ceil(minBlockRate * vsize) - 75000); } } get showAcceleratorSavingsMsg(): boolean { - return this.acceleratorSavingsSats > 0 && this.cpfpInfo != null && !this.hasCpfp; + return this.acceleratorSavingsSats > 0 && this.cpfpInfo !== null && this.cpfpInfo !== undefined && !this.hasCpfp; } onAccelerateClicked(): void { From bbd077a7a8f33387c3fcdaf3632c61c2fdbf057e Mon Sep 17 00:00:00 2001 From: mononaut Date: Tue, 19 May 2026 12:17:15 +0000 Subject: [PATCH 08/23] fix (c) -> (r) --- .../transaction-details/transaction-details.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html index cf28d0960..2ec9ac28c 100644 --- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html +++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html @@ -48,7 +48,7 @@ @if (showAcceleratorSavingsMsg) { - Could have saved using Mempool Accelerator Pro™ + Could have saved using Mempool Accelerator® Pro } @if (!isLoadingTx && !tx?.status?.confirmed && isAcceleration) { From 640910d8aaa0712586a55c3abe5ae8add43e81e6 Mon Sep 17 00:00:00 2001 From: mononaut Date: Mon, 25 May 2026 10:57:22 +0000 Subject: [PATCH 09/23] [ops] fix liquid assets sync --- production/liquid-sync-assets | 11 +++++++++++ production/mempool.crontab | 3 +-- 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100755 production/liquid-sync-assets diff --git a/production/liquid-sync-assets b/production/liquid-sync-assets new file mode 100755 index 000000000..feaa1ae3c --- /dev/null +++ b/production/liquid-sync-assets @@ -0,0 +1,11 @@ +#!/usr/bin/env zsh +set -e + +export NVM_DIR="$HOME/.nvm" +source "$NVM_DIR/nvm.sh" +nvm use v24.13.0 + +cd "$HOME/liquid/frontend" +npm run sync-assets + +rsync -av "$HOME/liquid/frontend/dist/mempool/browser/resources/assets"* "$HOME/public_html/liquid/resources/" diff --git a/production/mempool.crontab b/production/mempool.crontab index 196c2566e..705cf02c1 100644 --- a/production/mempool.crontab +++ b/production/mempool.crontab @@ -5,5 +5,4 @@ 37 13 * * * sleep 30 ; /mempool/mempool.space/backup >/dev/null 2>&1 & # hourly liquid asset update -6 * * * * cd $HOME/liquid/frontend && npm run sync-assets && rsync -av $HOME/liquid/frontend/dist/mempool/browser/resources/assets* $HOME/public_html/liquid/resources/ >/dev/null 2>&1 - +6 * * * * $HOME/mempool/production/liquid-sync-assets >/dev/null 2>&1 From 051c4cf5b1c44d094e7180e0e02c3c86e6f08842 Mon Sep 17 00:00:00 2001 From: mononaut Date: Mon, 25 May 2026 11:06:31 +0000 Subject: [PATCH 10/23] [ops] reduce liquid asset cache times --- production/nginx/server-common.conf | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/production/nginx/server-common.conf b/production/nginx/server-common.conf index ee5081ced..5532076ec 100644 --- a/production/nginx/server-common.conf +++ b/production/nginx/server-common.conf @@ -94,6 +94,24 @@ location /resources/customize. { expires 5m; } +# only cache liquid asset registry files for 1 hour +location = /resources/assets.json { + try_files $uri =404; + expires 1h; +} +location = /resources/assets.minimal.json { + try_files $uri =404; + expires 1h; +} +location = /resources/assets-testnet.json { + try_files $uri =404; + expires 1h; +} +location = /resources/assets-testnet.minimal.json { + try_files $uri =404; + expires 1h; +} + # cache /main.f40e91d908a068a2.js forever since they never change location ~* ^/.+\..+\.(js|css)$ { try_files /$lang/$uri /en-US/$uri =404; From e167577b4cefc86b3d1f84fc70c68ad26015c470 Mon Sep 17 00:00:00 2001 From: mononaut Date: Mon, 25 May 2026 11:15:14 +0000 Subject: [PATCH 11/23] [ops] delete obsolete assets script --- production/mempool-update-assets | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100755 production/mempool-update-assets diff --git a/production/mempool-update-assets b/production/mempool-update-assets deleted file mode 100755 index 10debd09b..000000000 --- a/production/mempool-update-assets +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env zsh -set -e - -wget -O /mempool/public_html/mainnet/resources/assets.json https://raw.githubusercontent.com/blockstream/asset_registry_db/master/index.json -wget -O /mempool/public_html/mainnet/resources/assets.minimal.json https://raw.githubusercontent.com/blockstream/asset_registry_db/master/index.minimal.json - -wget -O /mempool/public_html/mainnet/resources/assets.json https://raw.githubusercontent.com/blockstream/asset_registry_testnet_db/master/index.json -wget -O /mempool/public_html/mainnet/resources/assets.minimal.json https://raw.githubusercontent.com/blockstream/asset_registry_testnet_db/master/index.minimal.json - -exit 0 From 73b64cc5fbc442d7d68b558387c6ecd7cb6c4820 Mon Sep 17 00:00:00 2001 From: mononaut Date: Mon, 25 May 2026 16:13:02 +0000 Subject: [PATCH 12/23] use esplora registry api for liquid asset page --- .../app/components/asset/asset.component.ts | 22 ++++++++----------- .../src/app/interfaces/electrs.interface.ts | 4 ++++ frontend/src/app/services/assets.service.ts | 18 +++++++++++++-- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/components/asset/asset.component.ts b/frontend/src/app/components/asset/asset.component.ts index 5afcdbd1d..70793266b 100644 --- a/frontend/src/app/components/asset/asset.component.ts +++ b/frontend/src/app/components/asset/asset.component.ts @@ -7,7 +7,7 @@ import { WebsocketService } from '@app/services/websocket.service'; import { StateService } from '@app/services/state.service'; import { AudioService } from '@app/services/audio.service'; import { ApiService } from '@app/services/api.service'; -import { of, merge, Subscription, combineLatest } from 'rxjs'; +import { of, merge, Subscription, EMPTY } from 'rxjs'; import { SeoService } from '@app/services/seo.service'; import { environment } from '@environments/environment'; import { AssetsService } from '@app/services/assets.service'; @@ -82,30 +82,26 @@ export class AssetComponent implements OnInit, OnDestroy { ) .pipe( switchMap(() => { - return combineLatest([this.electrsApiService.getAsset$(this.assetString) + return this.electrsApiService.getAsset$(this.assetString) .pipe( catchError((err) => { this.isLoadingAsset = false; this.error = err; this.seoService.logSoft404(); console.log(err); - return of(null); - }) - ), this.assetsService.getAssetsMinimalJson$]) - .pipe( - take(1) - ); + return EMPTY; + }), + switchMap((asset) => this.assetsService.enrichLiquidAsset$(asset)), + take(1) + ); }) ); }) ) .pipe( - switchMap(([asset, assetsData]) => { + switchMap((asset) => { this.asset = asset; - this.assetContract = assetsData[this.asset.asset_id]; - if (!this.assetContract) { - this.assetContract = [null, '?', 'Unknown', 0]; - } + this.assetContract = [asset.entity?.domain || null, asset.ticker || '?', asset.name || 'Unknown', asset.precision || 0]; this.seoService.setDescription($localize`:@@meta.description.liquid.asset:Browse an overview of the Liquid asset ${this.assetContract[2]}:INTERPOLATION: (${this.assetContract[1]}:INTERPOLATION:): see issued amount, burned amount, circulating amount, related transactions, and more.`); this.blindedIssuance = this.asset.chain_stats.has_blinded_issuances || this.asset.mempool_stats.has_blinded_issuances; this.isNativeAsset = asset.asset_id === this.nativeAssetId; diff --git a/frontend/src/app/interfaces/electrs.interface.ts b/frontend/src/app/interfaces/electrs.interface.ts index 3eae7e391..ee4ef08af 100644 --- a/frontend/src/app/interfaces/electrs.interface.ts +++ b/frontend/src/app/interfaces/electrs.interface.ts @@ -206,6 +206,10 @@ export interface Asset { status: Status; chain_stats: AssetStats; mempool_stats: AssetStats; + name?: string; + ticker?: string; + precision?: number; + entity?: Entity; } export interface AssetExtended extends Asset { diff --git a/frontend/src/app/services/assets.service.ts b/frontend/src/app/services/assets.service.ts index 42afa9627..3320a52f6 100644 --- a/frontend/src/app/services/assets.service.ts +++ b/frontend/src/app/services/assets.service.ts @@ -1,10 +1,10 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { map, shareReplay, switchMap } from 'rxjs/operators'; import { StateService } from '@app/services/state.service'; import { environment } from '@environments/environment'; -import { AssetExtended } from '@interfaces/electrs.interface'; +import { Asset, AssetExtended } from '@interfaces/electrs.interface'; @Injectable({ providedIn: 'root' @@ -69,4 +69,18 @@ export class AssetsService { this.getWorldMapJson$ = this.httpClient.get(apiBaseUrl + '/resources/worldmap.json').pipe(shareReplay()); } + + public enrichLiquidAsset$(asset: Asset): Observable { + if (asset.name || asset.ticker || asset.precision != null) { + return of(asset); + } else if (this.stateService.network === 'liquid' && asset.asset_id === environment.nativeAssetId) { + return of({ ...asset, name: 'Liquid Bitcoin', ticker: 'LBTC', precision: 8 }); + } else if (this.stateService.network === 'liquidtestnet' && asset.asset_id === environment.nativeTestAssetId) { + return of({ ...asset, name: 'Test Liquid Bitcoin', ticker: 'tLBTC', precision: 8 }); + } else { + return this.getAssetsJson$.pipe( + map((assets) => assets.objects[asset.asset_id] ? { ...asset, ...assets.objects[asset.asset_id] } : asset), + ); + } + } } From c5b9dc020e68e88aeb586a3a20e2f4a9258f06cd Mon Sep 17 00:00:00 2001 From: mononaut Date: Mon, 25 May 2026 16:37:35 +0000 Subject: [PATCH 13/23] use paginated esplora registry api for liquid assets list --- .../components/assets/assets.component.html | 2 +- .../app/components/assets/assets.component.ts | 55 +++++------------ .../src/app/interfaces/electrs.interface.ts | 8 +++ frontend/src/app/services/assets.service.ts | 60 ++++++++++++++++--- .../src/app/services/electrs-api.service.ts | 14 ++++- 5 files changed, 86 insertions(+), 53 deletions(-) diff --git a/frontend/src/app/components/assets/assets.component.html b/frontend/src/app/components/assets/assets.component.html index 30c6b7255..4685d7b49 100644 --- a/frontend/src/app/components/assets/assets.component.html +++ b/frontend/src/app/components/assets/assets.component.html @@ -18,7 +18,7 @@
- +

diff --git a/frontend/src/app/components/assets/assets.component.ts b/frontend/src/app/components/assets/assets.component.ts index f0f081f58..c34463ed7 100644 --- a/frontend/src/app/components/assets/assets.component.ts +++ b/frontend/src/app/components/assets/assets.component.ts @@ -1,13 +1,11 @@ import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; import { AssetsService } from '@app/services/assets.service'; -import { environment } from '@environments/environment'; import { UntypedFormGroup } from '@angular/forms'; -import { filter, map, switchMap, take } from 'rxjs/operators'; +import { map, switchMap } from 'rxjs/operators'; import { ActivatedRoute, Router } from '@angular/router'; -import { combineLatest, Observable } from 'rxjs'; -import { AssetExtended } from '@interfaces/electrs.interface'; +import { Observable } from 'rxjs'; import { SeoService } from '@app/services/seo.service'; -import { StateService } from '@app/services/state.service'; +import { AssetRegistryItem } from '@interfaces/electrs.interface'; @Component({ selector: 'app-assets', @@ -17,16 +15,14 @@ import { StateService } from '@app/services/state.service'; standalone: false, }) export class AssetsComponent implements OnInit { - nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId; paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 4 : 6; ellipses = window.matchMedia('(max-width: 670px)').matches ? false : true; - assets: AssetExtended[]; - assetsCache: AssetExtended[]; searchForm: UntypedFormGroup; - assets$: Observable; + assets$: Observable; page = 1; + totalAssets = 0; error: any; itemsPerPage: number; @@ -38,46 +34,23 @@ export class AssetsComponent implements OnInit { private route: ActivatedRoute, private router: Router, private seoService: SeoService, - private stateService: StateService, ) { } ngOnInit() { this.seoService.setTitle($localize`:@@ee8f8008bae6ce3a49840c4e1d39b4af23d4c263:Assets`); this.itemsPerPage = Math.max(Math.round(this.contentSpace / this.fiveItemsPxSize) * 5, 10); - this.assets$ = combineLatest([ - this.assetsService.getAssetsJson$, - this.route.queryParams, - ]) + this.assets$ = this.route.queryParams .pipe( - take(1), - switchMap(([assets, qp]) => { - this.assets = assets.array; - - return this.route.queryParams - .pipe( - filter((queryParams) => { - const newPage = parseInt(queryParams.page, 10); - if (newPage !== this.page) { - return true; - } - return false; - }), - map((queryParams) => { - if (queryParams.page) { - const newPage = parseInt(queryParams.page, 10); - this.page = newPage; - } else { - this.page = 1; - } - return ''; - }) - ); - }), - map(() => { + switchMap((queryParams) => { + this.page = queryParams.page ? parseInt(queryParams.page, 10) : 1; const start = (this.page - 1) * this.itemsPerPage; - return this.assets.slice(start, this.itemsPerPage + start); - }) + return this.assetsService.getLiquidAssetsPage$(start, this.itemsPerPage); + }), + map((result) => { + this.totalAssets = result.total; + return result.assets; + }), ); } diff --git a/frontend/src/app/interfaces/electrs.interface.ts b/frontend/src/app/interfaces/electrs.interface.ts index ee4ef08af..a8e6d4483 100644 --- a/frontend/src/app/interfaces/electrs.interface.ts +++ b/frontend/src/app/interfaces/electrs.interface.ts @@ -225,6 +225,14 @@ export interface Entity { domain: string; } +export interface AssetRegistryItem { + asset_id: string; + name: string; + ticker?: string; + domain?: string; + entity?: Entity; +} + interface IssuanceTxin { txid: string; vin: number; diff --git a/frontend/src/app/services/assets.service.ts b/frontend/src/app/services/assets.service.ts index 3320a52f6..6d51c7f53 100644 --- a/frontend/src/app/services/assets.service.ts +++ b/frontend/src/app/services/assets.service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable, of } from 'rxjs'; -import { map, shareReplay, switchMap } from 'rxjs/operators'; +import { Observable, of, throwError } from 'rxjs'; +import { catchError, map, shareReplay, switchMap } from 'rxjs/operators'; import { StateService } from '@app/services/state.service'; import { environment } from '@environments/environment'; -import { Asset, AssetExtended } from '@interfaces/electrs.interface'; +import { ElectrsApiService } from '@app/services/electrs-api.service'; +import { Asset, AssetExtended, AssetRegistryItem } from '@interfaces/electrs.interface'; @Injectable({ providedIn: 'root' @@ -15,19 +16,25 @@ export class AssetsService { getAssetsJson$: Observable<{ array: AssetExtended[]; objects: any}>; getAssetsMinimalJson$: Observable; getWorldMapJson$: Observable; + registryAvailable = true; + private apiBaseUrl = ''; constructor( private httpClient: HttpClient, private stateService: StateService, + private electrsApiService: ElectrsApiService, ) { - let apiBaseUrl = ''; - if (!this.stateService.isBrowser) { - apiBaseUrl = this.stateService.env.NGINX_PROTOCOL + '://' + this.stateService.env.NGINX_HOSTNAME + ':' + this.stateService.env.NGINX_PORT; + this.apiBaseUrl = ''; // use relative URL by default + if (!stateService.isBrowser) { // except when inside AU SSR process + this.apiBaseUrl = this.stateService.env.NGINX_PROTOCOL + '://' + this.stateService.env.NGINX_HOSTNAME + ':' + this.stateService.env.NGINX_PORT; } + this.stateService.networkChanged$.subscribe(() => { + this.registryAvailable = true; + }); this.getAssetsJson$ = this.stateService.networkChanged$ .pipe( - switchMap(() => this.httpClient.get(`${apiBaseUrl}/resources/assets${this.stateService.network === 'liquidtestnet' ? '-testnet' : ''}.json`)), + switchMap(() => this.httpClient.get(`${this.apiBaseUrl}/resources/assets${this.stateService.network === 'liquidtestnet' ? '-testnet' : ''}.json`)), map((rawAssets) => { const assets: AssetExtended[] = Object.values(rawAssets); @@ -56,7 +63,7 @@ export class AssetsService { ); this.getAssetsMinimalJson$ = this.stateService.networkChanged$ .pipe( - switchMap(() => this.httpClient.get(`${apiBaseUrl}/resources/assets${this.stateService.network === 'liquidtestnet' ? '-testnet' : ''}.minimal.json`)), + switchMap(() => this.httpClient.get(`${this.apiBaseUrl}/resources/assets${this.stateService.network === 'liquidtestnet' ? '-testnet' : ''}.minimal.json`)), map((assetsMinimal) => { if (this.stateService.network === 'liquidtestnet') { // Hard coding the Liquid Testnet native asset @@ -67,7 +74,7 @@ export class AssetsService { shareReplay(1), ); - this.getWorldMapJson$ = this.httpClient.get(apiBaseUrl + '/resources/worldmap.json').pipe(shareReplay()); + this.getWorldMapJson$ = this.httpClient.get(this.apiBaseUrl + '/resources/worldmap.json').pipe(shareReplay()); } public enrichLiquidAsset$(asset: Asset): Observable { @@ -83,4 +90,39 @@ export class AssetsService { ); } } + + public getLiquidAssetsPage$(startIndex: number, limit: number): Observable<{ assets: AssetRegistryItem[]; total: number }> { + return (this.registryAvailable ? this.electrsApiService.getLiquidAssetsRegistry$(startIndex, limit).pipe( + map((response) => { + const assets = response.body || []; + const total = parseInt(response.headers.get('X-Total-Results') || `${assets.length}`, 10); + if (!total && !assets.length) { + this.registryAvailable = false; + return null; + } + return { assets, total }; + }), + catchError((error) => { + if (![404, 501].includes(error.status)) { + return throwError(() => error); + } + this.registryAvailable = false; + return of(null); + }), + ) : of(null)).pipe( + switchMap((registryPage) => registryPage ? of(registryPage) : this.getAssetsJson$.pipe( + map((assets) => ({ + assets: assets.array.slice(startIndex, startIndex + limit), + total: assets.array.length, + })), + )), + map((page) => ({ + ...page, + assets: page.assets.map((asset) => ({ + ...asset, + entity: asset.entity || (asset.domain ? { domain: asset.domain } : undefined), + })), + })), + ); + } } diff --git a/frontend/src/app/services/electrs-api.service.ts b/frontend/src/app/services/electrs-api.service.ts index 831c9f167..687156f44 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 { HttpClient, HttpParams, HttpResponse } 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, Utxo } from '@interfaces/electrs.interface'; +import { Transaction, Address, Outspend, Recent, Asset, ScriptHash, AddressTxSummary, Utxo, AssetRegistryItem } from '@interfaces/electrs.interface'; import { StateService } from '@app/services/state.service'; import { BlockExtended } from '@interfaces/node-api.interface'; import { calcScriptHash$ } from '@app/bitcoin.utils'; @@ -228,6 +228,16 @@ export class ElectrsApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId); } + getLiquidAssetsRegistry$(startIndex: number, limit: number): Observable> { + const params = new HttpParams() + .set('start_index', startIndex) + .set('limit', limit) + .set('sort_field', 'name') + .set('sort_dir', 'asc'); + + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/assets/registry', { params, observe: 'response' }); + } + getAssetTransactions$(assetId: string): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId + '/txs'); } From 150a8fd87777fd77c9cf2cae30d503006b43b3d8 Mon Sep 17 00:00:00 2001 From: mononaut Date: Mon, 25 May 2026 16:48:41 +0000 Subject: [PATCH 14/23] use esplora registry data for asset group pages --- .../asset-group/asset-group.component.ts | 27 ++++++++----------- frontend/src/app/services/assets.service.ts | 24 ++++++++++++++++- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/components/assets/asset-group/asset-group.component.ts b/frontend/src/app/components/assets/asset-group/asset-group.component.ts index 4c3d45639..cb1932908 100644 --- a/frontend/src/app/components/assets/asset-group/asset-group.component.ts +++ b/frontend/src/app/components/assets/asset-group/asset-group.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, ParamMap } from '@angular/router'; -import { combineLatest, Observable } from 'rxjs'; +import { from, Observable } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; import { ApiService } from '@app/services/api.service'; import { AssetsService } from '@app/services/assets.service'; @@ -24,22 +24,17 @@ export class AssetGroupComponent implements OnInit { this.group$ = this.route.paramMap .pipe( switchMap((params: ParamMap) => { - return combineLatest([ - this.assetsService.getAssetsJson$, - this.apiService.getAssetGroup$(params.get('id')), - ]); + return this.apiService.getAssetGroup$(params.get('id')); + }), + switchMap((group) => { + return from(Promise.all(group.assets.map((assetId) => this.assetsService.getLiquidAssetData(assetId).catch(() => ({ asset_id: assetId }))))) + .pipe( + map((assets) => ({ + group: group, + assets: assets, + })) + ); }), - map(([assets, group]) => { - const items = []; - // @ts-ignore - for (const item of group.assets) { - items.push(assets.objects[item]); - } - return { - group: group, - assets: items - }; - }) ); } } diff --git a/frontend/src/app/services/assets.service.ts b/frontend/src/app/services/assets.service.ts index 6d51c7f53..eee61b98e 100644 --- a/frontend/src/app/services/assets.service.ts +++ b/frontend/src/app/services/assets.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable, of, throwError } from 'rxjs'; +import { firstValueFrom, Observable, of, throwError } from 'rxjs'; import { catchError, map, shareReplay, switchMap } from 'rxjs/operators'; import { StateService } from '@app/services/state.service'; import { environment } from '@environments/environment'; @@ -77,6 +77,28 @@ export class AssetsService { this.getWorldMapJson$ = this.httpClient.get(this.apiBaseUrl + '/resources/worldmap.json').pipe(shareReplay()); } + public async getLiquidAssetData(assetId: string): Promise> { + if (this.stateService.network === 'liquid' && assetId === environment.nativeAssetId) { + return { asset_id: assetId, name: 'Liquid Bitcoin', ticker: 'LBTC', precision: 8 }; + } else if (this.stateService.network === 'liquidtestnet' && assetId === environment.nativeTestAssetId) { + return { asset_id: assetId, name: 'Test Liquid Bitcoin', ticker: 'tLBTC', precision: 8 }; + } else if (this.registryAvailable) { + try { + const apiAsset = await firstValueFrom(this.electrsApiService.getAsset$(assetId)); + if (apiAsset.name || apiAsset.ticker || apiAsset.precision != null) { + return apiAsset; + } + } catch (error: any) { + if (![404, 501].includes(error?.status)) { + throw error; + } + } + } + + const assets = await firstValueFrom(this.getAssetsJson$); + return assets.objects[assetId] || {}; + } + public enrichLiquidAsset$(asset: Asset): Observable { if (asset.name || asset.ticker || asset.precision != null) { return of(asset); From b16a3402703fee9c6624b2d1c33457f305da61ec Mon Sep 17 00:00:00 2001 From: mononaut Date: Mon, 25 May 2026 17:04:03 +0000 Subject: [PATCH 15/23] use esplora registry api for tx output asset annotations --- .../transactions-list.component.ts | 23 +++++++--- .../tx-bowtie-graph.component.ts | 19 +++++--- frontend/src/app/services/assets.service.ts | 46 +++++++++++++++++-- 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index 4e40d4e67..45659a5b6 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -59,7 +59,7 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy { refreshOutspends$: ReplaySubject = new ReplaySubject(); refreshChannels$: ReplaySubject = new ReplaySubject(); showDetails$ = new BehaviorSubject(false); - assetsMinimal: any; + assetsMinimal: any = {}; transactionsLength: number = 0; inputRowLimit: number = 12; outputRowLimit: number = 12; @@ -118,12 +118,6 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy { } }); - if (this.network === 'liquid' || this.network === 'liquidtestnet') { - this.assetsService.getAssetsMinimalJson$.subscribe((assets) => { - this.assetsMinimal = assets; - }); - } - this.outspendsSubscription = merge( this.refreshOutspends$ .pipe( @@ -238,6 +232,7 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy { } this.transactionsLength = this.transactions.length; + this.loadLiquidAssetData(); if (!this.txPreview) { this.cacheService.setTxCache(this.transactions); @@ -370,6 +365,19 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy { } } + private loadLiquidAssetData(): void { + if (!this.isLiquid) { + return; + } + + this.assetsService.getLiquidAssetsMinimalData(this.transactions).then((assets) => { + this.assetsMinimal = assets; + this.ref.markForCheck(); + }).catch(() => { + this.ref.markForCheck(); + }); + } + updateAddressSimilarities(): void { if (!this.transactions || !this.transactions.length) { return; @@ -549,6 +557,7 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy { for (const [index, vin] of temp.entries()) { newTx.vin[index].isInscription = vin.isInscription; } + this.loadLiquidAssetData(); this.ref.markForCheck(); }); } diff --git a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts index e9bdfb883..f2d55e98b 100644 --- a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts +++ b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts @@ -78,7 +78,7 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { zeroValueWidth = 60; zeroValueThickness = 20; hasLine: boolean; - assetsMinimal: any; + assetsMinimal: any = {}; nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId; outspendsSubscription: Subscription; @@ -116,12 +116,6 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { ngOnInit(): void { this.initGraph(); - if (this.network === 'liquid' || this.network === 'liquidtestnet') { - this.assetsService.getAssetsMinimalJson$.subscribe((assets) => { - this.assetsMinimal = assets; - }); - } - this.outspendsSubscription = merge( this.refreshOutspends$ .pipe( @@ -156,11 +150,22 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { ngOnChanges(): void { this.initGraph(); + this.loadLiquidAssetData(); if (!this.cached) { this.refreshOutspends$.next(this.tx.txid); } } + private loadLiquidAssetData(): void { + if (!this.isLiquid || !this.tx) { + return; + } + + this.assetsService.getLiquidAssetsMinimalData([this.tx]).then((assets) => { + this.assetsMinimal = assets; + }).catch(() => {}); + } + initGraph(): void { this.isLiquid = (this.network === 'liquid' || this.network === 'liquidtestnet'); this.gradient = this.gradientColors[this.network]; diff --git a/frontend/src/app/services/assets.service.ts b/frontend/src/app/services/assets.service.ts index eee61b98e..042d76531 100644 --- a/frontend/src/app/services/assets.service.ts +++ b/frontend/src/app/services/assets.service.ts @@ -5,19 +5,21 @@ import { catchError, map, shareReplay, switchMap } from 'rxjs/operators'; import { StateService } from '@app/services/state.service'; import { environment } from '@environments/environment'; import { ElectrsApiService } from '@app/services/electrs-api.service'; -import { Asset, AssetExtended, AssetRegistryItem } from '@interfaces/electrs.interface'; +import { Asset, AssetExtended, AssetRegistryItem, Transaction } from '@interfaces/electrs.interface'; @Injectable({ providedIn: 'root' }) export class AssetsService { - nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId; - getAssetsJson$: Observable<{ array: AssetExtended[]; objects: any}>; getAssetsMinimalJson$: Observable; getWorldMapJson$: Observable; registryAvailable = true; private apiBaseUrl = ''; + private nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId; + private assetsMinimalCache: any = { + [this.nativeAssetId]: this.stateService.network === 'liquid' ? [null, 'LBTC', 'Liquid Bitcoin', 8] : [null, 'tLBTC', 'Test Liquid Bitcoin', 8], + }; constructor( private httpClient: HttpClient, @@ -30,6 +32,10 @@ export class AssetsService { } this.stateService.networkChanged$.subscribe(() => { this.registryAvailable = true; + this.nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId; + this.assetsMinimalCache = { + [this.nativeAssetId]: this.stateService.network === 'liquid' ? [null, 'LBTC', 'Liquid Bitcoin', 8] : [null, 'tLBTC', 'Test Liquid Bitcoin', 8], + }; }); this.getAssetsJson$ = this.stateService.networkChanged$ @@ -99,6 +105,40 @@ export class AssetsService { return assets.objects[assetId] || {}; } + public async getLiquidAssetMinimalData(assetId: string): Promise { + if (this.assetsMinimalCache[assetId]) { + return this.assetsMinimalCache[assetId]; + } + + const asset: any = await this.getLiquidAssetData(assetId); + if (asset.name || asset.ticker || asset.precision != null) { + this.assetsMinimalCache[assetId] = [asset.entity?.domain || asset.domain || null, asset.ticker, asset.name, asset.precision || 0]; + return this.assetsMinimalCache[assetId]; + } + return null; + } + + public async getLiquidAssetsMinimalData(transactions: Transaction[]): Promise { + const assetIds = new Set(); + for (const tx of transactions || []) { + for (const vin of tx.vin || []) { + if (vin.prevout?.asset && vin.prevout.asset !== this.nativeAssetId) { + assetIds.add(vin.prevout.asset); + } + } + for (const vout of tx.vout || []) { + if (vout.asset && vout.asset !== this.nativeAssetId) { + assetIds.add(vout.asset); + } + } + } + + const missingAssetIds = Array.from(assetIds).filter((assetId) => !this.assetsMinimalCache[assetId]); + await Promise.all(missingAssetIds.map((assetId) => this.getLiquidAssetMinimalData(assetId))); + + return this.assetsMinimalCache; + } + public enrichLiquidAsset$(asset: Asset): Observable { if (asset.name || asset.ticker || asset.precision != null) { return of(asset); From 88d3158ea9cdba4943ba0b653c5d3bddf34af9c5 Mon Sep 17 00:00:00 2001 From: mononaut Date: Mon, 25 May 2026 17:07:31 +0000 Subject: [PATCH 16/23] use minimal liquid assets file for typeahead search --- .../assets/assets-nav/assets-nav.component.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/components/assets/assets-nav/assets-nav.component.ts b/frontend/src/app/components/assets/assets-nav/assets-nav.component.ts index bc5c40f3f..10242bf61 100644 --- a/frontend/src/app/components/assets/assets-nav/assets-nav.component.ts +++ b/frontend/src/app/components/assets/assets-nav/assets-nav.component.ts @@ -4,13 +4,19 @@ import { Router } from '@angular/router'; import { NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; import { merge, Observable, of, Subject } from 'rxjs'; import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; -import { AssetExtended } from '@interfaces/electrs.interface'; import { AssetsService } from '@app/services/assets.service'; import { SeoService } from '@app/services/seo.service'; import { StateService } from '@app/services/state.service'; import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; import { environment } from '@environments/environment'; +interface AssetSearchResult { + asset_id: string; + name: string; + ticker: string; + entity?: { domain: string }; +} + @Component({ selector: 'app-assets-nav', templateUrl: './assets-nav.component.html', @@ -21,10 +27,10 @@ export class AssetsNavComponent implements OnInit { @ViewChild('instance', {static: true}) instance: NgbTypeahead; nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId; searchForm: UntypedFormGroup; - assetsCache: AssetExtended[]; + assetsCache: AssetSearchResult[]; typeaheadSearchFn: ((text: Observable) => Observable); - formatterFn = (asset: AssetExtended) => asset.name + ' (' + asset.ticker + ')'; + formatterFn = (asset: AssetSearchResult) => asset.name + ' (' + asset.ticker + ')'; focus$ = new Subject(); click$ = new Subject(); @@ -62,15 +68,20 @@ export class AssetsNavComponent implements OnInit { if (!searchText.length) { return of([]); } - return this.assetsService.getAssetsJson$.pipe( + return this.assetsService.getAssetsMinimalJson$.pipe( map((assets) => { if (searchText.length ) { - const filteredAssets = assets.array.filter((asset) => asset.name.toLowerCase().indexOf(searchText.toLowerCase()) > -1 + const filteredAssets = Object.entries(assets).map(([assetId, assetData]: [string, any[]]) => ({ + asset_id: assetId, + entity: assetData[0] ? { domain: assetData[0] } : undefined, + ticker: assetData[1] || '', + name: assetData[2] || '', + })).filter((asset) => asset.name.toLowerCase().indexOf(searchText.toLowerCase()) > -1 || (asset.ticker || '').toLowerCase().indexOf(searchText.toLowerCase()) > -1 || (asset.entity && asset.entity.domain || '').toLowerCase().indexOf(searchText.toLowerCase()) > -1); return filteredAssets.slice(0, this.itemsPerPage); } else { - return assets.array.slice(0, this.itemsPerPage); + return []; } }) ); From 28d202c28b3a578c293c34d0c5e16d61233d878a Mon Sep 17 00:00:00 2001 From: mononaut Date: Mon, 25 May 2026 17:20:42 +0000 Subject: [PATCH 17/23] use new esplora asset search api --- .../assets/assets-nav/assets-nav.component.ts | 33 ++++--------------- frontend/src/app/services/assets.service.ts | 28 ++++++++++++++++ .../src/app/services/electrs-api.service.ts | 5 +++ 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/frontend/src/app/components/assets/assets-nav/assets-nav.component.ts b/frontend/src/app/components/assets/assets-nav/assets-nav.component.ts index 10242bf61..c31e6cbaa 100644 --- a/frontend/src/app/components/assets/assets-nav/assets-nav.component.ts +++ b/frontend/src/app/components/assets/assets-nav/assets-nav.component.ts @@ -3,19 +3,13 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { Router } from '@angular/router'; import { NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; import { merge, Observable, of, Subject } from 'rxjs'; -import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, filter, switchMap } from 'rxjs/operators'; import { AssetsService } from '@app/services/assets.service'; import { SeoService } from '@app/services/seo.service'; import { StateService } from '@app/services/state.service'; import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; import { environment } from '@environments/environment'; - -interface AssetSearchResult { - asset_id: string; - name: string; - ticker: string; - entity?: { domain: string }; -} +import { AssetRegistryItem } from '@interfaces/electrs.interface'; @Component({ selector: 'app-assets-nav', @@ -27,10 +21,10 @@ export class AssetsNavComponent implements OnInit { @ViewChild('instance', {static: true}) instance: NgbTypeahead; nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId; searchForm: UntypedFormGroup; - assetsCache: AssetSearchResult[]; + assetsCache: AssetRegistryItem[]; typeaheadSearchFn: ((text: Observable) => Observable); - formatterFn = (asset: AssetSearchResult) => asset.name + ' (' + asset.ticker + ')'; + formatterFn = (asset: AssetRegistryItem) => asset.name + ' (' + asset.ticker + ')'; focus$ = new Subject(); click$ = new Subject(); @@ -57,6 +51,7 @@ export class AssetsNavComponent implements OnInit { typeaheadSearch = (text$: Observable) => { const debouncedText$ = text$.pipe( + debounceTime(200), distinctUntilChanged() ); const clicksWithClosedPopup$ = this.click$.pipe(filter(() => !this.instance.isPopupOpen())); @@ -68,23 +63,7 @@ export class AssetsNavComponent implements OnInit { if (!searchText.length) { return of([]); } - return this.assetsService.getAssetsMinimalJson$.pipe( - map((assets) => { - if (searchText.length ) { - const filteredAssets = Object.entries(assets).map(([assetId, assetData]: [string, any[]]) => ({ - asset_id: assetId, - entity: assetData[0] ? { domain: assetData[0] } : undefined, - ticker: assetData[1] || '', - name: assetData[2] || '', - })).filter((asset) => asset.name.toLowerCase().indexOf(searchText.toLowerCase()) > -1 - || (asset.ticker || '').toLowerCase().indexOf(searchText.toLowerCase()) > -1 - || (asset.entity && asset.entity.domain || '').toLowerCase().indexOf(searchText.toLowerCase()) > -1); - return filteredAssets.slice(0, this.itemsPerPage); - } else { - return []; - } - }) - ); + return this.assetsService.searchLiquidAssets$(searchText, this.itemsPerPage); }), ); }; diff --git a/frontend/src/app/services/assets.service.ts b/frontend/src/app/services/assets.service.ts index 042d76531..687b2fe33 100644 --- a/frontend/src/app/services/assets.service.ts +++ b/frontend/src/app/services/assets.service.ts @@ -187,4 +187,32 @@ export class AssetsService { })), ); } + + public searchLiquidAssets$(searchText: string, limit: number): Observable { + const lowerSearchText = searchText.toLowerCase(); + return (this.registryAvailable ? this.electrsApiService.getLiquidAssetsRegistrySearch$(searchText).pipe( + catchError((error) => { + if (![404, 501].includes(error.status)) { + return throwError(() => error); + } + this.registryAvailable = false; + return of(null); + }), + ) : of(null)).pipe( + switchMap((registryAssets) => registryAssets ? of(registryAssets) : this.getAssetsMinimalJson$.pipe( + map((assets) => Object.entries(assets).map(([assetId, assetData]: [string, any[]]) => ({ + asset_id: assetId, + entity: assetData[0] ? { domain: assetData[0] } : undefined, + ticker: assetData[1] || '', + name: assetData[2] || '', + })).filter((asset) => asset.name.toLowerCase().indexOf(lowerSearchText) > -1 + || (asset.ticker || '').toLowerCase().indexOf(lowerSearchText) > -1 + || (asset.entity && asset.entity.domain || '').toLowerCase().indexOf(lowerSearchText) > -1)), + )), + map((assets) => assets.map((asset) => ({ + ...asset, + entity: asset.entity || (asset.domain ? { domain: asset.domain } : undefined), + })).slice(0, limit)), + ); + } } diff --git a/frontend/src/app/services/electrs-api.service.ts b/frontend/src/app/services/electrs-api.service.ts index 687156f44..c3d53f7da 100644 --- a/frontend/src/app/services/electrs-api.service.ts +++ b/frontend/src/app/services/electrs-api.service.ts @@ -238,6 +238,11 @@ export class ElectrsApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/assets/registry', { params, observe: 'response' }); } + getLiquidAssetsRegistrySearch$(query: string): Observable { + const params = new HttpParams().set('q', query); + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/assets/registry/search', { params }); + } + getAssetTransactions$(assetId: string): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId + '/txs'); } From c144f0358fbfbc084d7593036ae8ab2527299b1d Mon Sep 17 00:00:00 2001 From: mononaut Date: Mon, 25 May 2026 17:31:19 +0000 Subject: [PATCH 18/23] use new esplora registry search for main search form --- .../search-form/search-form.component.ts | 65 +++++++++++-------- .../search-results.component.html | 14 ++-- .../search-results.component.ts | 2 +- frontend/src/app/services/assets.service.ts | 17 ++++- 4 files changed, 63 insertions(+), 35 deletions(-) diff --git a/frontend/src/app/components/search-form/search-form.component.ts b/frontend/src/app/components/search-form/search-form.component.ts index 5f2896b66..739d513c2 100644 --- a/frontend/src/app/components/search-form/search-form.component.ts +++ b/frontend/src/app/components/search-form/search-form.component.ts @@ -22,7 +22,6 @@ export class SearchFormComponent implements OnInit { @Input() hamburgerOpen = false; env: Env; network = ''; - assets: object = {}; pools: object[] = []; isSearching = false; isTypeaheading$ = new BehaviorSubject(false); @@ -96,13 +95,6 @@ export class SearchFormComponent implements OnInit { searchText: ['', Validators.required], }); - if (this.network === 'liquid' || this.network === 'liquidtestnet') { - this.assetsService.getAssetsMinimalJson$ - .subscribe((assets) => { - this.assets = assets; - }); - } - const searchText$ = this.searchForm.get('searchText').valueChanges .pipe( map((text) => { @@ -121,7 +113,8 @@ export class SearchFormComponent implements OnInit { return of([ [], { nodes: [], channels: [] }, - this.pools + this.pools, + [], ]); } this.isTypeaheading$.next(true); @@ -129,7 +122,8 @@ export class SearchFormComponent implements OnInit { return zip( this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))), [{ nodes: [], channels: [] }], - this.getMiningPools() + this.getMiningPools(), + this.getLiquidAssetSearch$(text), ); } return zip( @@ -138,7 +132,8 @@ export class SearchFormComponent implements OnInit { nodes: [], channels: [], }))), - this.getMiningPools() + this.getMiningPools(), + this.getLiquidAssetSearch$(text), ); }), map((result: any[]) => { @@ -159,7 +154,8 @@ export class SearchFormComponent implements OnInit { nodes: [], channels: [], }, - this.pools + this.pools, + [], ])) ] ).pipe( @@ -178,7 +174,7 @@ export class SearchFormComponent implements OnInit { addresses: [], nodes: [], channels: [], - liquidAsset: [], + liquidAssets: [], pools: [] }; } @@ -186,6 +182,7 @@ export class SearchFormComponent implements OnInit { const result = latestData[1]; const addressPrefixSearchResults = result[0]; const lightningResults = result[1]; + const liquidAssets = result[3]; // Do not show date and timestamp results for liquid const isNetworkBitcoin = this.network === '' || this.network === 'testnet' || this.network === 'testnet4' || this.network === 'signet'; @@ -198,8 +195,8 @@ export class SearchFormComponent implements OnInit { const matchesAddress = !matchesTxId && this.regexAddress.test(searchText); const publicKey = matchesAddress && searchText.startsWith('0'); const otherNetworks = findOtherNetworks(searchText, this.network as any || 'mainnet', this.env); - const liquidAsset = this.assets ? (this.assets[searchText] || []) : []; const pools = this.pools.filter(pool => pool['name'].toLowerCase().includes(searchText.toLowerCase())).slice(0, 10); + const hashQuickMatch = +(matchesBlockHeight || matchesBlockHash || (matchesTxId && !liquidAssets.length) || matchesAddress || matchesUnixTimestamp || matchesDateTime); if (matchesDateTime && searchText.indexOf('/') !== -1) { searchText = searchText.replace(/\//g, '-'); @@ -211,7 +208,7 @@ export class SearchFormComponent implements OnInit { return { searchText: searchText, - hashQuickMatch: +(matchesBlockHeight || matchesBlockHash || matchesTxId || matchesAddress || matchesUnixTimestamp || matchesDateTime), + hashQuickMatch: hashQuickMatch, blockHeight: matchesBlockHeight, dateTime: matchesDateTime, unixTimestamp: matchesUnixTimestamp, @@ -223,7 +220,7 @@ export class SearchFormComponent implements OnInit { otherNetworks: otherNetworks, nodes: lightningResults.nodes, channels: lightningResults.channels, - liquidAsset: liquidAsset, + liquidAssets: liquidAssets, pools: pools }; }) @@ -258,6 +255,8 @@ export class SearchFormComponent implements OnInit { } } else if (result.slug) { this.navigate('/mining/pool/', result.slug); + } else if (result.asset_id) { + this.navigate('/assets/asset/', result.asset_id); } } @@ -275,19 +274,24 @@ export class SearchFormComponent implements OnInit { } else if (this.regexTransaction.test(searchText)) { const matches = this.regexTransaction.exec(searchText); if (this.network === 'liquid' || this.network === 'liquidtestnet') { - if (this.assets[matches[0]]) { - this.navigate('/assets/asset/', matches[0]); - } - this.electrsApiService.getAsset$(matches[0]) - .subscribe( - () => { this.navigate('/assets/asset/', matches[0]); }, - () => { - this.electrsApiService.getBlock$(matches[0]) + this.assetsService.searchLiquidAssets$(matches[0], 1) + .pipe(catchError(() => of([]))) + .subscribe((assets) => { + if (assets[0]?.asset_id === matches[0]) { + this.navigate('/assets/asset/', matches[0]); + } else { + this.electrsApiService.getAsset$(matches[0]) .subscribe( - (block) => { this.navigate('/block/', matches[0], { state: { data: { block } } }); }, - () => { this.navigate('/tx/', matches[0]); }); + () => { this.navigate('/assets/asset/', matches[0]); }, + () => { + this.electrsApiService.getBlock$(matches[0]) + .subscribe( + (block) => { this.navigate('/block/', matches[0], { state: { data: { block } } }); }, + () => { this.navigate('/tx/', matches[0]); }); + } + ); } - ); + }); } else { this.navigate('/tx/', matches[0]); } @@ -347,4 +351,11 @@ export class SearchFormComponent implements OnInit { catchError(() => of([])) ); } + + getLiquidAssetSearch$(searchText: string): Observable { + if (this.network !== 'liquid' && this.network !== 'liquidtestnet') { + return of([]); + } + return this.assetsService.searchLiquidAssets$(searchText, 10).pipe(catchError(() => of([]))); + } } diff --git a/frontend/src/app/components/search-form/search-results/search-results.component.html b/frontend/src/app/components/search-form/search-results/search-results.component.html index 22e823265..79b4ad9bf 100644 --- a/frontend/src/app/components/search-form/search-results/search-results.component.html +++ b/frontend/src/app/components/search-form/search-results/search-results.component.html @@ -1,4 +1,4 @@ -