Merge branch 'master' into knorrium/nginx_upgrade

This commit is contained in:
Felipe Knorr Kuhn 2026-05-31 06:44:11 +09:00 committed by GitHub
commit b69e60525d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 747 additions and 306 deletions

View file

@ -3,6 +3,8 @@ import config from '../config';
import axios from 'axios';
import logger from '../logger';
const PROXY_PATH_SEGMENT_REGEX = /^(?!\.{1,2}$)[^\p{Cc}/?#\\]{1,256}$/u;
class AboutRoutes {
public initRoutes(app: Application) {
app
@ -15,8 +17,13 @@ class AboutRoutes {
}
})
.get(config.MEMPOOL.API_URL_PREFIX + 'donations/images/:id', async (req, res) => {
if (!PROXY_PATH_SEGMENT_REGEX.test(req.params.id)) {
res.status(400).end();
return;
}
try {
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/donations/images/${req.params.id}`, {
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/donations/images/${encodeURIComponent(req.params.id)}`, {
responseType: 'stream', timeout: 10000
});
response.data.pipe(res);
@ -33,8 +40,13 @@ class AboutRoutes {
}
})
.get(config.MEMPOOL.API_URL_PREFIX + 'contributors/images/:id', async (req, res) => {
if (!PROXY_PATH_SEGMENT_REGEX.test(req.params.id)) {
res.status(400).end();
return;
}
try {
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/contributors/images/${req.params.id}`, {
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/contributors/images/${encodeURIComponent(req.params.id)}`, {
responseType: 'stream', timeout: 10000
});
response.data.pipe(res);
@ -51,8 +63,13 @@ class AboutRoutes {
}
})
.get(config.MEMPOOL.API_URL_PREFIX + 'translators/images/:id', async (req, res) => {
if (!PROXY_PATH_SEGMENT_REGEX.test(req.params.id)) {
res.status(400).end();
return;
}
try {
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/translators/images/${req.params.id}`, {
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/translators/images/${encodeURIComponent(req.params.id)}`, {
responseType: 'stream', timeout: 10000
});
response.data.pipe(res);
@ -61,7 +78,7 @@ class AboutRoutes {
}
})
.get(config.MEMPOOL.API_URL_PREFIX + 'services/sponsors', async (req, res) => {
const url = `${config.MEMPOOL_SERVICES.API}/${req.originalUrl.replace('/api/v1/services/', '')}`;
const url = `${config.MEMPOOL_SERVICES.API}/sponsors`;
try {
const response = await axios.get(url, { responseType: 'stream', timeout: 10000 });
response.data.pipe(res);
@ -71,7 +88,12 @@ class AboutRoutes {
}
})
.get(config.MEMPOOL.API_URL_PREFIX + 'services/account/images/:username/:md5', async (req, res) => {
const url = `${config.MEMPOOL_SERVICES.API}/${req.originalUrl.replace('/api/v1/services/', '')}`;
if (!PROXY_PATH_SEGMENT_REGEX.test(req.params.username) || !PROXY_PATH_SEGMENT_REGEX.test(req.params.md5)) {
res.status(400).end();
return;
}
const url = `${config.MEMPOOL_SERVICES.API}/account/images/${encodeURIComponent(req.params.username)}/${encodeURIComponent(req.params.md5)}`;
try {
const response = await axios.get(url, { responseType: 'stream', timeout: 10000 });
response.data.pipe(res);
@ -84,4 +106,4 @@ class AboutRoutes {
}
}
export default new AboutRoutes();
export default new AboutRoutes();

View file

@ -5,16 +5,18 @@ import logger from '../../logger';
import mempool from '../mempool';
import AccelerationRepository from '../../repositories/AccelerationRepository';
const TXID_REGEX = /^[a-f0-9]{64}$/i;
class AccelerationRoutes {
private tag = 'Accelerator';
public initRoutes(app: Application): void {
app
.get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations', this.$getAcceleratorAccelerations.bind(this))
.get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations/:txid', this.$getAcceleratorAcceleration.bind(this))
.get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations/history', this.$getAcceleratorAccelerationsHistory.bind(this))
.get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations/history/aggregated', this.$getAcceleratorAccelerationsHistoryAggregated.bind(this))
.get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations/stats', this.$getAcceleratorAccelerationsStats.bind(this))
.get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations/:txid', this.$getAcceleratorAcceleration.bind(this))
.post(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/estimate', this.$getAcceleratorEstimate.bind(this))
;
}
@ -26,7 +28,7 @@ class AccelerationRoutes {
/** @asyncUnsafe */
private async $getAcceleratorAcceleration(req: Request, res: Response): Promise<void> {
if (req.params.txid) {
if (req.params.txid && TXID_REGEX.test(req.params.txid)) {
const acceleration = await AccelerationRepository.$getAccelerationInfoForTxid(req.params.txid);
if (acceleration) {
res.status(200).send(acceleration);
@ -34,7 +36,7 @@ class AccelerationRoutes {
res.status(404).send('Acceleration not found');
}
} else {
res.status(400).send('txid is required');
res.status(400).send('invalid txid');
}
}
@ -55,9 +57,9 @@ class AccelerationRoutes {
}
private async $getAcceleratorAccelerationsHistoryAggregated(req: Request, res: Response): Promise<void> {
const url = `${config.MEMPOOL_SERVICES.API}/${req.originalUrl.replace('/api/v1/services/', '')}`;
const url = `${config.MEMPOOL_SERVICES.API}/accelerator/accelerations/history/aggregated`;
try {
const response = await axios.get(url, { responseType: 'stream', timeout: 10000 });
const response = await axios.get(url, { params: req.query, responseType: 'stream', timeout: 10000 });
for (const key in response.headers) {
res.setHeader(key, response.headers[key]);
}
@ -69,9 +71,9 @@ class AccelerationRoutes {
}
private async $getAcceleratorAccelerationsStats(req: Request, res: Response): Promise<void> {
const url = `${config.MEMPOOL_SERVICES.API}/${req.originalUrl.replace('/api/v1/services/', '')}`;
const url = `${config.MEMPOOL_SERVICES.API}/accelerator/accelerations/stats`;
try {
const response = await axios.get(url, { responseType: 'stream', timeout: 10000 });
const response = await axios.get(url, { params: req.query, responseType: 'stream', timeout: 10000 });
for (const key in response.headers) {
res.setHeader(key, response.headers[key]);
}
@ -83,7 +85,7 @@ class AccelerationRoutes {
}
private async $getAcceleratorEstimate(req: Request, res: Response): Promise<void> {
const url = `${config.MEMPOOL_SERVICES.API}/${req.originalUrl.replace('/api/v1/services/', '')}`;
const url = `${config.MEMPOOL_SERVICES.API}/accelerator/estimate`;
try {
const response = await axios.post(url, req.body, { responseType: 'stream', timeout: 10000 });
for (const key in response.headers) {
@ -97,4 +99,4 @@ class AccelerationRoutes {
}
}
export default new AccelerationRoutes();
export default new AccelerationRoutes();

View file

@ -28,6 +28,7 @@ const TXID_REGEX = /^[a-f0-9]{64}$/i;
const BLOCK_HASH_REGEX = /^[a-f0-9]{64}$/i;
const ADDRESS_REGEX = /^[a-z0-9]{2,120}$/i;
const SCRIPT_HASH_REGEX = /^([a-f0-9]{2})+$/i;
const MAX_TRANSACTION_TIMES = 100;
class BitcoinRoutes {
public initRoutes(app: Application) {
@ -144,11 +145,18 @@ class BitcoinRoutes {
private getTransactionTimes(req: Request, res: Response) {
if (!req.query.txId || typeof req.query.txId !== 'object') {
handleError(req, res, 500, 'invalid txId format');
handleError(req, res, 400, 'invalid txId format');
return;
}
const requestedTxIds = Object.values(req.query.txId);
if (requestedTxIds.length > MAX_TRANSACTION_TIMES) {
handleError(req, res, 400, 'Too many txids requested');
return;
}
const txIds: string[] = [];
for (const txid of Object.values(req.query.txId)) {
for (const txid of requestedTxIds) {
if (typeof txid === 'string' && TXID_REGEX.test(txid)) {
txIds.push(txid);
}
@ -161,7 +169,7 @@ class BitcoinRoutes {
private async $getBatchedOutspends(req: Request, res: Response): Promise<IEsploraApi.Outspend[][] | void> {
const txids_csv = req.query.txids;
if (!txids_csv || typeof txids_csv !== 'string') {
handleError(req, res, 500, 'Invalid txids format');
handleError(req, res, 400, 'Invalid txids format');
return;
}
const txids = txids_csv.split(',');
@ -184,7 +192,7 @@ class BitcoinRoutes {
private async $getCpfpInfo(req: Request, res: Response) {
if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID`);
handleError(req, res, 400, `Invalid transaction ID`);
return;
}
@ -246,7 +254,7 @@ class BitcoinRoutes {
private async getTransaction(req: Request, res: Response) {
if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID`);
handleError(req, res, 400, `Invalid transaction ID`);
return;
}
try {
@ -265,7 +273,7 @@ class BitcoinRoutes {
private async getRawTransaction(req: Request, res: Response) {
if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID`);
handleError(req, res, 400, `Invalid transaction ID`);
return;
}
try {
@ -353,7 +361,7 @@ class BitcoinRoutes {
private async getTransactionStatus(req: Request, res: Response) {
if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID`);
handleError(req, res, 400, `Invalid transaction ID`);
return;
}
try {
@ -372,7 +380,7 @@ class BitcoinRoutes {
private async getStrippedBlockTransactions(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
handleError(req, res, 400, `Invalid block hash`);
return;
}
try {
@ -386,11 +394,11 @@ class BitcoinRoutes {
private async getStrippedBlockTransaction(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
handleError(req, res, 400, `Invalid block hash`);
return;
}
if (!TXID_REGEX.test(req.params.txid)) {
handleError(req, res, 501, `Invalid transaction ID`);
handleError(req, res, 400, `Invalid transaction ID`);
return;
}
try {
@ -408,7 +416,7 @@ class BitcoinRoutes {
private async getBlock(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
handleError(req, res, 400, `Invalid block hash`);
return;
}
try {
@ -434,7 +442,7 @@ class BitcoinRoutes {
private async getBlockHeader(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
handleError(req, res, 400, `Invalid block hash`);
return;
}
try {
@ -448,7 +456,7 @@ class BitcoinRoutes {
private async getBlockAuditSummary(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
handleError(req, res, 400, `Invalid block hash`);
return;
}
try {
@ -467,11 +475,11 @@ class BitcoinRoutes {
private async $getBlockTxAuditSummary(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
handleError(req, res, 400, `Invalid block hash`);
return;
}
if (!TXID_REGEX.test(req.params.txid)) {
handleError(req, res, 501, `Invalid transaction ID`);
handleError(req, res, 400, `Invalid transaction ID`);
return;
}
try {
@ -621,7 +629,7 @@ class BitcoinRoutes {
private async getBlockTransactions(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
handleError(req, res, 400, `Invalid block hash`);
return;
}
try {
@ -663,7 +671,7 @@ class BitcoinRoutes {
return;
}
if (!ADDRESS_REGEX.test(req.params.address)) {
handleError(req, res, 501, `Invalid address`);
handleError(req, res, 400, `Invalid address`);
return;
}
@ -689,7 +697,7 @@ class BitcoinRoutes {
return;
}
if (!ADDRESS_REGEX.test(req.params.address)) {
handleError(req, res, 501, `Invalid address`);
handleError(req, res, 400, `Invalid address`);
return;
}
@ -719,7 +727,7 @@ class BitcoinRoutes {
return;
}
if (!ADDRESS_REGEX.test(req.params.address)) {
handleError(req, res, 501, `Invalid address`);
handleError(req, res, 400, `Invalid address`);
return;
}
@ -752,7 +760,7 @@ class BitcoinRoutes {
return;
}
if (!SCRIPT_HASH_REGEX.test(req.params.scripthash)) {
handleError(req, res, 501, `Invalid scripthash`);
handleError(req, res, 400, `Invalid scripthash`);
return;
}
@ -776,7 +784,7 @@ class BitcoinRoutes {
return;
}
if (!SCRIPT_HASH_REGEX.test(req.params.scripthash)) {
handleError(req, res, 501, `Invalid scripthash`);
handleError(req, res, 400, `Invalid scripthash`);
return;
}
@ -804,7 +812,7 @@ class BitcoinRoutes {
return;
}
if (!SCRIPT_HASH_REGEX.test(req.params.scripthash)) {
handleError(req, res, 501, `Invalid scripthash`);
handleError(req, res, 400, `Invalid scripthash`);
return;
}
@ -937,7 +945,7 @@ class BitcoinRoutes {
private async getRawBlock(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
handleError(req, res, 400, `Invalid block hash`);
return;
}
try {
@ -951,7 +959,7 @@ class BitcoinRoutes {
private async getTxIdsForBlock(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
handleError(req, res, 400, `Invalid block hash`);
return;
}
try {
@ -964,7 +972,7 @@ class BitcoinRoutes {
private async validateAddress(req: Request, res: Response) {
if (!ADDRESS_REGEX.test(req.params.address)) {
handleError(req, res, 501, `Invalid address`);
handleError(req, res, 400, `Invalid address`);
return;
}
try {
@ -977,7 +985,7 @@ class BitcoinRoutes {
private async getRbfHistory(req: Request, res: Response) {
if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID`);
handleError(req, res, 400, `Invalid transaction ID`);
return;
}
try {
@ -1012,7 +1020,7 @@ class BitcoinRoutes {
private async getCachedTx(req: Request, res: Response) {
if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID`);
handleError(req, res, 400, `Invalid transaction ID`);
return;
}
try {
@ -1029,7 +1037,7 @@ class BitcoinRoutes {
private async getTransactionOutspends(req: Request, res: Response) {
if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID`);
handleError(req, res, 400, `Invalid transaction ID`);
return;
}
try {
@ -1042,7 +1050,7 @@ class BitcoinRoutes {
private async getTransactionMerkleProof(req: Request, res: Response): Promise<void> {
if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID`);
handleError(req, res, 400, `Invalid transaction ID`);
return;
}
try {

View file

@ -6,6 +6,8 @@ import icons from './icons';
import { handleError } from '../../utils/api';
import PricesRepository from '../../repositories/PricesRepository';
const PROXY_PATH_SEGMENT_REGEX = /^(?!\.{1,2}$)[^\p{Cc}/?#\\]{1,256}$/u;
class LiquidRoutes {
public initRoutes(app: Application) {
app
@ -68,8 +70,13 @@ class LiquidRoutes {
}
private async $getAssetGroup(req: Request, res: Response) {
if (!PROXY_PATH_SEGMENT_REGEX.test(req.params.id)) {
handleError(req, res, 400, 'Invalid asset group id');
return;
}
try {
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.LIQUID_API}/assets/group/${parseInt(req.params.id, 10)}`,
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.LIQUID_API}/assets/group/${encodeURIComponent(req.params.id)}`,
{ responseType: 'stream', timeout: 10000 });
response.data.pipe(res);
} catch (e) {

View file

@ -371,8 +371,8 @@ class WebsocketHandler {
}
if (parsedMessage && parsedMessage['track-mempool-block'] !== undefined) {
if (Number.isInteger(parsedMessage['track-mempool-block']) && parsedMessage['track-mempool-block'] >= 0) {
const index = parsedMessage['track-mempool-block'];
const index = parsedMessage['track-mempool-block'];
if (Number.isInteger(index) && index >= 0 && index < config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT) {
client['track-mempool-block'] = index;
const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions();
response['projected-block-transactions'] = JSON.stringify({

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

@ -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)" />
<text *ngFor="let outline of chunkOutlines; trackBy: trackByChunkIndex"
class="chunk-label"
@ -59,10 +61,12 @@
[attr.x]="outline.labelX" [attr.y]="outline.labelY"
text-anchor="middle"
[ngbTooltip]="chunkLabelTooltip"
[disableTooltip]="isMobile"
container="body"
placement="top"
(mouseenter)="onChunkEnter(outline.chunkIndex)"
(mouseleave)="onChunkLeave()">
(mouseleave)="onChunkLeave()"
(click)="onChunkClick(outline.chunkIndex, $event)">
{{ outline.feerate | feeRounding }} sat/vB
</text>
</ng-container>
@ -76,7 +80,8 @@
stroke-width="12"
(mouseenter)="onEdgeEnter(i, $event)"
(mousemove)="onEdgeMove($event)"
(mouseleave)="onEdgeLeave()" />
(mouseleave)="onEdgeLeave()"
(click)="onEdgeClick(i, $event)" />
<path class="edge-line"
[class.highlighted]="edge.highlighted"
[class.ancestor]="edge.highlightKind === 'ancestor'"
@ -96,7 +101,7 @@
(mouseenter)="onNodeEnter(node, $event)"
(mousemove)="onNodeMove($event)"
(mouseleave)="onNodeLeave()"
(click)="onNodeClick(node)">
(click)="onNodeClick(node, $event)">
<rect class="node-rect"
[class.current]="node.isCurrent"
[class.hovered]="node.hovered"
@ -120,7 +125,7 @@
<div #tooltip
class="cluster-tooltip"
*ngIf="hoverNode || hoverEdge"
*ngIf="!isMobile && (hoverNode || hoverEdge)"
[style.left.px]="tooltipPosition.x"
[style.top.px]="tooltipPosition.y">
<ng-container *ngIf="hoverNode">
@ -154,3 +159,39 @@
<div><span class="swatch descendant"></span><span i18n="cluster.child|child">child</span></div>
</div>
</div>
<div
class="cluster-mobile-panel"
*ngIf="isMobile && (hoverNode || hoverEdge)"
(click)="$event.stopPropagation()">
<ng-container *ngIf="hoverNode">
<div class="tx-id-row">
<a [routerLink]="['/tx/' | relativeUrl, hoverNode.tx.txid]">{{ hoverNode.tx.txid | shortenString }}</a>
<span class="this-tx-badge" *ngIf="hoverNode.isCurrent" i18n="cluster.this-transaction|this transaction">this transaction</span>
</div>
<div class="tooltip-body">
<table class="stats">
<tr>
<th i18n="transaction.fee|Transaction fee">Fee</th>
<td>{{ hoverNode.tx.fee | number }} <span class="unit" i18n="shared.sats">sats</span></td>
</tr>
<tr>
<th i18n="transaction.vsize|Virtual size">Size</th>
<td><span [innerHTML]="hoverNode.tx.weight / 4 | vbytes: 2"></span></td>
</tr>
<tr>
<th i18n="transaction.fee-rate|Transaction fee rate">Fee rate</th>
<td>{{ hoverNode.feerate | feeRounding }} <span class="unit">sat/vB</span></td>
</tr>
</table>
<div class="legend">
<div><span class="swatch ancestor"></span><span i18n="cluster.parents|parents">parents</span></div>
<div><span class="swatch descendant"></span><span i18n="cluster.children|children">children</span></div>
</div>
</div>
</ng-container>
<div class="legend edge-legend" *ngIf="!hoverNode && hoverEdge">
<div><span class="swatch ancestor"></span><span i18n="cluster.parent|parent">parent</span></div>
<div><span class="swatch descendant"></span><span i18n="cluster.child|child">child</span></div>
</div>
</div>

View file

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

View file

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

View file

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

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

@ -46,6 +46,11 @@
<ng-template #detailsRight>
<ng-container *ngTemplateOutlet="feeRow"></ng-container>
<ng-container *ngTemplateOutlet="feeRateRow"></ng-container>
@if (showAcceleratorSavingsMsg) {
<tr>
<td colspan="2" style="--bs-table-bg-type: var(--tertiary); white-space: normal; text-align: center;">Could have saved <app-fiat [blockConversion]="tx.price" [value]="acceleratorSavingsSats"></app-fiat> using <a href="https://mempool.space/enterprise#accelerator-pro" target="_blank" rel="noopener">Mempool Accelerator&reg; Pro</a></td>
</tr>
}
@if (!isLoadingTx && !tx?.status?.confirmed && isAcceleration) {
<ng-container *ngTemplateOutlet="acceleratingRow"></ng-container>
} @else {
@ -248,13 +253,14 @@
<ng-template #clusterPreviewButton let-cluster="cluster" let-txid="txid">
<div class="cluster-preview-inline" role="button" tabindex="0"
[ngbTooltip]="clusterPreviewTooltip"
[disableTooltip]="isMobile"
placement="bottom"
tooltipClass="cluster-preview-ngb-tooltip"
container="body"
(click)="toggleCpfp()"
(keydown.enter)="toggleCpfp()"
(keydown.space)="toggleCpfp(); $event.preventDefault()">
<app-cluster-diagram class="cluster-preview-diagram" [cluster]="cluster" [txid]="txid" [preview]="true"></app-cluster-diagram>
<app-cluster-diagram class="cluster-preview-diagram" [cluster]="cluster" [txid]="txid" [preview]="true" [isMobile]="isMobile"></app-cluster-diagram>
<fa-icon class="cluster-preview-expand" [icon]="['fas', cpfpMode ? 'compress' : 'expand']" [fixedWidth]="true"></fa-icon>
</div>
<ng-template #clusterPreviewTooltip>

View file

@ -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<boolean>();
@Output() toggleCpfp$ = new EventEmitter<void>();
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 ?? 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
&& 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 !== 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.cpfpInfo !== undefined && !this.hasCpfp;
}
onAccelerateClicked(): void {
this.accelerateClicked.emit(true);

View file

@ -104,7 +104,7 @@
<ng-container *ngIf="cpfpInfo?.cluster; else legacyCpfpRaw">
<div class="box">
<app-cluster-diagram
[cluster]="cpfpInfo.cluster" [txid]="transaction.txid">
[cluster]="cpfpInfo.cluster" [txid]="transaction.txid" [isMobile]="isMobile">
</app-cluster-diagram>
</div>
</ng-container>
@ -248,4 +248,4 @@
</h3>
</div>
}
</div>
</div>

View file

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

View file

@ -82,7 +82,6 @@
<!-- CPFP Details -->
<ng-container *ngIf="cpfpMode && hasCpfp">
<span id="cluster" #cluster class="anchor"></span>
<br>
<div class="title float-start">
<h2 *ngIf="cpfpInfo?.cluster && !isAcceleration" i18n="transaction.cluster|Cluster">Cluster</h2>
@ -93,7 +92,7 @@
<ng-container *ngIf="cpfpInfo?.cluster && !isAcceleration; else legacyCpfp">
<div class="box">
<app-cluster-diagram
[cluster]="cpfpInfo.cluster" [txid]="tx.txid">
[cluster]="cpfpInfo.cluster" [txid]="tx.txid" [isMobile]="isMobile">
</app-cluster-diagram>
</div>
</ng-container>
@ -394,4 +393,4 @@
</ng-template>
</ng-template>
</div>
</div>

View file

@ -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) {
@ -197,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,
@ -228,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');
@ -609,7 +605,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 +616,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 +626,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 +884,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 +1077,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;
@ -1108,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);
@ -1128,41 +1126,26 @@ 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.getCpfpFragment(),
replaceUrl: true,
});
} else {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { cpfp: this.cpfpMode ? 'simple' : null },
queryParamsHandling: 'merge',
preserveFragment: true,
replaceUrl: true,
});
}
this.router.navigate([], {
relativeTo: this.route,
queryParams: { cpfp: this.cpfpMode ? 'true' : null },
queryParamsHandling: 'merge',
fragment: this.formatFragment(this.fragmentParams),
replaceUrl: true,
});
}
private isCpfpParamEnabled(cpfpParam: string | undefined): boolean {
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 +1155,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 +1175,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 +1189,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 +1244,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');

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

11
production/liquid-sync-assets Executable file
View file

@ -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/"

View file

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

View file

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

View file

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