From 73b64cc5fbc442d7d68b558387c6ecd7cb6c4820 Mon Sep 17 00:00:00 2001 From: mononaut Date: Mon, 25 May 2026 16:13:02 +0000 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 @@ -