mirror of
https://github.com/mempool/mempool.git
synced 2026-08-13 12:33:11 +02:00
Merge branch 'master' into knorrium/nginx_upgrade
This commit is contained in:
commit
b69e60525d
32 changed files with 747 additions and 306 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([])));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> <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> <b>({{ asset.ticker }})</b>
|
||||
</button>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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® 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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
11
production/liquid-sync-assets
Executable 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/"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue