Merge pull request #6525 from mempool/mononaut/use-new-liquid-assets-apis

This commit is contained in:
wiz 2026-05-30 10:35:01 +09:00 committed by GitHub
commit e11630315d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 350 additions and 148 deletions

View file

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

View file

@ -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
};
})
);
}
}

View file

@ -3,13 +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 { AssetExtended } from '@interfaces/electrs.interface';
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';
import { AssetRegistryItem } from '@interfaces/electrs.interface';
@Component({
selector: 'app-assets-nav',
@ -21,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: AssetExtended[];
assetsCache: AssetRegistryItem[];
typeaheadSearchFn: ((text: Observable<string>) => Observable<readonly any[]>);
formatterFn = (asset: AssetExtended) => asset.name + ' (' + asset.ticker + ')';
formatterFn = (asset: AssetRegistryItem) => asset.name + ' (' + asset.ticker + ')';
focus$ = new Subject<string>();
click$ = new Subject<string>();
@ -51,6 +51,7 @@ export class AssetsNavComponent implements OnInit {
typeaheadSearch = (text$: Observable<string>) => {
const debouncedText$ = text$.pipe(
debounceTime(200),
distinctUntilChanged()
);
const clicksWithClosedPopup$ = this.click$.pipe(filter(() => !this.instance.isPopupOpen()));
@ -62,18 +63,7 @@ export class AssetsNavComponent implements OnInit {
if (!searchText.length) {
return of([]);
}
return this.assetsService.getAssetsJson$.pipe(
map((assets) => {
if (searchText.length ) {
const filteredAssets = assets.array.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 this.assetsService.searchLiquidAssets$(searchText, this.itemsPerPage);
}),
);
};

View file

@ -18,7 +18,7 @@
<br>
<ngb-pagination class="pagination-container" [collectionSize]="assets.length" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="ellipses"></ngb-pagination>
<ngb-pagination class="pagination-container" [collectionSize]="totalAssets" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="ellipses"></ngb-pagination>
<div class="clearfix"></div>
<br>

View file

@ -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<AssetExtended[]>;
assets$: Observable<AssetRegistryItem[]>;
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;
}),
);
}

View file

@ -22,7 +22,6 @@ export class SearchFormComponent implements OnInit {
@Input() hamburgerOpen = false;
env: Env;
network = '';
assets: object = {};
pools: object[] = [];
isSearching = false;
isTypeaheading$ = new BehaviorSubject<boolean>(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<any[]> {
if (this.network !== 'liquid' && this.network !== 'liquidtestnet') {
return of([]);
}
return this.assetsService.searchLiquidAssets$(searchText, 10).pipe(catchError(() => of([])));
}
}

View file

@ -1,4 +1,4 @@
<div class="dropdown-menu show" *ngIf="results" [hidden]="!results.hashQuickMatch && !results.otherNetworks.length && !results.addresses.length && !results.nodes.length && !results.channels.length && !results.liquidAsset.length && !results.pools.length">
<div class="dropdown-menu show" *ngIf="results" [hidden]="!results.hashQuickMatch && !results.otherNetworks.length && !results.addresses.length && !results.nodes.length && !results.channels.length && !results.liquidAssets.length && !results.pools.length">
<ng-template [ngIf]="results.blockHeight">
<div class="card-title" i18n="search.bitcoin-block-height">{{ networkName }} Block Height</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
@ -17,7 +17,7 @@
<ng-container *ngTemplateOutlet="goTo; context: { $implicit: results.searchText }"></ng-container>
</button>
</ng-template>
<ng-template [ngIf]="results.txId && !results.liquidAsset.length">
<ng-template [ngIf]="results.txId && !results.liquidAssets.length">
<div class="card-title" i18n="search.bitcoin-transaction">{{ networkName }} Transaction</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
<ng-container *ngTemplateOutlet="goTo; context: { $implicit: results.searchText | shortenString : 13 }"></ng-container>
@ -81,11 +81,13 @@
<ng-container *ngTemplateOutlet="goTo; context: { $implicit: results.searchText | shortenString : isMobile ? 17 : 30 }"></ng-container>
</button>
</ng-template>
<ng-template [ngIf]="results.liquidAsset.length">
<ng-template [ngIf]="results.liquidAssets.length">
<div class="card-title" i18n="search.liquid-asset">Liquid Asset</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
<ng-container *ngTemplateOutlet="goTo; context: { $implicit: results.searchText | shortenString : 11 }"></ng-container>&nbsp;<b>({{ results.liquidAsset[1] }})</b>
</button>
<ng-template ngFor [ngForOf]="results.liquidAssets" let-asset let-i="index">
<button (click)="clickItem(results.hashQuickMatch + results.addresses.length + results.pools.length + results.nodes.length + results.channels.length + results.otherNetworks.length + i)" [class.active]="results.hashQuickMatch + results.addresses.length + results.pools.length + results.nodes.length + results.channels.length + results.otherNetworks.length + i === activeIdx" type="button" role="option" class="dropdown-item">
<ng-container *ngTemplateOutlet="goTo; context: { $implicit: asset.name }"></ng-container>&nbsp;<b>({{ asset.ticker }})</b>
</button>
</ng-template>
</ng-template>
</div>

View file

@ -28,7 +28,7 @@ export class SearchResultsComponent implements OnChanges {
ngOnChanges() {
this.activeIdx = 0;
if (this.results) {
this.resultsFlattened = [...(this.results.hashQuickMatch ? [this.results.searchText] : []), ...this.results.addresses, ...this.results.pools, ...this.results.nodes, ...this.results.channels, ...this.results.otherNetworks];
this.resultsFlattened = [...(this.results.hashQuickMatch ? [this.results.searchText] : []), ...this.results.addresses, ...this.results.pools, ...this.results.nodes, ...this.results.channels, ...this.results.otherNetworks, ...this.results.liquidAssets];
// If searchText is a public key corresponding to a node, select it by default
if (this.results.publicKey && this.results.nodes.length > 0) {
this.activeIdx = 1;

View file

@ -59,7 +59,7 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy {
refreshOutspends$: ReplaySubject<string[]> = new ReplaySubject();
refreshChannels$: ReplaySubject<string[]> = new ReplaySubject();
showDetails$ = new BehaviorSubject<boolean>(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();
});
}

View file

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

View file

@ -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 {
@ -221,6 +225,15 @@ export interface Entity {
domain: string;
}
export interface AssetRegistryItem {
asset_id: string;
name: string;
ticker?: string;
precision?: number;
domain?: string;
entity?: Entity;
}
interface IssuanceTxin {
txid: string;
vin: number;

View file

@ -1,33 +1,46 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map, shareReplay, switchMap } from 'rxjs/operators';
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';
import { AssetExtended } from '@interfaces/electrs.interface';
import { ElectrsApiService } from '@app/services/electrs-api.service';
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<any>;
getWorldMapJson$: Observable<any>;
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,
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.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$
.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 +69,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,6 +80,182 @@ 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 async getLiquidAssetData(assetId: string): Promise<Partial<Asset>> {
if (this.stateService.network === 'liquid' && assetId === environment.nativeAssetId) {
const asset = { asset_id: assetId, name: 'Liquid Bitcoin', ticker: 'LBTC', precision: 8 };
return asset;
} else if (this.stateService.network === 'liquidtestnet' && assetId === environment.nativeTestAssetId) {
const asset = { asset_id: assetId, name: 'Test Liquid Bitcoin', ticker: 'tLBTC', precision: 8 };
return asset;
} else if (this.registryAvailable) {
try {
const apiAsset = await firstValueFrom(this.electrsApiService.getLiquidAssetRegistry$(assetId));
if (apiAsset.name || apiAsset.ticker || apiAsset.precision != null) {
const asset = { ...apiAsset, asset_id: assetId };
this.assetsMinimalCache[assetId] = [asset.entity?.domain || asset.domain || null, asset.ticker, asset.name, asset.precision || 0];
return asset;
}
} catch (error: any) {
if (error?.status === 501) {
this.registryAvailable = false;
} else if (error?.status !== 404) {
throw error;
}
}
}
const assets = await firstValueFrom(this.getAssetsJson$);
const asset: any = assets.objects[assetId] || { asset_id: 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 asset;
}
private cacheLiquidAssetMinimalData(asset: Partial<Asset>): void {
if (asset.asset_id && (asset.name || asset.ticker || asset.precision != null)) {
this.assetsMinimalCache[asset.asset_id] = [asset.entity?.domain || (asset as any).domain || null, asset.ticker, asset.name, asset.precision || 0];
}
}
public async getLiquidAssetMinimalData(assetId: string): Promise<any[]> {
if (this.assetsMinimalCache[assetId]) {
return this.assetsMinimalCache[assetId];
}
const asset: any = await this.getLiquidAssetData(assetId);
if (asset.name || asset.ticker || asset.precision != null) {
return this.assetsMinimalCache[assetId];
}
return null;
}
public async getLiquidAssetsMinimalData(transactions: Transaction[]): Promise<any> {
const assetIds = new Set<string>();
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<Asset> {
if (asset.name || asset.ticker || asset.precision != null) {
this.cacheLiquidAssetMinimalData(asset);
return of(asset);
} else if (this.stateService.network === 'liquid' && asset.asset_id === environment.nativeAssetId) {
const nativeAsset = { ...asset, name: 'Liquid Bitcoin', ticker: 'LBTC', precision: 8 };
return of(nativeAsset);
} else if (this.stateService.network === 'liquidtestnet' && asset.asset_id === environment.nativeTestAssetId) {
const nativeAsset = { ...asset, name: 'Test Liquid Bitcoin', ticker: 'tLBTC', precision: 8 };
return of(nativeAsset);
} else {
return this.getAssetsJson$.pipe(
map((assets) => {
const enrichedAsset = assets.objects[asset.asset_id] ? { ...asset, ...assets.objects[asset.asset_id] } : asset;
this.cacheLiquidAssetMinimalData(enrichedAsset);
return enrichedAsset;
}),
);
}
}
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),
})),
})),
);
}
public searchLiquidAssets$(searchText: string, limit: number): Observable<AssetRegistryItem[]> {
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.asset_id.indexOf(lowerSearchText) > -1
|| asset.name.toLowerCase().indexOf(lowerSearchText) > -1
|| (asset.ticker || '').toLowerCase().indexOf(lowerSearchText) > -1
|| (asset.entity && asset.entity.domain || '').toLowerCase().indexOf(lowerSearchText) > -1)),
)),
switchMap((assets) => {
if (!/^[0-9a-f]{64}$/i.test(searchText) || assets.some((asset) => asset.asset_id.toLowerCase() === lowerSearchText)) {
return of(assets);
}
return this.electrsApiService.getLiquidAssetRegistry$(searchText).pipe(
map((asset) => {
if (asset.name || asset.ticker || asset.precision != null) {
this.assetsMinimalCache[asset.asset_id] = [asset.entity?.domain || asset.domain || null, asset.ticker, asset.name, asset.precision || 0];
return [{
asset_id: asset.asset_id,
entity: asset.entity,
ticker: asset.ticker || '',
name: asset.name || asset.asset_id,
}, ...assets];
}
return assets;
}),
catchError(() => of(assets)),
);
}),
map((assets) => assets.map((asset) => ({
...asset,
entity: asset.entity || (asset.domain ? { domain: asset.domain } : undefined),
})).slice(0, limit)),
);
}
}

View file

@ -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,25 @@ export class ElectrsApiService {
return this.httpClient.get<Asset>(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId);
}
getLiquidAssetsRegistry$(startIndex: number, limit: number): Observable<HttpResponse<AssetRegistryItem[]>> {
const params = new HttpParams()
.set('start_index', startIndex)
.set('limit', limit)
.set('sort_field', 'name')
.set('sort_dir', 'asc');
return this.httpClient.get<AssetRegistryItem[]>(this.apiBaseUrl + this.apiBasePath + '/api/assets/registry', { params, observe: 'response' });
}
getLiquidAssetsRegistrySearch$(query: string): Observable<AssetRegistryItem[]> {
const params = new HttpParams().set('q', query);
return this.httpClient.get<AssetRegistryItem[]>(this.apiBaseUrl + this.apiBasePath + '/api/assets/registry/search', { params });
}
getLiquidAssetRegistry$(assetId: string): Observable<AssetRegistryItem> {
return this.httpClient.get<AssetRegistryItem>(this.apiBaseUrl + this.apiBasePath + '/api/assets/registry/' + assetId);
}
getAssetTransactions$(assetId: string): Observable<Transaction[]> {
return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId + '/txs');
}