Merge branch 'master' into update_gha

This commit is contained in:
Felipe Knorr Kuhn 2022-11-29 20:06:44 -08:00 committed by GitHub
commit 33775f32e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
152 changed files with 9638 additions and 9643 deletions

View File

@ -68,24 +68,24 @@ jobs:
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Checkout project - name: Checkout project
uses: actions/checkout@629c2de402a417ea7690ca6ce3f33229e27606a5 # v2 uses: actions/checkout@e2f20e631ae6d7dd3b768f56a5d2af784dd54791 # v2.5.0
- name: Init repo for Dockerization - name: Init repo for Dockerization
run: docker/init.sh "$TAG" run: docker/init.sh "$TAG"
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@27d0a4f181a40b142cce983c5393082c365d1480 # v1 uses: docker/setup-qemu-action@e81a89b1732b9c48d79cd809d8d81d79c4647a18 # v2.1.0
id: qemu id: qemu
- name: Setup Docker buildx action - name: Setup Docker buildx action
uses: docker/setup-buildx-action@94ab11c41e45d028884a99163086648e898eed25 # v1 uses: docker/setup-buildx-action@8c0edbc76e98fa90f69d9a2c020dcb50019dc325 # v2.2.1
id: buildx id: buildx
- name: Available platforms - name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }} run: echo ${{ steps.buildx.outputs.platforms }}
- name: Cache Docker layers - name: Cache Docker layers
uses: actions/cache@661fd3eb7f2f20d8c7c84bc2b0509efd7a826628 # v2 uses: actions/cache@9b0c1fce7a93df8e3bb8926b0d6e9d89e92f20a7 # v3.0.11
id: cache id: cache
with: with:
path: /tmp/.buildx-cache path: /tmp/.buildx-cache

2
.gitignore vendored
View File

@ -3,3 +3,5 @@ data
docker-compose.yml docker-compose.yml
backend/mempool-config.json backend/mempool-config.json
*.swp *.swp
frontend/src/resources/config.template.js
frontend/src/resources/config.js

View File

@ -2,6 +2,7 @@
"MEMPOOL": { "MEMPOOL": {
"NETWORK": "mainnet", "NETWORK": "mainnet",
"BACKEND": "electrum", "BACKEND": "electrum",
"ENABLED": true,
"HTTP_PORT": 8999, "HTTP_PORT": 8999,
"SPAWN_CLUSTER_PROCS": 0, "SPAWN_CLUSTER_PROCS": 0,
"API_URL_PREFIX": "/api/v1/", "API_URL_PREFIX": "/api/v1/",
@ -23,7 +24,9 @@
"STDOUT_LOG_MIN_PRIORITY": "debug", "STDOUT_LOG_MIN_PRIORITY": "debug",
"AUTOMATIC_BLOCK_REINDEXING": false, "AUTOMATIC_BLOCK_REINDEXING": false,
"POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json", "POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json",
"POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master" "POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master",
"ADVANCED_TRANSACTION_SELECTION": false,
"TRANSACTION_INDEXING": false
}, },
"CORE_RPC": { "CORE_RPC": {
"HOST": "127.0.0.1", "HOST": "127.0.0.1",
@ -80,7 +83,8 @@
"BACKEND": "lnd", "BACKEND": "lnd",
"STATS_REFRESH_INTERVAL": 600, "STATS_REFRESH_INTERVAL": 600,
"GRAPH_REFRESH_INTERVAL": 600, "GRAPH_REFRESH_INTERVAL": 600,
"LOGGER_UPDATE_INTERVAL": 30 "LOGGER_UPDATE_INTERVAL": 30,
"FORENSICS_INTERVAL": 43200
}, },
"LND": { "LND": {
"TLS_CERT_PATH": "tls.cert", "TLS_CERT_PATH": "tls.cert",

View File

@ -1,7 +1,9 @@
{ {
"MEMPOOL": { "MEMPOOL": {
"ENABLED": true,
"NETWORK": "__MEMPOOL_NETWORK__", "NETWORK": "__MEMPOOL_NETWORK__",
"BACKEND": "__MEMPOOL_BACKEND__", "BACKEND": "__MEMPOOL_BACKEND__",
"ENABLED": true,
"BLOCKS_SUMMARIES_INDEXING": true, "BLOCKS_SUMMARIES_INDEXING": true,
"HTTP_PORT": 1, "HTTP_PORT": 1,
"SPAWN_CLUSTER_PROCS": 2, "SPAWN_CLUSTER_PROCS": 2,
@ -23,7 +25,9 @@
"STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__", "STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__",
"INDEXING_BLOCKS_AMOUNT": 14, "INDEXING_BLOCKS_AMOUNT": 14,
"POOLS_JSON_TREE_URL": "__POOLS_JSON_TREE_URL__", "POOLS_JSON_TREE_URL": "__POOLS_JSON_TREE_URL__",
"POOLS_JSON_URL": "__POOLS_JSON_URL__" "POOLS_JSON_URL": "__POOLS_JSON_URL__",
"ADVANCED_TRANSACTION_SELECTION": "__ADVANCED_TRANSACTION_SELECTION__",
"TRANSACTION_INDEXING": "__TRANSACTION_INDEXING__"
}, },
"CORE_RPC": { "CORE_RPC": {
"HOST": "__CORE_RPC_HOST__", "HOST": "__CORE_RPC_HOST__",
@ -95,7 +99,8 @@
"TOPOLOGY_FOLDER": "__LIGHTNING_TOPOLOGY_FOLDER__", "TOPOLOGY_FOLDER": "__LIGHTNING_TOPOLOGY_FOLDER__",
"STATS_REFRESH_INTERVAL": 600, "STATS_REFRESH_INTERVAL": 600,
"GRAPH_REFRESH_INTERVAL": 600, "GRAPH_REFRESH_INTERVAL": 600,
"LOGGER_UPDATE_INTERVAL": 30 "LOGGER_UPDATE_INTERVAL": 30,
"FORENSICS_INTERVAL": 43200
}, },
"LND": { "LND": {
"TLS_CERT_PATH": "", "TLS_CERT_PATH": "",

View File

@ -13,6 +13,7 @@ describe('Mempool Backend Config', () => {
const config = jest.requireActual('../config').default; const config = jest.requireActual('../config').default;
expect(config.MEMPOOL).toStrictEqual({ expect(config.MEMPOOL).toStrictEqual({
ENABLED: true,
NETWORK: 'mainnet', NETWORK: 'mainnet',
BACKEND: 'none', BACKEND: 'none',
BLOCKS_SUMMARIES_INDEXING: false, BLOCKS_SUMMARIES_INDEXING: false,
@ -36,7 +37,9 @@ describe('Mempool Backend Config', () => {
USER_AGENT: 'mempool', USER_AGENT: 'mempool',
STDOUT_LOG_MIN_PRIORITY: 'debug', STDOUT_LOG_MIN_PRIORITY: 'debug',
POOLS_JSON_TREE_URL: 'https://api.github.com/repos/mempool/mining-pools/git/trees/master', POOLS_JSON_TREE_URL: 'https://api.github.com/repos/mempool/mining-pools/git/trees/master',
POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json' POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json',
ADVANCED_TRANSACTION_SELECTION: false,
TRANSACTION_INDEXING: false,
}); });
expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true }); expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true });

View File

@ -1,13 +1,18 @@
import logger from '../logger'; import config from '../config';
import { BlockExtended, TransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces'; import bitcoinApi from './bitcoin/bitcoin-api-factory';
import { Common } from './common';
import { TransactionExtended, MempoolBlockWithTransactions, AuditScore } from '../mempool.interfaces';
import blocksRepository from '../repositories/BlocksRepository';
import blocksAuditsRepository from '../repositories/BlocksAuditsRepository';
import blocks from '../api/blocks';
const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners
class Audit { class Audit {
auditBlock(transactions: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended }) auditBlock(transactions: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended })
: { censored: string[], added: string[], score: number } { : { censored: string[], added: string[], fresh: string[], score: number } {
if (!projectedBlocks?.[0]?.transactionIds || !mempool) { if (!projectedBlocks?.[0]?.transactionIds || !mempool) {
return { censored: [], added: [], score: 0 }; return { censored: [], added: [], fresh: [], score: 0 };
} }
const matches: string[] = []; // present in both mined block and template const matches: string[] = []; // present in both mined block and template
@ -44,8 +49,6 @@ class Audit {
displacedWeight += (4000 - transactions[0].weight); displacedWeight += (4000 - transactions[0].weight);
logger.warn(`${fresh.length} fresh, ${Object.keys(isCensored).length} possibly censored, ${displacedWeight} displaced weight`);
// we can expect an honest miner to include 'displaced' transactions in place of recent arrivals and censored txs // we can expect an honest miner to include 'displaced' transactions in place of recent arrivals and censored txs
// these displaced transactions should occupy the first N weight units of the next projected block // these displaced transactions should occupy the first N weight units of the next projected block
let displacedWeightRemaining = displacedWeight; let displacedWeightRemaining = displacedWeight;
@ -73,20 +76,33 @@ class Audit {
// mark unexpected transactions in the mined block as 'added' // mark unexpected transactions in the mined block as 'added'
let overflowWeight = 0; let overflowWeight = 0;
let totalWeight = 0;
for (const tx of transactions) { for (const tx of transactions) {
if (inTemplate[tx.txid]) { if (inTemplate[tx.txid]) {
matches.push(tx.txid); matches.push(tx.txid);
} else { } else {
if (!isDisplaced[tx.txid]) { if (!isDisplaced[tx.txid]) {
added.push(tx.txid); added.push(tx.txid);
} else {
} }
let blockIndex = -1;
let index = -1;
projectedBlocks.forEach((block, bi) => {
const i = block.transactionIds.indexOf(tx.txid);
if (i >= 0) {
blockIndex = bi;
index = i;
}
});
overflowWeight += tx.weight; overflowWeight += tx.weight;
} }
totalWeight += tx.weight;
} }
// transactions missing from near the end of our template are probably not being censored // transactions missing from near the end of our template are probably not being censored
let overflowWeightRemaining = overflowWeight; let overflowWeightRemaining = overflowWeight - (config.MEMPOOL.BLOCK_WEIGHT_UNITS - totalWeight);
let lastOverflowRate = 1.00; let maxOverflowRate = 0;
let rateThreshold = 0;
index = projectedBlocks[0].transactionIds.length - 1; index = projectedBlocks[0].transactionIds.length - 1;
while (index >= 0) { while (index >= 0) {
const txid = projectedBlocks[0].transactionIds[index]; const txid = projectedBlocks[0].transactionIds[index];
@ -94,8 +110,11 @@ class Audit {
if (isCensored[txid]) { if (isCensored[txid]) {
delete isCensored[txid]; delete isCensored[txid];
} }
lastOverflowRate = mempool[txid].effectiveFeePerVsize; if (mempool[txid].effectiveFeePerVsize > maxOverflowRate) {
} else if (Math.floor(mempool[txid].effectiveFeePerVsize * 100) <= Math.ceil(lastOverflowRate * 100)) { // tolerance of 0.01 sat/vb maxOverflowRate = mempool[txid].effectiveFeePerVsize;
rateThreshold = (Math.ceil(maxOverflowRate * 100) / 100) + 0.005;
}
} else if (mempool[txid].effectiveFeePerVsize <= rateThreshold) { // tolerance of 0.01 sat/vb + rounding
if (isCensored[txid]) { if (isCensored[txid]) {
delete isCensored[txid]; delete isCensored[txid];
} }
@ -110,6 +129,7 @@ class Audit {
return { return {
censored: Object.keys(isCensored), censored: Object.keys(isCensored),
added, added,
fresh,
score score
}; };
} }

View File

@ -3,13 +3,14 @@ import { IEsploraApi } from './esplora-api.interface';
export interface AbstractBitcoinApi { export interface AbstractBitcoinApi {
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]>; $getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]>;
$getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean, lazyPrevouts?: boolean): Promise<IEsploraApi.Transaction>; $getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean, lazyPrevouts?: boolean): Promise<IEsploraApi.Transaction>;
$getTransactionHex(txId: string): Promise<string>;
$getBlockHeightTip(): Promise<number>; $getBlockHeightTip(): Promise<number>;
$getBlockHashTip(): Promise<string>; $getBlockHashTip(): Promise<string>;
$getTxIdsForBlock(hash: string): Promise<string[]>; $getTxIdsForBlock(hash: string): Promise<string[]>;
$getBlockHash(height: number): Promise<string>; $getBlockHash(height: number): Promise<string>;
$getBlockHeader(hash: string): Promise<string>; $getBlockHeader(hash: string): Promise<string>;
$getBlock(hash: string): Promise<IEsploraApi.Block>; $getBlock(hash: string): Promise<IEsploraApi.Block>;
$getRawBlock(hash: string): Promise<string>; $getRawBlock(hash: string): Promise<Buffer>;
$getAddress(address: string): Promise<IEsploraApi.Address>; $getAddress(address: string): Promise<IEsploraApi.Address>;
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>; $getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
$getAddressPrefix(prefix: string): string[]; $getAddressPrefix(prefix: string): string[];

View File

@ -57,6 +57,11 @@ class BitcoinApi implements AbstractBitcoinApi {
}); });
} }
$getTransactionHex(txId: string): Promise<string> {
return this.$getRawTransaction(txId, true)
.then((tx) => tx.hex || '');
}
$getBlockHeightTip(): Promise<number> { $getBlockHeightTip(): Promise<number> {
return this.bitcoindClient.getChainTips() return this.bitcoindClient.getChainTips()
.then((result: IBitcoinApi.ChainTips[]) => { .then((result: IBitcoinApi.ChainTips[]) => {
@ -76,7 +81,7 @@ class BitcoinApi implements AbstractBitcoinApi {
.then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx); .then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx);
} }
$getRawBlock(hash: string): Promise<string> { $getRawBlock(hash: string): Promise<Buffer> {
return this.bitcoindClient.getBlock(hash, 0) return this.bitcoindClient.getBlock(hash, 0)
.then((raw: string) => Buffer.from(raw, "hex")); .then((raw: string) => Buffer.from(raw, "hex"));
} }

View File

@ -1,5 +1,6 @@
import { Application, Request, Response } from 'express'; import { Application, Request, Response } from 'express';
import axios from 'axios'; import axios from 'axios';
import * as bitcoinjs from 'bitcoinjs-lib';
import config from '../../config'; import config from '../../config';
import websocketHandler from '../websocket-handler'; import websocketHandler from '../websocket-handler';
import mempool from '../mempool'; import mempool from '../mempool';
@ -16,13 +17,14 @@ import logger from '../../logger';
import blocks from '../blocks'; import blocks from '../blocks';
import bitcoinClient from './bitcoin-client'; import bitcoinClient from './bitcoin-client';
import difficultyAdjustment from '../difficulty-adjustment'; import difficultyAdjustment from '../difficulty-adjustment';
import transactionRepository from '../../repositories/TransactionRepository';
class BitcoinRoutes { class BitcoinRoutes {
public initRoutes(app: Application) { public initRoutes(app: Application) {
app app
.get(config.MEMPOOL.API_URL_PREFIX + 'transaction-times', this.getTransactionTimes) .get(config.MEMPOOL.API_URL_PREFIX + 'transaction-times', this.getTransactionTimes)
.get(config.MEMPOOL.API_URL_PREFIX + 'outspends', this.$getBatchedOutspends) .get(config.MEMPOOL.API_URL_PREFIX + 'outspends', this.$getBatchedOutspends)
.get(config.MEMPOOL.API_URL_PREFIX + 'cpfp/:txId', this.getCpfpInfo) .get(config.MEMPOOL.API_URL_PREFIX + 'cpfp/:txId', this.$getCpfpInfo)
.get(config.MEMPOOL.API_URL_PREFIX + 'difficulty-adjustment', this.getDifficultyChange) .get(config.MEMPOOL.API_URL_PREFIX + 'difficulty-adjustment', this.getDifficultyChange)
.get(config.MEMPOOL.API_URL_PREFIX + 'fees/recommended', this.getRecommendedFees) .get(config.MEMPOOL.API_URL_PREFIX + 'fees/recommended', this.getRecommendedFees)
.get(config.MEMPOOL.API_URL_PREFIX + 'fees/mempool-blocks', this.getMempoolBlocks) .get(config.MEMPOOL.API_URL_PREFIX + 'fees/mempool-blocks', this.getMempoolBlocks)
@ -87,7 +89,9 @@ class BitcoinRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks', this.getBlocks.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks', this.getBlocks.bind(this))
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', this.getBlocks.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', this.getBlocks.bind(this))
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', this.getBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', this.getBlock)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions); .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/audit-summary', this.getBlockAuditSummary)
.post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion)
; ;
if (config.MEMPOOL.BACKEND !== 'esplora') { if (config.MEMPOOL.BACKEND !== 'esplora') {
@ -185,29 +189,36 @@ class BitcoinRoutes {
} }
} }
private getCpfpInfo(req: Request, res: Response) { private async $getCpfpInfo(req: Request, res: Response) {
if (!/^[a-fA-F0-9]{64}$/.test(req.params.txId)) { if (!/^[a-fA-F0-9]{64}$/.test(req.params.txId)) {
res.status(501).send(`Invalid transaction ID.`); res.status(501).send(`Invalid transaction ID.`);
return; return;
} }
const tx = mempool.getMempool()[req.params.txId]; const tx = mempool.getMempool()[req.params.txId];
if (!tx) { if (tx) {
res.status(404).send(`Transaction doesn't exist in the mempool.`); if (tx?.cpfpChecked) {
res.json({
ancestors: tx.ancestors,
bestDescendant: tx.bestDescendant || null,
descendants: tx.descendants || null,
effectiveFeePerVsize: tx.effectiveFeePerVsize || null,
});
return;
}
const cpfpInfo = Common.setRelativesAndGetCpfpInfo(tx, mempool.getMempool());
res.json(cpfpInfo);
return; return;
} else {
const cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId);
if (cpfpInfo) {
res.json(cpfpInfo);
return;
}
} }
res.status(404).send(`Transaction has no CPFP info available.`);
if (tx.cpfpChecked) {
res.json({
ancestors: tx.ancestors,
bestDescendant: tx.bestDescendant || null,
});
return;
}
const cpfpInfo = Common.setRelativesAndGetCpfpInfo(tx, mempool.getMempool());
res.json(cpfpInfo);
} }
private getBackendInfo(req: Request, res: Response) { private getBackendInfo(req: Request, res: Response) {
@ -241,6 +252,74 @@ class BitcoinRoutes {
} }
} }
/**
* Takes the PSBT as text/plain body, parses it, and adds the full
* parent transaction to each input that doesn't already have it.
* This is used for BTCPayServer / Trezor users which need access to
* the full parent transaction even with segwit inputs.
* It will respond with a text/plain PSBT in the same format (hex|base64).
*/
private async postPsbtCompletion(req: Request, res: Response): Promise<void> {
res.setHeader('content-type', 'text/plain');
const notFoundError = `Couldn't get transaction hex for parent of input`;
try {
let psbt: bitcoinjs.Psbt;
let format: 'hex' | 'base64';
let isModified = false;
try {
psbt = bitcoinjs.Psbt.fromBase64(req.body);
format = 'base64';
} catch (e1) {
try {
psbt = bitcoinjs.Psbt.fromHex(req.body);
format = 'hex';
} catch (e2) {
throw new Error(`Unable to parse PSBT`);
}
}
for (const [index, input] of psbt.data.inputs.entries()) {
if (!input.nonWitnessUtxo) {
// Buffer.from ensures it won't be modified in place by reverse()
const txid = Buffer.from(psbt.txInputs[index].hash)
.reverse()
.toString('hex');
let transactionHex: string;
// If missing transaction, return 404 status error
try {
transactionHex = await bitcoinApi.$getTransactionHex(txid);
if (!transactionHex) {
throw new Error('');
}
} catch (err) {
throw new Error(`${notFoundError} #${index} @ ${txid}`);
}
psbt.updateInput(index, {
nonWitnessUtxo: Buffer.from(transactionHex, 'hex'),
});
if (!isModified) {
isModified = true;
}
}
}
if (isModified) {
res.send(format === 'hex' ? psbt.toHex() : psbt.toBase64());
} else {
// Not modified
// 422 Unprocessable Entity
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422
res.status(422).send(`Psbt had no missing nonWitnessUtxos.`);
}
} catch (e: any) {
if (e instanceof Error && new RegExp(notFoundError).test(e.message)) {
res.status(404).send(e.message);
} else {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
}
private async getTransactionStatus(req: Request, res: Response) { private async getTransactionStatus(req: Request, res: Response) {
try { try {
const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true); const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true);
@ -254,6 +333,16 @@ class BitcoinRoutes {
} }
} }
private async getStrippedBlockTransactions(req: Request, res: Response) {
try {
const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash);
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
res.json(transactions);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async getBlock(req: Request, res: Response) { private async getBlock(req: Request, res: Response) {
try { try {
const block = await blocks.$getBlock(req.params.hash); const block = await blocks.$getBlock(req.params.hash);
@ -286,9 +375,9 @@ class BitcoinRoutes {
} }
} }
private async getStrippedBlockTransactions(req: Request, res: Response) { private async getBlockAuditSummary(req: Request, res: Response) {
try { try {
const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); const transactions = await blocks.$getBlockAuditSummary(req.params.hash);
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
res.json(transactions); res.json(transactions);
} catch (e) { } catch (e) {

View File

@ -20,6 +20,11 @@ class ElectrsApi implements AbstractBitcoinApi {
.then((response) => response.data); .then((response) => response.data);
} }
$getTransactionHex(txId: string): Promise<string> {
return axios.get<string>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex', this.axiosConfig)
.then((response) => response.data);
}
$getBlockHeightTip(): Promise<number> { $getBlockHeightTip(): Promise<number> {
return axios.get<number>(config.ESPLORA.REST_API_URL + '/blocks/tip/height', this.axiosConfig) return axios.get<number>(config.ESPLORA.REST_API_URL + '/blocks/tip/height', this.axiosConfig)
.then((response) => response.data); .then((response) => response.data);
@ -50,9 +55,9 @@ class ElectrsApi implements AbstractBitcoinApi {
.then((response) => response.data); .then((response) => response.data);
} }
$getRawBlock(hash: string): Promise<string> { $getRawBlock(hash: string): Promise<Buffer> {
return axios.get<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", this.axiosConfig) return axios.get<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", { ...this.axiosConfig, responseType: 'arraybuffer' })
.then((response) => response.data); .then((response) => { return Buffer.from(response.data); });
} }
$getAddress(address: string): Promise<IEsploraApi.Address> { $getAddress(address: string): Promise<IEsploraApi.Address> {

View File

@ -21,10 +21,13 @@ import fiatConversion from './fiat-conversion';
import poolsParser from './pools-parser'; import poolsParser from './pools-parser';
import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository'; import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository'; import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository';
import cpfpRepository from '../repositories/CpfpRepository';
import transactionRepository from '../repositories/TransactionRepository';
import mining from './mining/mining'; import mining from './mining/mining';
import DifficultyAdjustmentsRepository from '../repositories/DifficultyAdjustmentsRepository'; import DifficultyAdjustmentsRepository from '../repositories/DifficultyAdjustmentsRepository';
import PricesRepository from '../repositories/PricesRepository'; import PricesRepository from '../repositories/PricesRepository';
import priceUpdater from '../tasks/price-updater'; import priceUpdater from '../tasks/price-updater';
import { Block } from 'bitcoinjs-lib';
class Blocks { class Blocks {
private blocks: BlockExtended[] = []; private blocks: BlockExtended[] = [];
@ -34,6 +37,7 @@ class Blocks {
private lastDifficultyAdjustmentTime = 0; private lastDifficultyAdjustmentTime = 0;
private previousDifficultyRetarget = 0; private previousDifficultyRetarget = 0;
private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = []; private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = [];
private newAsyncBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => Promise<void>)[] = [];
constructor() { } constructor() { }
@ -57,6 +61,10 @@ class Blocks {
this.newBlockCallbacks.push(fn); this.newBlockCallbacks.push(fn);
} }
public setNewAsyncBlockCallback(fn: (block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => Promise<void>) {
this.newAsyncBlockCallbacks.push(fn);
}
/** /**
* Return the list of transaction for a block * Return the list of transaction for a block
* @param blockHash * @param blockHash
@ -130,7 +138,7 @@ class Blocks {
const stripped = block.tx.map((tx) => { const stripped = block.tx.map((tx) => {
return { return {
txid: tx.txid, txid: tx.txid,
vsize: tx.vsize, vsize: tx.weight / 4,
fee: tx.fee ? Math.round(tx.fee * 100000000) : 0, fee: tx.fee ? Math.round(tx.fee * 100000000) : 0,
value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0) * 100000000) value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0) * 100000000)
}; };
@ -195,9 +203,9 @@ class Blocks {
}; };
} }
const auditSummary = await BlocksAuditsRepository.$getShortBlockAudit(block.id); const auditScore = await BlocksAuditsRepository.$getBlockAuditScore(block.id);
if (auditSummary) { if (auditScore != null) {
blockExtended.extras.matchRate = auditSummary.matchRate; blockExtended.extras.matchRate = auditScore.matchRate;
} }
} }
@ -255,7 +263,7 @@ class Blocks {
/** /**
* [INDEXING] Index all blocks summaries for the block txs visualization * [INDEXING] Index all blocks summaries for the block txs visualization
*/ */
public async $generateBlocksSummariesDatabase() { public async $generateBlocksSummariesDatabase(): Promise<void> {
if (Common.blocksSummariesIndexingEnabled() === false) { if (Common.blocksSummariesIndexingEnabled() === false) {
return; return;
} }
@ -311,6 +319,57 @@ class Blocks {
} }
} }
/**
* [INDEXING] Index transaction CPFP data for all blocks
*/
public async $generateCPFPDatabase(): Promise<void> {
if (Common.cpfpIndexingEnabled() === false) {
return;
}
try {
// Get all indexed block hash
const unindexedBlocks = await blocksRepository.$getCPFPUnindexedBlocks();
if (!unindexedBlocks?.length) {
return;
}
// Logging
let count = 0;
let countThisRun = 0;
let timer = new Date().getTime() / 1000;
const startedAt = new Date().getTime() / 1000;
for (const block of unindexedBlocks) {
// Logging
const elapsedSeconds = Math.max(1, new Date().getTime() / 1000 - timer);
if (elapsedSeconds > 5) {
const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
const blockPerSeconds = Math.max(1, countThisRun / elapsedSeconds);
const progress = Math.round(count / unindexedBlocks.length * 10000) / 100;
logger.debug(`Indexing cpfp clusters for #${block.height} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${count}/${unindexedBlocks.length} (${progress}%) | elapsed: ${runningFor} seconds`);
timer = new Date().getTime() / 1000;
countThisRun = 0;
}
await this.$indexCPFP(block.hash, block.height); // Calculate and save CPFP data for transactions in this block
// Logging
count++;
countThisRun++;
}
if (count > 0) {
logger.notice(`CPFP indexing completed: indexed ${count} blocks`);
} else {
logger.debug(`CPFP indexing completed: indexed ${count} blocks`);
}
} catch (e) {
logger.err(`CPFP indexing failed. Trying again in 10 seconds. Reason: ${(e instanceof Error ? e.message : e)}`);
throw e;
}
}
/** /**
* [INDEXING] Index all blocks metadata for the mining dashboard * [INDEXING] Index all blocks metadata for the mining dashboard
*/ */
@ -354,7 +413,7 @@ class Blocks {
} }
++indexedThisRun; ++indexedThisRun;
++totalIndexed; ++totalIndexed;
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer)); const elapsedSeconds = Math.max(1, new Date().getTime() / 1000 - timer);
if (elapsedSeconds > 5 || blockHeight === lastBlockToIndex) { if (elapsedSeconds > 5 || blockHeight === lastBlockToIndex) {
const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
const blockPerSeconds = Math.max(1, indexedThisRun / elapsedSeconds); const blockPerSeconds = Math.max(1, indexedThisRun / elapsedSeconds);
@ -444,6 +503,9 @@ class Blocks {
const blockExtended: BlockExtended = await this.$getBlockExtended(block, transactions); const blockExtended: BlockExtended = await this.$getBlockExtended(block, transactions);
const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock); const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock);
// start async callbacks
const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, transactions));
if (Common.indexingEnabled()) { if (Common.indexingEnabled()) {
if (!fastForwarded) { if (!fastForwarded) {
const lastBlock = await blocksRepository.$getBlockByHeight(blockExtended.height - 1); const lastBlock = await blocksRepository.$getBlockByHeight(blockExtended.height - 1);
@ -453,9 +515,13 @@ class Blocks {
await BlocksRepository.$deleteBlocksFrom(lastBlock['height'] - 10); await BlocksRepository.$deleteBlocksFrom(lastBlock['height'] - 10);
await HashratesRepository.$deleteLastEntries(); await HashratesRepository.$deleteLastEntries();
await BlocksSummariesRepository.$deleteBlocksFrom(lastBlock['height'] - 10); await BlocksSummariesRepository.$deleteBlocksFrom(lastBlock['height'] - 10);
await cpfpRepository.$deleteClustersFrom(lastBlock['height'] - 10);
for (let i = 10; i >= 0; --i) { for (let i = 10; i >= 0; --i) {
const newBlock = await this.$indexBlock(lastBlock['height'] - i); const newBlock = await this.$indexBlock(lastBlock['height'] - i);
await this.$getStrippedBlockTransactions(newBlock.id, true, true); await this.$getStrippedBlockTransactions(newBlock.id, true, true);
if (config.MEMPOOL.TRANSACTION_INDEXING) {
await this.$indexCPFP(newBlock.id, lastBlock['height'] - i);
}
} }
await mining.$indexDifficultyAdjustments(); await mining.$indexDifficultyAdjustments();
await DifficultyAdjustmentsRepository.$deleteLastAdjustment(); await DifficultyAdjustmentsRepository.$deleteLastAdjustment();
@ -481,6 +547,9 @@ class Blocks {
if (Common.blocksSummariesIndexingEnabled() === true) { if (Common.blocksSummariesIndexingEnabled() === true) {
await this.$getStrippedBlockTransactions(blockExtended.id, true); await this.$getStrippedBlockTransactions(blockExtended.id, true);
} }
if (config.MEMPOOL.TRANSACTION_INDEXING) {
this.$indexCPFP(blockExtended.id, this.currentBlockHeight);
}
} }
} }
@ -514,6 +583,9 @@ class Blocks {
if (!memPool.hasPriority()) { if (!memPool.hasPriority()) {
diskCache.$saveCacheToDisk(); diskCache.$saveCacheToDisk();
} }
// wait for pending async callbacks to finish
await Promise.all(callbackPromises);
} }
} }
@ -579,7 +651,7 @@ class Blocks {
if (skipMemoryCache === false) { if (skipMemoryCache === false) {
// Check the memory cache // Check the memory cache
const cachedSummary = this.getBlockSummaries().find((b) => b.id === hash); const cachedSummary = this.getBlockSummaries().find((b) => b.id === hash);
if (cachedSummary) { if (cachedSummary?.transactions?.length) {
return cachedSummary.transactions; return cachedSummary.transactions;
} }
} }
@ -587,7 +659,7 @@ class Blocks {
// Check if it's indexed in db // Check if it's indexed in db
if (skipDBLookup === false && Common.blocksSummariesIndexingEnabled() === true) { if (skipDBLookup === false && Common.blocksSummariesIndexingEnabled() === true) {
const indexedSummary = await BlocksSummariesRepository.$getByBlockId(hash); const indexedSummary = await BlocksSummariesRepository.$getByBlockId(hash);
if (indexedSummary !== undefined) { if (indexedSummary !== undefined && indexedSummary?.transactions?.length) {
return indexedSummary.transactions; return indexedSummary.transactions;
} }
} }
@ -640,6 +712,22 @@ class Blocks {
return returnBlocks; return returnBlocks;
} }
public async $getBlockAuditSummary(hash: string): Promise<any> {
let summary;
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
summary = await BlocksAuditsRepository.$getBlockAudit(hash);
}
// fallback to non-audited transaction summary
if (!summary?.transactions?.length) {
const strippedTransactions = await this.$getStrippedBlockTransactions(hash);
summary = {
transactions: strippedTransactions
};
}
return summary;
}
public getLastDifficultyAdjustmentTime(): number { public getLastDifficultyAdjustmentTime(): number {
return this.lastDifficultyAdjustmentTime; return this.lastDifficultyAdjustmentTime;
} }
@ -651,6 +739,62 @@ class Blocks {
public getCurrentBlockHeight(): number { public getCurrentBlockHeight(): number {
return this.currentBlockHeight; return this.currentBlockHeight;
} }
public async $indexCPFP(hash: string, height: number): Promise<void> {
let transactions;
if (false/*Common.blocksSummariesIndexingEnabled()*/) {
transactions = await this.$getStrippedBlockTransactions(hash);
const rawBlock = await bitcoinApi.$getRawBlock(hash);
const block = Block.fromBuffer(rawBlock);
const txMap = {};
for (const tx of block.transactions || []) {
txMap[tx.getId()] = tx;
}
for (const tx of transactions) {
if (txMap[tx.txid]?.ins) {
tx.vin = txMap[tx.txid].ins.map(vin => {
return {
txid: vin.hash
};
});
}
}
} else {
const block = await bitcoinClient.getBlock(hash, 2);
transactions = block.tx.map(tx => {
tx.vsize = tx.weight / 4;
return tx;
});
}
let cluster: TransactionStripped[] = [];
let ancestors: { [txid: string]: boolean } = {};
for (let i = transactions.length - 1; i >= 0; i--) {
const tx = transactions[i];
if (!ancestors[tx.txid]) {
let totalFee = 0;
let totalVSize = 0;
cluster.forEach(tx => {
totalFee += tx?.fee || 0;
totalVSize += tx.vsize;
});
const effectiveFeePerVsize = (totalFee * 100_000_000) / totalVSize;
if (cluster.length > 1) {
await cpfpRepository.$saveCluster(height, cluster.map(tx => { return { txid: tx.txid, weight: tx.vsize * 4, fee: (tx.fee || 0) * 100_000_000 }; }), effectiveFeePerVsize);
for (const tx of cluster) {
await transactionRepository.$setCluster(tx.txid, cluster[0].txid);
}
}
cluster = [];
ancestors = {};
}
cluster.push(tx);
tx.vin.forEach(vin => {
ancestors[vin.txid] = true;
});
}
await blocksRepository.$setCPFPIndexed(hash);
}
} }
export default new Blocks(); export default new Blocks();

View File

@ -187,6 +187,13 @@ export class Common {
); );
} }
static cpfpIndexingEnabled(): boolean {
return (
Common.indexingEnabled() &&
config.MEMPOOL.TRANSACTION_INDEXING === true
);
}
static setDateMidnight(date: Date): void { static setDateMidnight(date: Date): void {
date.setUTCHours(0); date.setUTCHours(0);
date.setUTCMinutes(0); date.setUTCMinutes(0);

View File

@ -4,8 +4,8 @@ import logger from '../logger';
import { Common } from './common'; import { Common } from './common';
class DatabaseMigration { class DatabaseMigration {
private static currentVersion = 41; private static currentVersion = 47;
private queryTimeout = 120000; private queryTimeout = 900_000;
private statisticsAddedIndexed = false; private statisticsAddedIndexed = false;
private uniqueLogs: string[] = []; private uniqueLogs: string[] = [];
@ -352,7 +352,34 @@ class DatabaseMigration {
if (databaseSchemaVersion < 41 && isBitcoin === true) { if (databaseSchemaVersion < 41 && isBitcoin === true) {
await this.$executeQuery('UPDATE channels SET closing_reason = NULL WHERE closing_reason = 1'); await this.$executeQuery('UPDATE channels SET closing_reason = NULL WHERE closing_reason = 1');
} }
}
if (databaseSchemaVersion < 42 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `channels` ADD closing_resolved tinyint(1) DEFAULT 0');
}
if (databaseSchemaVersion < 43 && isBitcoin === true) {
await this.$executeQuery(this.getCreateLNNodeRecordsTableQuery(), await this.$checkIfTableExists('nodes_records'));
}
if (databaseSchemaVersion < 44 && isBitcoin === true) {
await this.$executeQuery('TRUNCATE TABLE `blocks_audits`');
await this.$executeQuery('UPDATE blocks_summaries SET template = NULL');
}
if (databaseSchemaVersion < 45 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fresh_txs JSON DEFAULT "[]"');
}
if (databaseSchemaVersion < 46) {
await this.$executeQuery(`ALTER TABLE blocks MODIFY blockTimestamp timestamp NOT NULL DEFAULT 0`);
}
if (databaseSchemaVersion < 47) {
await this.$executeQuery('ALTER TABLE `blocks` ADD cpfp_indexed tinyint(1) DEFAULT 0');
await this.$executeQuery(this.getCreateCPFPTableQuery(), await this.$checkIfTableExists('cpfp_clusters'));
await this.$executeQuery(this.getCreateTransactionsTableQuery(), await this.$checkIfTableExists('transactions'));
}
}
/** /**
* Special case here for the `statistics` table - It appeared that somehow some dbs already had the `added` field indexed * Special case here for the `statistics` table - It appeared that somehow some dbs already had the `added` field indexed
@ -787,6 +814,38 @@ class DatabaseMigration {
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
} }
private getCreateLNNodeRecordsTableQuery(): string {
return `CREATE TABLE IF NOT EXISTS nodes_records (
public_key varchar(66) NOT NULL,
type int(10) unsigned NOT NULL,
payload blob NOT NULL,
UNIQUE KEY public_key_type (public_key, type),
INDEX (public_key),
FOREIGN KEY (public_key)
REFERENCES nodes (public_key)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
}
private getCreateCPFPTableQuery(): string {
return `CREATE TABLE IF NOT EXISTS cpfp_clusters (
root varchar(65) NOT NULL,
height int(10) NOT NULL,
txs JSON DEFAULT NULL,
fee_rate double unsigned NOT NULL,
PRIMARY KEY (root)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
}
private getCreateTransactionsTableQuery(): string {
return `CREATE TABLE IF NOT EXISTS transactions (
txid varchar(65) NOT NULL,
cluster varchar(65) DEFAULT NULL,
PRIMARY KEY (txid),
FOREIGN KEY (cluster) REFERENCES cpfp_clusters (root) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
}
public async $truncateIndexedData(tables: string[]) { public async $truncateIndexedData(tables: string[]) {
const allowedTables = ['blocks', 'hashrates', 'prices']; const allowedTables = ['blocks', 'hashrates', 'prices'];

View File

@ -117,6 +117,17 @@ class ChannelsApi {
} }
} }
public async $getUnresolvedClosedChannels(): Promise<any[]> {
try {
const query = `SELECT * FROM channels WHERE status = 2 AND closing_reason = 2 AND closing_resolved = 0 AND closing_transaction_id != ''`;
const [rows]: any = await DB.query(query);
return rows;
} catch (e) {
logger.err('$getUnresolvedClosedChannels error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $getChannelsWithoutCreatedDate(): Promise<any[]> { public async $getChannelsWithoutCreatedDate(): Promise<any[]> {
try { try {
const query = `SELECT * FROM channels WHERE created IS NULL`; const query = `SELECT * FROM channels WHERE created IS NULL`;

View File

@ -105,6 +105,18 @@ class NodesApi {
node.closed_channel_count = rows[0].closed_channel_count; node.closed_channel_count = rows[0].closed_channel_count;
} }
// Custom records
query = `
SELECT type, payload
FROM nodes_records
WHERE public_key = ?
`;
[rows] = await DB.query(query, [public_key]);
node.custom_records = {};
for (const record of rows) {
node.custom_records[record.type] = Buffer.from(record.payload, 'binary').toString('hex');
}
return node; return node;
} catch (e) { } catch (e) {
logger.err(`Cannot get node information for ${public_key}. Reason: ${(e instanceof Error ? e.message : e)}`); logger.err(`Cannot get node information for ${public_key}. Reason: ${(e instanceof Error ? e.message : e)}`);
@ -512,7 +524,37 @@ class NodesApi {
public async $getNodesPerISP(ISPId: string) { public async $getNodesPerISP(ISPId: string) {
try { try {
const query = ` let query = `
SELECT channels.node1_public_key AS node1PublicKey, isp1.id as isp1ID,
channels.node2_public_key AS node2PublicKey, isp2.id as isp2ID
FROM channels
JOIN nodes node1 ON node1.public_key = channels.node1_public_key
JOIN nodes node2 ON node2.public_key = channels.node2_public_key
JOIN geo_names isp1 ON isp1.id = node1.as_number
JOIN geo_names isp2 ON isp2.id = node2.as_number
WHERE channels.status = 1 AND (node1.as_number IN (?) OR node2.as_number IN (?))
ORDER BY short_id DESC
`;
const IPSIds = ISPId.split(',');
const [rows]: any = await DB.query(query, [IPSIds, IPSIds]);
const nodes = {};
const intISPIds: number[] = [];
for (const ispId of IPSIds) {
intISPIds.push(parseInt(ispId, 10));
}
for (const channel of rows) {
if (intISPIds.includes(channel.isp1ID)) {
nodes[channel.node1PublicKey] = true;
}
if (intISPIds.includes(channel.isp2ID)) {
nodes[channel.node2PublicKey] = true;
}
}
query = `
SELECT nodes.public_key, CAST(COALESCE(nodes.capacity, 0) as INT) as capacity, CAST(COALESCE(nodes.channels, 0) as INT) as channels, SELECT nodes.public_key, CAST(COALESCE(nodes.capacity, 0) as INT) as capacity, CAST(COALESCE(nodes.channels, 0) as INT) as channels,
nodes.alias, UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at, nodes.alias, UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at,
geo_names_city.names as city, geo_names_country.names as country, geo_names_city.names as city, geo_names_country.names as country,
@ -523,17 +565,18 @@ class NodesApi {
LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city' LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city'
LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code' LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division' LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division'
WHERE nodes.as_number IN (?) WHERE nodes.public_key IN (?)
ORDER BY capacity DESC ORDER BY capacity DESC
`; `;
const [rows]: any = await DB.query(query, [ISPId.split(',')]); const [rows2]: any = await DB.query(query, [Object.keys(nodes)]);
for (let i = 0; i < rows.length; ++i) { for (let i = 0; i < rows2.length; ++i) {
rows[i].country = JSON.parse(rows[i].country); rows2[i].country = JSON.parse(rows2[i].country);
rows[i].city = JSON.parse(rows[i].city); rows2[i].city = JSON.parse(rows2[i].city);
rows[i].subdivision = JSON.parse(rows[i].subdivision); rows2[i].subdivision = JSON.parse(rows2[i].subdivision);
} }
return rows; return rows2;
} catch (e) { } catch (e) {
logger.err(`Cannot get nodes for ISP id ${ISPId}. Reason: ${e instanceof Error ? e.message : e}`); logger.err(`Cannot get nodes for ISP id ${ISPId}. Reason: ${e instanceof Error ? e.message : e}`);
throw e; throw e;

View File

@ -7,6 +7,15 @@ import { Common } from '../../common';
* Convert a clightning "listnode" entry to a lnd node entry * Convert a clightning "listnode" entry to a lnd node entry
*/ */
export function convertNode(clNode: any): ILightningApi.Node { export function convertNode(clNode: any): ILightningApi.Node {
let custom_records: { [type: number]: string } | undefined = undefined;
if (clNode.option_will_fund) {
try {
custom_records = { '1': Buffer.from(clNode.option_will_fund.compact_lease || '', 'hex').toString('base64') };
} catch (e) {
logger.err(`Cannot decode option_will_fund compact_lease for ${clNode.nodeid}). Reason: ` + (e instanceof Error ? e.message : e));
custom_records = undefined;
}
}
return { return {
alias: clNode.alias ?? '', alias: clNode.alias ?? '',
color: `#${clNode.color ?? ''}`, color: `#${clNode.color ?? ''}`,
@ -23,6 +32,7 @@ export function convertNode(clNode: any): ILightningApi.Node {
}; };
}) ?? [], }) ?? [],
last_update: clNode?.last_timestamp ?? 0, last_update: clNode?.last_timestamp ?? 0,
custom_records
}; };
} }

View File

@ -49,6 +49,7 @@ export namespace ILightningApi {
}[]; }[];
color: string; color: string;
features: { [key: number]: Feature }; features: { [key: number]: Feature };
custom_records?: { [type: number]: string };
} }
export interface Info { export interface Info {

View File

@ -1,12 +1,17 @@
import logger from '../logger'; import logger from '../logger';
import { MempoolBlock, TransactionExtended, AuditTransaction, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor } from '../mempool.interfaces'; import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta } from '../mempool.interfaces';
import { Common } from './common'; import { Common } from './common';
import config from '../config'; import config from '../config';
import { PairingHeap } from '../utils/pairing-heap'; import { StaticPool } from 'node-worker-threads-pool';
import path from 'path';
class MempoolBlocks { class MempoolBlocks {
private mempoolBlocks: MempoolBlockWithTransactions[] = []; private mempoolBlocks: MempoolBlockWithTransactions[] = [];
private mempoolBlockDeltas: MempoolBlockDelta[] = []; private mempoolBlockDeltas: MempoolBlockDelta[] = [];
private makeTemplatesPool = new StaticPool({
size: 1,
task: path.resolve(__dirname, './tx-selection-worker.js'),
});
constructor() {} constructor() {}
@ -72,16 +77,15 @@ class MempoolBlocks {
const time = end - start; const time = end - start;
logger.debug('Mempool blocks calculated in ' + time / 1000 + ' seconds'); logger.debug('Mempool blocks calculated in ' + time / 1000 + ' seconds');
const { blocks, deltas } = this.calculateMempoolBlocks(memPoolArray, this.mempoolBlocks); const blocks = this.calculateMempoolBlocks(memPoolArray, this.mempoolBlocks);
const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, blocks);
this.mempoolBlocks = blocks; this.mempoolBlocks = blocks;
this.mempoolBlockDeltas = deltas; this.mempoolBlockDeltas = deltas;
} }
private calculateMempoolBlocks(transactionsSorted: TransactionExtended[], prevBlocks: MempoolBlockWithTransactions[]): private calculateMempoolBlocks(transactionsSorted: TransactionExtended[], prevBlocks: MempoolBlockWithTransactions[]): MempoolBlockWithTransactions[] {
{ blocks: MempoolBlockWithTransactions[], deltas: MempoolBlockDelta[] } {
const mempoolBlocks: MempoolBlockWithTransactions[] = []; const mempoolBlocks: MempoolBlockWithTransactions[] = [];
const mempoolBlockDeltas: MempoolBlockDelta[] = [];
let blockWeight = 0; let blockWeight = 0;
let blockSize = 0; let blockSize = 0;
let transactions: TransactionExtended[] = []; let transactions: TransactionExtended[] = [];
@ -102,7 +106,11 @@ class MempoolBlocks {
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length)); mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length));
} }
// Calculate change from previous block states return mempoolBlocks;
}
private calculateMempoolDeltas(prevBlocks: MempoolBlockWithTransactions[], mempoolBlocks: MempoolBlockWithTransactions[]): MempoolBlockDelta[] {
const mempoolBlockDeltas: MempoolBlockDelta[] = [];
for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) { for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) {
let added: TransactionStripped[] = []; let added: TransactionStripped[] = [];
let removed: string[] = []; let removed: string[] = [];
@ -135,284 +143,26 @@ class MempoolBlocks {
removed removed
}); });
} }
return mempoolBlockDeltas;
return {
blocks: mempoolBlocks,
deltas: mempoolBlockDeltas
};
} }
/* public async makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, blockLimit: number, weightLimit: number | null = null, condenseRest = false): Promise<void> {
* Build projected mempool blocks using an approximation of the transaction selection algorithm from Bitcoin Core const { mempool, blocks } = await this.makeTemplatesPool.exec({ mempool: newMempool, blockLimit, weightLimit, condenseRest });
* (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp) const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, blocks);
*
* blockLimit: number of blocks to build in total.
* weightLimit: maximum weight of transactions to consider using the selection algorithm.
* if weightLimit is significantly lower than the mempool size, results may start to diverge from getBlockTemplate
* condenseRest: whether to ignore excess transactions or append them to the final block.
*/
public makeBlockTemplates(mempool: { [txid: string]: TransactionExtended }, blockLimit: number, weightLimit: number | null = null, condenseRest = false): MempoolBlockWithTransactions[] {
const start = Date.now();
const auditPool: { [txid: string]: AuditTransaction } = {};
const mempoolArray: AuditTransaction[] = [];
const restOfArray: TransactionExtended[] = [];
let weight = 0; // copy CPFP info across to main thread's mempool
const maxWeight = weightLimit ? Math.max(4_000_000 * blockLimit, weightLimit) : Infinity; Object.keys(newMempool).forEach((txid) => {
// grab the top feerate txs up to maxWeight if (newMempool[txid] && mempool[txid]) {
Object.values(mempool).sort((a, b) => b.feePerVsize - a.feePerVsize).forEach(tx => { newMempool[txid].effectiveFeePerVsize = mempool[txid].effectiveFeePerVsize;
weight += tx.weight; newMempool[txid].ancestors = mempool[txid].ancestors;
if (weight >= maxWeight) { newMempool[txid].descendants = mempool[txid].descendants;
restOfArray.push(tx); newMempool[txid].bestDescendant = mempool[txid].bestDescendant;
return; newMempool[txid].cpfpChecked = mempool[txid].cpfpChecked;
}
// initializing everything up front helps V8 optimize property access later
auditPool[tx.txid] = {
txid: tx.txid,
fee: tx.fee,
size: tx.size,
weight: tx.weight,
feePerVsize: tx.feePerVsize,
vin: tx.vin,
relativesSet: false,
ancestorMap: new Map<string, AuditTransaction>(),
children: new Set<AuditTransaction>(),
ancestorFee: 0,
ancestorWeight: 0,
score: 0,
used: false,
modified: false,
modifiedNode: null,
}
mempoolArray.push(auditPool[tx.txid]);
})
// Build relatives graph & calculate ancestor scores
for (const tx of mempoolArray) {
if (!tx.relativesSet) {
this.setRelatives(tx, auditPool);
}
}
// Sort by descending ancestor score
mempoolArray.sort((a, b) => (b.score || 0) - (a.score || 0));
// Build blocks by greedily choosing the highest feerate package
// (i.e. the package rooted in the transaction with the best ancestor score)
const blocks: MempoolBlockWithTransactions[] = [];
let blockWeight = 4000;
let blockSize = 0;
let transactions: AuditTransaction[] = [];
const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => (a.score || 0) > (b.score || 0));
let overflow: AuditTransaction[] = [];
let failures = 0;
let top = 0;
while ((top < mempoolArray.length || !modified.isEmpty()) && (condenseRest || blocks.length < blockLimit)) {
// skip invalid transactions
while (top < mempoolArray.length && (mempoolArray[top].used || mempoolArray[top].modified)) {
top++;
}
// Select best next package
let nextTx;
const nextPoolTx = mempoolArray[top];
const nextModifiedTx = modified.peek();
if (nextPoolTx && (!nextModifiedTx || (nextPoolTx.score || 0) > (nextModifiedTx.score || 0))) {
nextTx = nextPoolTx;
top++;
} else {
modified.pop();
if (nextModifiedTx) {
nextTx = nextModifiedTx;
nextTx.modifiedNode = undefined;
}
}
if (nextTx && !nextTx?.used) {
// Check if the package fits into this block
if (blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS) {
blockWeight += nextTx.ancestorWeight;
const ancestors: AuditTransaction[] = Array.from(nextTx.ancestorMap.values());
// sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count)
const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx];
const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4);
sortedTxSet.forEach((ancestor, i, arr) => {
const mempoolTx = mempool[ancestor.txid];
if (ancestor && !ancestor?.used) {
ancestor.used = true;
// update original copy of this tx with effective fee rate & relatives data
mempoolTx.effectiveFeePerVsize = effectiveFeeRate;
mempoolTx.ancestors = (Array.from(ancestor.ancestorMap?.values()) as AuditTransaction[]).map((a) => {
return {
txid: a.txid,
fee: a.fee,
weight: a.weight,
}
})
if (i < arr.length - 1) {
mempoolTx.bestDescendant = {
txid: arr[arr.length - 1].txid,
fee: arr[arr.length - 1].fee,
weight: arr[arr.length - 1].weight,
};
}
transactions.push(ancestor);
blockSize += ancestor.size;
}
});
// remove these as valid package ancestors for any descendants remaining in the mempool
if (sortedTxSet.length) {
sortedTxSet.forEach(tx => {
this.updateDescendants(tx, auditPool, modified);
});
}
failures = 0;
} else {
// hold this package in an overflow list while we check for smaller options
overflow.push(nextTx);
failures++;
}
}
// this block is full
const exceededPackageTries = failures > 1000 && blockWeight > (config.MEMPOOL.BLOCK_WEIGHT_UNITS - 4000);
if (exceededPackageTries && (!condenseRest || blocks.length < blockLimit - 1)) {
// construct this block
if (transactions.length) {
blocks.push(this.dataToMempoolBlocks(transactions.map(t => mempool[t.txid]), blockSize, blockWeight, blocks.length));
}
// reset for the next block
transactions = [];
blockSize = 0;
blockWeight = 4000;
// 'overflow' packages didn't fit in this block, but are valid candidates for the next
for (const overflowTx of overflow.reverse()) {
if (overflowTx.modified) {
overflowTx.modifiedNode = modified.add(overflowTx);
} else {
top--;
mempoolArray[top] = overflowTx;
}
}
overflow = [];
}
}
if (condenseRest) {
// pack any leftover transactions into the last block
for (const tx of overflow) {
if (!tx || tx?.used) {
continue;
}
blockWeight += tx.weight;
blockSize += tx.size;
transactions.push(tx);
tx.used = true;
}
const blockTransactions = transactions.map(t => mempool[t.txid])
restOfArray.forEach(tx => {
blockWeight += tx.weight;
blockSize += tx.size;
blockTransactions.push(tx);
});
if (blockTransactions.length) {
blocks.push(this.dataToMempoolBlocks(blockTransactions, blockSize, blockWeight, blocks.length));
}
transactions = [];
} else if (transactions.length) {
blocks.push(this.dataToMempoolBlocks(transactions.map(t => mempool[t.txid]), blockSize, blockWeight, blocks.length));
}
const end = Date.now();
const time = end - start;
logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds');
return blocks;
}
// traverse in-mempool ancestors
// recursion unavoidable, but should be limited to depth < 25 by mempool policy
public setRelatives(
tx: AuditTransaction,
mempool: { [txid: string]: AuditTransaction },
): void {
for (const parent of tx.vin) {
const parentTx = mempool[parent.txid];
if (parentTx && !tx.ancestorMap!.has(parent.txid)) {
tx.ancestorMap.set(parent.txid, parentTx);
parentTx.children.add(tx);
// visit each node only once
if (!parentTx.relativesSet) {
this.setRelatives(parentTx, mempool);
}
parentTx.ancestorMap.forEach((ancestor) => {
tx.ancestorMap.set(ancestor.txid, ancestor);
});
}
};
tx.ancestorFee = tx.fee || 0;
tx.ancestorWeight = tx.weight || 0;
tx.ancestorMap.forEach((ancestor) => {
tx.ancestorFee += ancestor.fee;
tx.ancestorWeight += ancestor.weight;
});
tx.score = tx.ancestorFee / (tx.ancestorWeight || 1);
tx.relativesSet = true;
}
// iterate over remaining descendants, removing the root as a valid ancestor & updating the ancestor score
// avoids recursion to limit call stack depth
private updateDescendants(
rootTx: AuditTransaction,
mempool: { [txid: string]: AuditTransaction },
modified: PairingHeap<AuditTransaction>,
): void {
const descendantSet: Set<AuditTransaction> = new Set();
// stack of nodes left to visit
const descendants: AuditTransaction[] = [];
let descendantTx;
let ancestorIndex;
let tmpScore;
rootTx.children.forEach(childTx => {
if (!descendantSet.has(childTx)) {
descendants.push(childTx);
descendantSet.add(childTx);
} }
}); });
while (descendants.length) {
descendantTx = descendants.pop();
if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) {
// remove tx as ancestor
descendantTx.ancestorMap.delete(rootTx.txid);
descendantTx.ancestorFee -= rootTx.fee;
descendantTx.ancestorWeight -= rootTx.weight;
tmpScore = descendantTx.score;
descendantTx.score = descendantTx.ancestorFee / descendantTx.ancestorWeight;
if (!descendantTx.modifiedNode) { this.mempoolBlocks = blocks;
descendantTx.modified = true; this.mempoolBlockDeltas = deltas;
descendantTx.modifiedNode = modified.add(descendantTx);
} else {
// rebalance modified heap if score has changed
if (descendantTx.score < tmpScore) {
modified.decreasePriority(descendantTx.modifiedNode);
} else if (descendantTx.score > tmpScore) {
modified.increasePriority(descendantTx.modifiedNode);
}
}
// add this node's children to the stack
descendantTx.children.forEach(childTx => {
// visit each node only once
if (!descendantSet.has(childTx)) {
descendants.push(childTx);
descendantSet.add(childTx);
}
});
}
}
} }
private dataToMempoolBlocks(transactions: TransactionExtended[], private dataToMempoolBlocks(transactions: TransactionExtended[],

View File

@ -20,6 +20,8 @@ class Mempool {
maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 }; maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 };
private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[], private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
deletedTransactions: TransactionExtended[]) => void) | undefined; deletedTransactions: TransactionExtended[]) => void) | undefined;
private asyncMempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
deletedTransactions: TransactionExtended[]) => void) | undefined;
private txPerSecondArray: number[] = []; private txPerSecondArray: number[] = [];
private txPerSecond: number = 0; private txPerSecond: number = 0;
@ -63,6 +65,11 @@ class Mempool {
this.mempoolChangedCallback = fn; this.mempoolChangedCallback = fn;
} }
public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: TransactionExtended; },
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => Promise<void>) {
this.asyncMempoolChangedCallback = fn;
}
public getMempool(): { [txid: string]: TransactionExtended } { public getMempool(): { [txid: string]: TransactionExtended } {
return this.mempoolCache; return this.mempoolCache;
} }
@ -72,6 +79,9 @@ class Mempool {
if (this.mempoolChangedCallback) { if (this.mempoolChangedCallback) {
this.mempoolChangedCallback(this.mempoolCache, [], []); this.mempoolChangedCallback(this.mempoolCache, [], []);
} }
if (this.asyncMempoolChangedCallback) {
this.asyncMempoolChangedCallback(this.mempoolCache, [], []);
}
} }
public async $updateMemPoolInfo() { public async $updateMemPoolInfo() {
@ -103,12 +113,11 @@ class Mempool {
return txTimes; return txTimes;
} }
public async $updateMempool() { public async $updateMempool(): Promise<void> {
logger.debug('Updating mempool'); logger.debug(`Updating mempool...`);
const start = new Date().getTime(); const start = new Date().getTime();
let hasChange: boolean = false; let hasChange: boolean = false;
const currentMempoolSize = Object.keys(this.mempoolCache).length; const currentMempoolSize = Object.keys(this.mempoolCache).length;
let txCount = 0;
const transactions = await bitcoinApi.$getRawMempool(); const transactions = await bitcoinApi.$getRawMempool();
const diff = transactions.length - currentMempoolSize; const diff = transactions.length - currentMempoolSize;
const newTransactions: TransactionExtended[] = []; const newTransactions: TransactionExtended[] = [];
@ -124,7 +133,6 @@ class Mempool {
try { try {
const transaction = await transactionUtils.$getTransactionExtended(txid); const transaction = await transactionUtils.$getTransactionExtended(txid);
this.mempoolCache[txid] = transaction; this.mempoolCache[txid] = transaction;
txCount++;
if (this.inSync) { if (this.inSync) {
this.txPerSecondArray.push(new Date().getTime()); this.txPerSecondArray.push(new Date().getTime());
this.vBytesPerSecondArray.push({ this.vBytesPerSecondArray.push({
@ -133,14 +141,9 @@ class Mempool {
}); });
} }
hasChange = true; hasChange = true;
if (diff > 0) {
logger.debug('Fetched transaction ' + txCount + ' / ' + diff);
} else {
logger.debug('Fetched transaction ' + txCount);
}
newTransactions.push(transaction); newTransactions.push(transaction);
} catch (e) { } catch (e) {
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e)); logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e));
} }
} }
@ -194,11 +197,13 @@ class Mempool {
if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) { if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) {
this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions); this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
} }
if (this.asyncMempoolChangedCallback && (hasChange || deletedTransactions.length)) {
await this.asyncMempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
}
const end = new Date().getTime(); const end = new Date().getTime();
const time = end - start; const time = end - start;
logger.debug(`New mempool size: ${Object.keys(this.mempoolCache).length} Change: ${diff}`); logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`);
logger.debug('Mempool updated in ' + time / 1000 + ' seconds');
} }
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) { public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) {

View File

@ -1,6 +1,7 @@
import { Application, Request, Response } from 'express'; import { Application, Request, Response } from 'express';
import config from "../../config"; import config from "../../config";
import logger from '../../logger'; import logger from '../../logger';
import audits from '../audit';
import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository'; import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository';
import BlocksRepository from '../../repositories/BlocksRepository'; import BlocksRepository from '../../repositories/BlocksRepository';
import DifficultyAdjustmentsRepository from '../../repositories/DifficultyAdjustmentsRepository'; import DifficultyAdjustmentsRepository from '../../repositories/DifficultyAdjustmentsRepository';
@ -26,7 +27,11 @@ class MiningRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments/:interval', this.$getDifficultyAdjustments) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments/:interval', this.$getDifficultyAdjustments)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/predictions/:interval', this.$getHistoricalBlockPrediction) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/predictions/:interval', this.$getHistoricalBlockPrediction)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/scores', this.$getBlockAuditScores)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/scores/:height', this.$getBlockAuditScores)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/score/:hash', this.$getBlockAuditScore)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/:hash', this.$getBlockAudit) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/:hash', this.$getBlockAudit)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/timestamp/:timestamp', this.$getHeightFromTimestamp)
; ;
} }
@ -252,6 +257,55 @@ class MiningRoutes {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);
} }
} }
private async $getHeightFromTimestamp(req: Request, res: Response) {
try {
const timestamp = parseInt(req.params.timestamp, 10);
// This will prevent people from entering milliseconds etc.
// Block timestamps are allowed to be up to 2 hours off, so 24 hours
// will never put the maximum value before the most recent block
const nowPlus1day = Math.floor(Date.now() / 1000) + 60 * 60 * 24;
// Prevent non-integers that are not seconds
if (!/^[1-9][0-9]*$/.test(req.params.timestamp) || timestamp > nowPlus1day) {
throw new Error(`Invalid timestamp, value must be Unix seconds`);
}
const result = await BlocksRepository.$getBlockHeightFromTimestamp(
timestamp,
);
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
res.json(result);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getBlockAuditScores(req: Request, res: Response) {
try {
let height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10);
if (height == null) {
height = await BlocksRepository.$mostRecentBlockHeight();
}
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(await BlocksAuditsRepository.$getBlockAuditScores(height, height - 15));
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
public async $getBlockAuditScore(req: Request, res: Response) {
try {
const audit = await BlocksAuditsRepository.$getBlockAuditScore(req.params.hash);
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
res.json(audit || 'null');
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
} }
export default new MiningRoutes(); export default new MiningRoutes();

View File

@ -14,10 +14,10 @@ interface Pool {
class PoolsParser { class PoolsParser {
miningPools: any[] = []; miningPools: any[] = [];
unknownPool: any = { unknownPool: any = {
'name': "Unknown", 'name': 'Unknown',
'link': "https://learnmeabitcoin.com/technical/coinbase-transaction", 'link': 'https://learnmeabitcoin.com/technical/coinbase-transaction',
'regexes': "[]", 'regexes': '[]',
'addresses': "[]", 'addresses': '[]',
'slug': 'unknown' 'slug': 'unknown'
}; };
slugWarnFlag = false; slugWarnFlag = false;
@ -25,7 +25,7 @@ class PoolsParser {
/** /**
* Parse the pools.json file, consolidate the data and dump it into the database * Parse the pools.json file, consolidate the data and dump it into the database
*/ */
public async migratePoolsJson(poolsJson: object) { public async migratePoolsJson(poolsJson: object): Promise<void> {
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) { if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) {
return; return;
} }
@ -81,6 +81,7 @@ class PoolsParser {
// Finally, we generate the final consolidated pools data // Finally, we generate the final consolidated pools data
const finalPoolDataAdd: Pool[] = []; const finalPoolDataAdd: Pool[] = [];
const finalPoolDataUpdate: Pool[] = []; const finalPoolDataUpdate: Pool[] = [];
const finalPoolDataRename: Pool[] = [];
for (let i = 0; i < poolNames.length; ++i) { for (let i = 0; i < poolNames.length; ++i) {
let allAddresses: string[] = []; let allAddresses: string[] = [];
let allRegexes: string[] = []; let allRegexes: string[] = [];
@ -127,8 +128,26 @@ class PoolsParser {
finalPoolDataUpdate.push(poolObj); finalPoolDataUpdate.push(poolObj);
} }
} else { } else {
logger.debug(`Add '${finalPoolName}' mining pool`); // Double check that if we're not just renaming a pool (same address same regex)
finalPoolDataAdd.push(poolObj); const [poolToRename]: any[] = await DB.query(`
SELECT * FROM pools
WHERE addresses = ? OR regexes = ?`,
[JSON.stringify(poolObj.addresses), JSON.stringify(poolObj.regexes)]
);
if (poolToRename && poolToRename.length > 0) {
// We're actually renaming an existing pool
finalPoolDataRename.push({
'name': poolObj.name,
'link': poolObj.link,
'regexes': allRegexes,
'addresses': allAddresses,
'slug': slug
});
logger.debug(`Rename '${poolToRename[0].name}' mining pool to ${poolObj.name}`);
} else {
logger.debug(`Add '${finalPoolName}' mining pool`);
finalPoolDataAdd.push(poolObj);
}
} }
this.miningPools.push({ this.miningPools.push({
@ -145,7 +164,9 @@ class PoolsParser {
return; return;
} }
if (finalPoolDataAdd.length > 0 || finalPoolDataUpdate.length > 0) { if (finalPoolDataAdd.length > 0 || finalPoolDataUpdate.length > 0 ||
finalPoolDataRename.length > 0
) {
logger.debug(`Update pools table now`); logger.debug(`Update pools table now`);
// Add new mining pools into the database // Add new mining pools into the database
@ -169,8 +190,22 @@ class PoolsParser {
;`); ;`);
} }
// Rename mining pools
const renameQueries: string[] = [];
for (let i = 0; i < finalPoolDataRename.length; ++i) {
renameQueries.push(`
UPDATE pools
SET name='${finalPoolDataRename[i].name}', link='${finalPoolDataRename[i].link}',
slug='${finalPoolDataRename[i].slug}'
WHERE regexes='${JSON.stringify(finalPoolDataRename[i].regexes)}'
AND addresses='${JSON.stringify(finalPoolDataRename[i].addresses)}'
;`);
}
try { try {
await this.$deleteBlocskToReindex(finalPoolDataUpdate); if (finalPoolDataAdd.length > 0 || updateQueries.length > 0) {
await this.$deleteBlocskToReindex(finalPoolDataUpdate);
}
if (finalPoolDataAdd.length > 0) { if (finalPoolDataAdd.length > 0) {
await DB.query({ sql: queryAdd, timeout: 120000 }); await DB.query({ sql: queryAdd, timeout: 120000 });
@ -178,6 +213,9 @@ class PoolsParser {
for (const query of updateQueries) { for (const query of updateQueries) {
await DB.query({ sql: query, timeout: 120000 }); await DB.query({ sql: query, timeout: 120000 });
} }
for (const query of renameQueries) {
await DB.query({ sql: query, timeout: 120000 });
}
await this.insertUnknownPool(); await this.insertUnknownPool();
logger.info('Mining pools.json import completed'); logger.info('Mining pools.json import completed');
} catch (e) { } catch (e) {

View File

@ -0,0 +1,338 @@
import config from '../config';
import logger from '../logger';
import { TransactionExtended, MempoolBlockWithTransactions, AuditTransaction } from '../mempool.interfaces';
import { PairingHeap } from '../utils/pairing-heap';
import { Common } from './common';
import { parentPort } from 'worker_threads';
if (parentPort) {
parentPort.on('message', (params: { mempool: { [txid: string]: TransactionExtended }, blockLimit: number, weightLimit: number | null, condenseRest: boolean}) => {
const { mempool, blocks } = makeBlockTemplates(params);
// return the result to main thread.
if (parentPort) {
parentPort.postMessage({ mempool, blocks });
}
});
}
/*
* Build projected mempool blocks using an approximation of the transaction selection algorithm from Bitcoin Core
* (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp)
*
* blockLimit: number of blocks to build in total.
* weightLimit: maximum weight of transactions to consider using the selection algorithm.
* if weightLimit is significantly lower than the mempool size, results may start to diverge from getBlockTemplate
* condenseRest: whether to ignore excess transactions or append them to the final block.
*/
function makeBlockTemplates({ mempool, blockLimit, weightLimit, condenseRest }: { mempool: { [txid: string]: TransactionExtended }, blockLimit: number, weightLimit?: number | null, condenseRest?: boolean | null })
: { mempool: { [txid: string]: TransactionExtended }, blocks: MempoolBlockWithTransactions[] } {
const start = Date.now();
const auditPool: { [txid: string]: AuditTransaction } = {};
const mempoolArray: AuditTransaction[] = [];
const restOfArray: TransactionExtended[] = [];
let weight = 0;
const maxWeight = weightLimit ? Math.max(4_000_000 * blockLimit, weightLimit) : Infinity;
// grab the top feerate txs up to maxWeight
Object.values(mempool).sort((a, b) => b.feePerVsize - a.feePerVsize).forEach(tx => {
weight += tx.weight;
if (weight >= maxWeight) {
restOfArray.push(tx);
return;
}
// initializing everything up front helps V8 optimize property access later
auditPool[tx.txid] = {
txid: tx.txid,
fee: tx.fee,
size: tx.size,
weight: tx.weight,
feePerVsize: tx.feePerVsize,
vin: tx.vin,
relativesSet: false,
ancestorMap: new Map<string, AuditTransaction>(),
children: new Set<AuditTransaction>(),
ancestorFee: 0,
ancestorWeight: 0,
score: 0,
used: false,
modified: false,
modifiedNode: null,
};
mempoolArray.push(auditPool[tx.txid]);
});
// Build relatives graph & calculate ancestor scores
for (const tx of mempoolArray) {
if (!tx.relativesSet) {
setRelatives(tx, auditPool);
}
}
// Sort by descending ancestor score
mempoolArray.sort((a, b) => (b.score || 0) - (a.score || 0));
// Build blocks by greedily choosing the highest feerate package
// (i.e. the package rooted in the transaction with the best ancestor score)
const blocks: MempoolBlockWithTransactions[] = [];
let blockWeight = 4000;
let blockSize = 0;
let transactions: AuditTransaction[] = [];
const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => (a.score || 0) > (b.score || 0));
let overflow: AuditTransaction[] = [];
let failures = 0;
let top = 0;
while ((top < mempoolArray.length || !modified.isEmpty()) && (condenseRest || blocks.length < blockLimit)) {
// skip invalid transactions
while (top < mempoolArray.length && (mempoolArray[top].used || mempoolArray[top].modified)) {
top++;
}
// Select best next package
let nextTx;
const nextPoolTx = mempoolArray[top];
const nextModifiedTx = modified.peek();
if (nextPoolTx && (!nextModifiedTx || (nextPoolTx.score || 0) > (nextModifiedTx.score || 0))) {
nextTx = nextPoolTx;
top++;
} else {
modified.pop();
if (nextModifiedTx) {
nextTx = nextModifiedTx;
nextTx.modifiedNode = undefined;
}
}
if (nextTx && !nextTx?.used) {
// Check if the package fits into this block
if (blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS) {
blockWeight += nextTx.ancestorWeight;
const ancestors: AuditTransaction[] = Array.from(nextTx.ancestorMap.values());
const descendants: AuditTransaction[] = [];
// sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count)
const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx];
const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4);
while (sortedTxSet.length) {
const ancestor = sortedTxSet.pop();
const mempoolTx = mempool[ancestor.txid];
if (ancestor && !ancestor?.used) {
ancestor.used = true;
// update original copy of this tx with effective fee rate & relatives data
mempoolTx.effectiveFeePerVsize = effectiveFeeRate;
mempoolTx.ancestors = sortedTxSet.map((a) => {
return {
txid: a.txid,
fee: a.fee,
weight: a.weight,
};
}).reverse();
mempoolTx.descendants = descendants.map((a) => {
return {
txid: a.txid,
fee: a.fee,
weight: a.weight,
};
});
descendants.push(ancestor);
mempoolTx.cpfpChecked = true;
transactions.push(ancestor);
blockSize += ancestor.size;
}
}
// remove these as valid package ancestors for any descendants remaining in the mempool
if (sortedTxSet.length) {
sortedTxSet.forEach(tx => {
updateDescendants(tx, auditPool, modified);
});
}
failures = 0;
} else {
// hold this package in an overflow list while we check for smaller options
overflow.push(nextTx);
failures++;
}
}
// this block is full
const exceededPackageTries = failures > 1000 && blockWeight > (config.MEMPOOL.BLOCK_WEIGHT_UNITS - 4000);
const queueEmpty = top >= mempoolArray.length && modified.isEmpty();
if ((exceededPackageTries || queueEmpty) && (!condenseRest || blocks.length < blockLimit - 1)) {
// construct this block
if (transactions.length) {
blocks.push(dataToMempoolBlocks(transactions.map(t => mempool[t.txid]), blockSize, blockWeight, blocks.length));
}
// reset for the next block
transactions = [];
blockSize = 0;
blockWeight = 4000;
// 'overflow' packages didn't fit in this block, but are valid candidates for the next
for (const overflowTx of overflow.reverse()) {
if (overflowTx.modified) {
overflowTx.modifiedNode = modified.add(overflowTx);
} else {
top--;
mempoolArray[top] = overflowTx;
}
}
overflow = [];
}
}
if (condenseRest) {
// pack any leftover transactions into the last block
for (const tx of overflow) {
if (!tx || tx?.used) {
continue;
}
blockWeight += tx.weight;
blockSize += tx.size;
const mempoolTx = mempool[tx.txid];
// update original copy of this tx with effective fee rate & relatives data
mempoolTx.effectiveFeePerVsize = tx.score;
mempoolTx.ancestors = (Array.from(tx.ancestorMap?.values()) as AuditTransaction[]).map((a) => {
return {
txid: a.txid,
fee: a.fee,
weight: a.weight,
};
});
mempoolTx.bestDescendant = null;
mempoolTx.cpfpChecked = true;
transactions.push(tx);
tx.used = true;
}
const blockTransactions = transactions.map(t => mempool[t.txid]);
restOfArray.forEach(tx => {
blockWeight += tx.weight;
blockSize += tx.size;
tx.effectiveFeePerVsize = tx.feePerVsize;
tx.cpfpChecked = false;
tx.ancestors = [];
tx.bestDescendant = null;
blockTransactions.push(tx);
});
if (blockTransactions.length) {
blocks.push(dataToMempoolBlocks(blockTransactions, blockSize, blockWeight, blocks.length));
}
transactions = [];
} else if (transactions.length) {
blocks.push(dataToMempoolBlocks(transactions.map(t => mempool[t.txid]), blockSize, blockWeight, blocks.length));
}
const end = Date.now();
const time = end - start;
logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds');
return {
mempool,
blocks
};
}
// traverse in-mempool ancestors
// recursion unavoidable, but should be limited to depth < 25 by mempool policy
function setRelatives(
tx: AuditTransaction,
mempool: { [txid: string]: AuditTransaction },
): void {
for (const parent of tx.vin) {
const parentTx = mempool[parent.txid];
if (parentTx && !tx.ancestorMap?.has(parent.txid)) {
tx.ancestorMap.set(parent.txid, parentTx);
parentTx.children.add(tx);
// visit each node only once
if (!parentTx.relativesSet) {
setRelatives(parentTx, mempool);
}
parentTx.ancestorMap.forEach((ancestor) => {
tx.ancestorMap.set(ancestor.txid, ancestor);
});
}
};
tx.ancestorFee = tx.fee || 0;
tx.ancestorWeight = tx.weight || 0;
tx.ancestorMap.forEach((ancestor) => {
tx.ancestorFee += ancestor.fee;
tx.ancestorWeight += ancestor.weight;
});
tx.score = tx.ancestorFee / ((tx.ancestorWeight / 4) || 1);
tx.relativesSet = true;
}
// iterate over remaining descendants, removing the root as a valid ancestor & updating the ancestor score
// avoids recursion to limit call stack depth
function updateDescendants(
rootTx: AuditTransaction,
mempool: { [txid: string]: AuditTransaction },
modified: PairingHeap<AuditTransaction>,
): void {
const descendantSet: Set<AuditTransaction> = new Set();
// stack of nodes left to visit
const descendants: AuditTransaction[] = [];
let descendantTx;
let tmpScore;
rootTx.children.forEach(childTx => {
if (!descendantSet.has(childTx)) {
descendants.push(childTx);
descendantSet.add(childTx);
}
});
while (descendants.length) {
descendantTx = descendants.pop();
if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) {
// remove tx as ancestor
descendantTx.ancestorMap.delete(rootTx.txid);
descendantTx.ancestorFee -= rootTx.fee;
descendantTx.ancestorWeight -= rootTx.weight;
tmpScore = descendantTx.score;
descendantTx.score = descendantTx.ancestorFee / (descendantTx.ancestorWeight / 4);
if (!descendantTx.modifiedNode) {
descendantTx.modified = true;
descendantTx.modifiedNode = modified.add(descendantTx);
} else {
// rebalance modified heap if score has changed
if (descendantTx.score < tmpScore) {
modified.decreasePriority(descendantTx.modifiedNode);
} else if (descendantTx.score > tmpScore) {
modified.increasePriority(descendantTx.modifiedNode);
}
}
// add this node's children to the stack
descendantTx.children.forEach(childTx => {
// visit each node only once
if (!descendantSet.has(childTx)) {
descendants.push(childTx);
descendantSet.add(childTx);
}
});
}
}
}
function dataToMempoolBlocks(transactions: TransactionExtended[],
blockSize: number, blockWeight: number, blocksIndex: number): MempoolBlockWithTransactions {
let rangeLength = 4;
if (blocksIndex === 0) {
rangeLength = 8;
}
if (transactions.length > 4000) {
rangeLength = 6;
} else if (transactions.length > 10000) {
rangeLength = 8;
}
return {
blockSize: blockSize,
blockVSize: blockWeight / 4,
nTx: transactions.length,
totalFees: transactions.reduce((acc, cur) => acc + cur.fee, 0),
medianFee: Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE),
feeRange: Common.getFeesInRange(transactions, rangeLength),
transactionIds: transactions.map((tx) => tx.txid),
transactions: transactions.map((tx) => Common.stripTransaction(tx)),
};
}

View File

@ -244,13 +244,18 @@ class WebsocketHandler {
}); });
} }
handleMempoolChange(newMempool: { [txid: string]: TransactionExtended }, async handleMempoolChange(newMempool: { [txid: string]: TransactionExtended },
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) { newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]): Promise<void> {
if (!this.wss) { if (!this.wss) {
throw new Error('WebSocket.Server is not set'); throw new Error('WebSocket.Server is not set');
} }
mempoolBlocks.updateMempoolBlocks(newMempool); if (config.MEMPOOL.ADVANCED_TRANSACTION_SELECTION) {
await mempoolBlocks.makeBlockTemplates(newMempool, 8, null, true);
}
else {
mempoolBlocks.updateMempoolBlocks(newMempool);
}
const mBlocks = mempoolBlocks.getMempoolBlocks(); const mBlocks = mempoolBlocks.getMempoolBlocks();
const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
const mempoolInfo = memPool.getMempoolInfo(); const mempoolInfo = memPool.getMempoolInfo();
@ -406,21 +411,24 @@ class WebsocketHandler {
}); });
} }
handleNewBlock(block: BlockExtended, txIds: string[], transactions: TransactionExtended[]): void { async handleNewBlock(block: BlockExtended, txIds: string[], transactions: TransactionExtended[]): Promise<void> {
if (!this.wss) { if (!this.wss) {
throw new Error('WebSocket.Server is not set'); throw new Error('WebSocket.Server is not set');
} }
let mBlocks: undefined | MempoolBlock[];
let mBlockDeltas: undefined | MempoolBlockDelta[];
let matchRate;
const _memPool = memPool.getMempool(); const _memPool = memPool.getMempool();
let matchRate;
if (Common.indexingEnabled()) { if (config.MEMPOOL.ADVANCED_TRANSACTION_SELECTION) {
const mempoolCopy = cloneMempool(_memPool); await mempoolBlocks.makeBlockTemplates(_memPool, 2);
const projectedBlocks = mempoolBlocks.makeBlockTemplates(mempoolCopy, 2); } else {
mempoolBlocks.updateMempoolBlocks(_memPool);
}
const { censored, added, score } = Audit.auditBlock(transactions, projectedBlocks, mempoolCopy); if (Common.indexingEnabled() && memPool.isInSync()) {
const projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions();
const { censored, added, fresh, score } = Audit.auditBlock(transactions, projectedBlocks, _memPool);
matchRate = Math.round(score * 100 * 100) / 100; matchRate = Math.round(score * 100 * 100) / 100;
const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => { const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => {
@ -446,6 +454,7 @@ class WebsocketHandler {
hash: block.id, hash: block.id,
addedTxs: added, addedTxs: added,
missingTxs: censored, missingTxs: censored,
freshTxs: fresh,
matchRate: matchRate, matchRate: matchRate,
}); });
@ -459,9 +468,13 @@ class WebsocketHandler {
delete _memPool[txId]; delete _memPool[txId];
} }
mempoolBlocks.updateMempoolBlocks(_memPool); if (config.MEMPOOL.ADVANCED_TRANSACTION_SELECTION) {
mBlocks = mempoolBlocks.getMempoolBlocks(); await mempoolBlocks.makeBlockTemplates(_memPool, 2);
mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); } else {
mempoolBlocks.updateMempoolBlocks(_memPool);
}
const mBlocks = mempoolBlocks.getMempoolBlocks();
const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
const da = difficultyAdjustment.getDifficultyAdjustment(); const da = difficultyAdjustment.getDifficultyAdjustment();
const fees = feeApi.getRecommendedFee(); const fees = feeApi.getRecommendedFee();
@ -569,14 +582,4 @@ class WebsocketHandler {
} }
} }
function cloneMempool(mempool: { [txid: string]: TransactionExtended }): { [txid: string]: TransactionExtended } {
const cloned = {};
Object.keys(mempool).forEach(id => {
cloned[id] = {
...mempool[id]
};
});
return cloned;
}
export default new WebsocketHandler(); export default new WebsocketHandler();

View File

@ -4,6 +4,7 @@ const configFromFile = require(
interface IConfig { interface IConfig {
MEMPOOL: { MEMPOOL: {
ENABLED: boolean;
NETWORK: 'mainnet' | 'testnet' | 'signet' | 'liquid' | 'liquidtestnet'; NETWORK: 'mainnet' | 'testnet' | 'signet' | 'liquid' | 'liquidtestnet';
BACKEND: 'esplora' | 'electrum' | 'none'; BACKEND: 'esplora' | 'electrum' | 'none';
HTTP_PORT: number; HTTP_PORT: number;
@ -28,6 +29,8 @@ interface IConfig {
AUTOMATIC_BLOCK_REINDEXING: boolean; AUTOMATIC_BLOCK_REINDEXING: boolean;
POOLS_JSON_URL: string, POOLS_JSON_URL: string,
POOLS_JSON_TREE_URL: string, POOLS_JSON_TREE_URL: string,
ADVANCED_TRANSACTION_SELECTION: boolean;
TRANSACTION_INDEXING: boolean;
}; };
ESPLORA: { ESPLORA: {
REST_API_URL: string; REST_API_URL: string;
@ -39,6 +42,7 @@ interface IConfig {
STATS_REFRESH_INTERVAL: number; STATS_REFRESH_INTERVAL: number;
GRAPH_REFRESH_INTERVAL: number; GRAPH_REFRESH_INTERVAL: number;
LOGGER_UPDATE_INTERVAL: number; LOGGER_UPDATE_INTERVAL: number;
FORENSICS_INTERVAL: number;
}; };
LND: { LND: {
TLS_CERT_PATH: string; TLS_CERT_PATH: string;
@ -119,6 +123,7 @@ interface IConfig {
const defaults: IConfig = { const defaults: IConfig = {
'MEMPOOL': { 'MEMPOOL': {
'ENABLED': true,
'NETWORK': 'mainnet', 'NETWORK': 'mainnet',
'BACKEND': 'none', 'BACKEND': 'none',
'HTTP_PORT': 8999, 'HTTP_PORT': 8999,
@ -143,6 +148,8 @@ const defaults: IConfig = {
'AUTOMATIC_BLOCK_REINDEXING': false, 'AUTOMATIC_BLOCK_REINDEXING': false,
'POOLS_JSON_URL': 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json', 'POOLS_JSON_URL': 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json',
'POOLS_JSON_TREE_URL': 'https://api.github.com/repos/mempool/mining-pools/git/trees/master', 'POOLS_JSON_TREE_URL': 'https://api.github.com/repos/mempool/mining-pools/git/trees/master',
'ADVANCED_TRANSACTION_SELECTION': false,
'TRANSACTION_INDEXING': false,
}, },
'ESPLORA': { 'ESPLORA': {
'REST_API_URL': 'http://127.0.0.1:3000', 'REST_API_URL': 'http://127.0.0.1:3000',
@ -195,6 +202,7 @@ const defaults: IConfig = {
'STATS_REFRESH_INTERVAL': 600, 'STATS_REFRESH_INTERVAL': 600,
'GRAPH_REFRESH_INTERVAL': 600, 'GRAPH_REFRESH_INTERVAL': 600,
'LOGGER_UPDATE_INTERVAL': 30, 'LOGGER_UPDATE_INTERVAL': 30,
'FORENSICS_INTERVAL': 43200,
}, },
'LND': { 'LND': {
'TLS_CERT_PATH': '', 'TLS_CERT_PATH': '',
@ -224,11 +232,11 @@ const defaults: IConfig = {
'BISQ_URL': 'https://bisq.markets/api', 'BISQ_URL': 'https://bisq.markets/api',
'BISQ_ONION': 'http://bisqmktse2cabavbr2xjq7xw3h6g5ottemo5rolfcwt6aly6tp5fdryd.onion/api' 'BISQ_ONION': 'http://bisqmktse2cabavbr2xjq7xw3h6g5ottemo5rolfcwt6aly6tp5fdryd.onion/api'
}, },
"MAXMIND": { 'MAXMIND': {
'ENABLED': false, 'ENABLED': false,
"GEOLITE2_CITY": "/usr/local/share/GeoIP/GeoLite2-City.mmdb", 'GEOLITE2_CITY': '/usr/local/share/GeoIP/GeoLite2-City.mmdb',
"GEOLITE2_ASN": "/usr/local/share/GeoIP/GeoLite2-ASN.mmdb", 'GEOLITE2_ASN': '/usr/local/share/GeoIP/GeoLite2-ASN.mmdb',
"GEOIP2_ISP": "/usr/local/share/GeoIP/GeoIP2-ISP.mmdb" 'GEOIP2_ISP': '/usr/local/share/GeoIP/GeoIP2-ISP.mmdb'
}, },
}; };

View File

@ -1,4 +1,4 @@
import express from "express"; import express from 'express';
import { Application, Request, Response, NextFunction } from 'express'; import { Application, Request, Response, NextFunction } from 'express';
import * as http from 'http'; import * as http from 'http';
import * as WebSocket from 'ws'; import * as WebSocket from 'ws';
@ -34,7 +34,8 @@ import miningRoutes from './api/mining/mining-routes';
import bisqRoutes from './api/bisq/bisq.routes'; import bisqRoutes from './api/bisq/bisq.routes';
import liquidRoutes from './api/liquid/liquid.routes'; import liquidRoutes from './api/liquid/liquid.routes';
import bitcoinRoutes from './api/bitcoin/bitcoin.routes'; import bitcoinRoutes from './api/bitcoin/bitcoin.routes';
import fundingTxFetcher from "./tasks/lightning/sync-tasks/funding-tx-fetcher"; import fundingTxFetcher from './tasks/lightning/sync-tasks/funding-tx-fetcher';
import forensicsService from './tasks/lightning/forensics.service';
class Server { class Server {
private wss: WebSocket.Server | undefined; private wss: WebSocket.Server | undefined;
@ -74,7 +75,7 @@ class Server {
} }
} }
async startServer(worker = false) { async startServer(worker = false): Promise<void> {
logger.notice(`Starting Mempool Server${worker ? ' (worker)' : ''}... (${backendInfo.getShortCommitHash()})`); logger.notice(`Starting Mempool Server${worker ? ' (worker)' : ''}... (${backendInfo.getShortCommitHash()})`);
this.app this.app
@ -83,7 +84,7 @@ class Server {
next(); next();
}) })
.use(express.urlencoded({ extended: true })) .use(express.urlencoded({ extended: true }))
.use(express.text()) .use(express.text({ type: ['text/plain', 'application/base64'] }))
; ;
this.server = http.createServer(this.app); this.server = http.createServer(this.app);
@ -92,7 +93,9 @@ class Server {
this.setUpWebsocketHandling(); this.setUpWebsocketHandling();
await syncAssets.syncAssets$(); await syncAssets.syncAssets$();
diskCache.loadMempoolCache(); if (config.MEMPOOL.ENABLED) {
diskCache.loadMempoolCache();
}
if (config.DATABASE.ENABLED) { if (config.DATABASE.ENABLED) {
await DB.checkDbConnection(); await DB.checkDbConnection();
@ -127,7 +130,10 @@ class Server {
fiatConversion.startService(); fiatConversion.startService();
this.setUpHttpApiRoutes(); this.setUpHttpApiRoutes();
this.runMainUpdateLoop();
if (config.MEMPOOL.ENABLED) {
this.runMainUpdateLoop();
}
if (config.BISQ.ENABLED) { if (config.BISQ.ENABLED) {
bisq.startBisqService(); bisq.startBisqService();
@ -149,7 +155,7 @@ class Server {
}); });
} }
async runMainUpdateLoop() { async runMainUpdateLoop(): Promise<void> {
try { try {
try { try {
await memPool.$updateMemPoolInfo(); await memPool.$updateMemPoolInfo();
@ -183,10 +189,11 @@ class Server {
} }
} }
async $runLightningBackend() { async $runLightningBackend(): Promise<void> {
try { try {
await fundingTxFetcher.$init(); await fundingTxFetcher.$init();
await networkSyncService.$startService(); await networkSyncService.$startService();
await forensicsService.$startService();
await lightningStatsUpdater.$startService(); await lightningStatsUpdater.$startService();
} catch(e) { } catch(e) {
logger.err(`Nodejs lightning backend crashed. Restarting in 1 minute. Reason: ${(e instanceof Error ? e.message : e)}`); logger.err(`Nodejs lightning backend crashed. Restarting in 1 minute. Reason: ${(e instanceof Error ? e.message : e)}`);
@ -195,7 +202,7 @@ class Server {
}; };
} }
setUpWebsocketHandling() { setUpWebsocketHandling(): void {
if (this.wss) { if (this.wss) {
websocketHandler.setWebsocketServer(this.wss); websocketHandler.setWebsocketServer(this.wss);
} }
@ -209,19 +216,21 @@ class Server {
}); });
} }
websocketHandler.setupConnectionHandling(); websocketHandler.setupConnectionHandling();
statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler)); if (config.MEMPOOL.ENABLED) {
blocks.setNewBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler)); statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler));
memPool.setMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler)); memPool.setAsyncMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler));
blocks.setNewAsyncBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler));
}
fiatConversion.setProgressChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler)); fiatConversion.setProgressChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler));
loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler)); loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler));
} }
setUpHttpApiRoutes() { setUpHttpApiRoutes(): void {
bitcoinRoutes.initRoutes(this.app); bitcoinRoutes.initRoutes(this.app);
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED) { if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && config.MEMPOOL.ENABLED) {
statisticsRoutes.initRoutes(this.app); statisticsRoutes.initRoutes(this.app);
} }
if (Common.indexingEnabled()) { if (Common.indexingEnabled() && config.MEMPOOL.ENABLED) {
miningRoutes.initRoutes(this.app); miningRoutes.initRoutes(this.app);
} }
if (config.BISQ.ENABLED) { if (config.BISQ.ENABLED) {
@ -238,4 +247,4 @@ class Server {
} }
} }
const server = new Server(); ((): Server => new Server())();

View File

@ -77,6 +77,7 @@ class Indexer {
await mining.$generateNetworkHashrateHistory(); await mining.$generateNetworkHashrateHistory();
await mining.$generatePoolHashrateHistory(); await mining.$generatePoolHashrateHistory();
await blocks.$generateBlocksSummariesDatabase(); await blocks.$generateBlocksSummariesDatabase();
await blocks.$generateCPFPDatabase();
} catch (e) { } catch (e) {
this.indexerRunning = false; this.indexerRunning = false;
logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e)); logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e));

View File

@ -28,10 +28,16 @@ export interface BlockAudit {
height: number, height: number,
hash: string, hash: string,
missingTxs: string[], missingTxs: string[],
freshTxs: string[],
addedTxs: string[], addedTxs: string[],
matchRate: number, matchRate: number,
} }
export interface AuditScore {
hash: string,
matchRate?: number,
}
export interface MempoolBlock { export interface MempoolBlock {
blockSize: number; blockSize: number;
blockVSize: number; blockVSize: number;
@ -66,6 +72,7 @@ export interface TransactionExtended extends IEsploraApi.Transaction {
firstSeen?: number; firstSeen?: number;
effectiveFeePerVsize: number; effectiveFeePerVsize: number;
ancestors?: Ancestor[]; ancestors?: Ancestor[];
descendants?: Ancestor[];
bestDescendant?: BestDescendant | null; bestDescendant?: BestDescendant | null;
cpfpChecked?: boolean; cpfpChecked?: boolean;
deleteAfter?: number; deleteAfter?: number;
@ -113,7 +120,9 @@ interface BestDescendant {
export interface CpfpInfo { export interface CpfpInfo {
ancestors: Ancestor[]; ancestors: Ancestor[];
bestDescendant: BestDescendant | null; bestDescendant?: BestDescendant | null;
descendants?: Ancestor[];
effectiveFeePerVsize?: number;
} }
export interface TransactionStripped { export interface TransactionStripped {

View File

@ -1,13 +1,14 @@
import blocks from '../api/blocks';
import DB from '../database'; import DB from '../database';
import logger from '../logger'; import logger from '../logger';
import { BlockAudit } from '../mempool.interfaces'; import { BlockAudit, AuditScore } from '../mempool.interfaces';
class BlocksAuditRepositories { class BlocksAuditRepositories {
public async $saveAudit(audit: BlockAudit): Promise<void> { public async $saveAudit(audit: BlockAudit): Promise<void> {
try { try {
await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, match_rate) await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, fresh_txs, match_rate)
VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs), VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs),
JSON.stringify(audit.addedTxs), audit.matchRate]); JSON.stringify(audit.addedTxs), JSON.stringify(audit.freshTxs), audit.matchRate]);
} catch (e: any) { } catch (e: any) {
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
logger.debug(`Cannot save block audit for block ${audit.hash} because it has already been indexed, ignoring`); logger.debug(`Cannot save block audit for block ${audit.hash} because it has already been indexed, ignoring`);
@ -51,7 +52,7 @@ class BlocksAuditRepositories {
const [rows]: any[] = await DB.query( const [rows]: any[] = await DB.query(
`SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size, `SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size,
blocks.weight, blocks.tx_count, blocks.weight, blocks.tx_count,
transactions, template, missing_txs as missingTxs, added_txs as addedTxs, match_rate as matchRate transactions, template, missing_txs as missingTxs, added_txs as addedTxs, fresh_txs as freshTxs, match_rate as matchRate
FROM blocks_audits FROM blocks_audits
JOIN blocks ON blocks.hash = blocks_audits.hash JOIN blocks ON blocks.hash = blocks_audits.hash
JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash
@ -61,21 +62,25 @@ class BlocksAuditRepositories {
if (rows.length) { if (rows.length) {
rows[0].missingTxs = JSON.parse(rows[0].missingTxs); rows[0].missingTxs = JSON.parse(rows[0].missingTxs);
rows[0].addedTxs = JSON.parse(rows[0].addedTxs); rows[0].addedTxs = JSON.parse(rows[0].addedTxs);
rows[0].freshTxs = JSON.parse(rows[0].freshTxs);
rows[0].transactions = JSON.parse(rows[0].transactions); rows[0].transactions = JSON.parse(rows[0].transactions);
rows[0].template = JSON.parse(rows[0].template); rows[0].template = JSON.parse(rows[0].template);
}
return rows[0]; if (rows[0].transactions.length) {
return rows[0];
}
}
return null;
} catch (e: any) { } catch (e: any) {
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e)); logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));
throw e; throw e;
} }
} }
public async $getShortBlockAudit(hash: string): Promise<any> { public async $getBlockAuditScore(hash: string): Promise<AuditScore> {
try { try {
const [rows]: any[] = await DB.query( const [rows]: any[] = await DB.query(
`SELECT hash as id, match_rate as matchRate `SELECT hash, match_rate as matchRate
FROM blocks_audits FROM blocks_audits
WHERE blocks_audits.hash = "${hash}" WHERE blocks_audits.hash = "${hash}"
`); `);
@ -85,6 +90,20 @@ class BlocksAuditRepositories {
throw e; throw e;
} }
} }
public async $getBlockAuditScores(maxHeight: number, minHeight: number): Promise<AuditScore[]> {
try {
const [rows]: any[] = await DB.query(
`SELECT hash, match_rate as matchRate
FROM blocks_audits
WHERE blocks_audits.height BETWEEN ? AND ?
`, [minHeight, maxHeight]);
return rows;
} catch (e: any) {
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
} }
export default new BlocksAuditRepositories(); export default new BlocksAuditRepositories();

View File

@ -392,6 +392,36 @@ class BlocksRepository {
} }
} }
/**
* Get the first block at or directly after a given timestamp
* @param timestamp number unix time in seconds
* @returns The height and timestamp of a block (timestamp might vary from given timestamp)
*/
public async $getBlockHeightFromTimestamp(
timestamp: number,
): Promise<{ height: number; hash: string; timestamp: number }> {
try {
// Get first block at or after the given timestamp
const query = `SELECT height, hash, blockTimestamp as timestamp FROM blocks
WHERE blockTimestamp <= FROM_UNIXTIME(?)
ORDER BY blockTimestamp DESC
LIMIT 1`;
const params = [timestamp];
const [rows]: any[][] = await DB.query(query, params);
if (rows.length === 0) {
throw new Error(`No block was found before timestamp ${timestamp}`);
}
return rows[0];
} catch (e) {
logger.err(
'Cannot get block height from timestamp from the db. Reason: ' +
(e instanceof Error ? e.message : e),
);
throw e;
}
}
/** /**
* Return blocks height * Return blocks height
*/ */
@ -632,6 +662,23 @@ class BlocksRepository {
} }
} }
/**
* Get a list of blocks that have not had CPFP data indexed
*/
public async $getCPFPUnindexedBlocks(): Promise<any[]> {
try {
const [rows]: any = await DB.query(`SELECT height, hash FROM blocks WHERE cpfp_indexed = 0 ORDER BY height DESC`);
return rows;
} catch (e) {
logger.err('Cannot fetch CPFP unindexed blocks. Reason: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $setCPFPIndexed(hash: string): Promise<void> {
await DB.query(`UPDATE blocks SET cpfp_indexed = 1 WHERE hash = ?`, [hash]);
}
/** /**
* Return the oldest block from a consecutive chain of block from the most recent one * Return the oldest block from a consecutive chain of block from the most recent one
*/ */

View File

@ -0,0 +1,43 @@
import DB from '../database';
import logger from '../logger';
import { Ancestor } from '../mempool.interfaces';
class CpfpRepository {
public async $saveCluster(height: number, txs: Ancestor[], effectiveFeePerVsize: number): Promise<void> {
try {
const txsJson = JSON.stringify(txs);
await DB.query(
`
INSERT INTO cpfp_clusters(root, height, txs, fee_rate)
VALUE (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
height = ?,
txs = ?,
fee_rate = ?
`,
[txs[0].txid, height, txsJson, effectiveFeePerVsize, height, txsJson, effectiveFeePerVsize, height]
);
} catch (e: any) {
logger.err(`Cannot save cpfp cluster into db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $deleteClustersFrom(height: number): Promise<void> {
logger.info(`Delete newer cpfp clusters from height ${height} from the database`);
try {
await DB.query(
`
DELETE from cpfp_clusters
WHERE height >= ?
`,
[height]
);
} catch (e: any) {
logger.err(`Cannot delete cpfp clusters from db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
}
export default new CpfpRepository();

View File

@ -0,0 +1,67 @@
import { ResultSetHeader, RowDataPacket } from 'mysql2';
import DB from '../database';
import logger from '../logger';
export interface NodeRecord {
publicKey: string; // node public key
type: number; // TLV extension record type
payload: string; // base64 record payload
}
class NodesRecordsRepository {
public async $saveRecord(record: NodeRecord): Promise<void> {
try {
const payloadBytes = Buffer.from(record.payload, 'base64');
await DB.query(`
INSERT INTO nodes_records(public_key, type, payload)
VALUE (?, ?, ?)
ON DUPLICATE KEY UPDATE
payload = ?
`, [record.publicKey, record.type, payloadBytes, payloadBytes]);
} catch (e: any) {
if (e.errno !== 1062) { // ER_DUP_ENTRY - Not an issue, just ignore this
logger.err(`Cannot save node record (${[record.publicKey, record.type, record.payload]}) into db. Reason: ` + (e instanceof Error ? e.message : e));
// We don't throw, not a critical issue if we miss some nodes records
}
}
}
public async $getRecordTypes(publicKey: string): Promise<any> {
try {
const query = `
SELECT type FROM nodes_records
WHERE public_key = ?
`;
const [rows] = await DB.query<RowDataPacket[][]>(query, [publicKey]);
return rows.map(row => row['type']);
} catch (e) {
logger.err(`Cannot retrieve custom records for ${publicKey} from db. Reason: ` + (e instanceof Error ? e.message : e));
return [];
}
}
public async $deleteUnusedRecords(publicKey: string, recordTypes: number[]): Promise<number> {
try {
let query;
if (recordTypes.length) {
query = `
DELETE FROM nodes_records
WHERE public_key = ?
AND type NOT IN (${recordTypes.map(type => `${type}`).join(',')})
`;
} else {
query = `
DELETE FROM nodes_records
WHERE public_key = ?
`;
}
const [result] = await DB.query<ResultSetHeader>(query, [publicKey]);
return result.affectedRows;
} catch (e) {
logger.err(`Cannot delete unused custom records for ${publicKey} from db. Reason: ` + (e instanceof Error ? e.message : e));
return 0;
}
}
}
export default new NodesRecordsRepository();

View File

@ -0,0 +1,77 @@
import DB from '../database';
import logger from '../logger';
import { Ancestor, CpfpInfo } from '../mempool.interfaces';
interface CpfpSummary {
txid: string;
cluster: string;
root: string;
txs: Ancestor[];
height: number;
fee_rate: number;
}
class TransactionRepository {
public async $setCluster(txid: string, cluster: string): Promise<void> {
try {
await DB.query(
`
INSERT INTO transactions
(
txid,
cluster
)
VALUE (?, ?)
ON DUPLICATE KEY UPDATE
cluster = ?
;`,
[txid, cluster, cluster]
);
} catch (e: any) {
logger.err(`Cannot save transaction cpfp cluster into db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $getCpfpInfo(txid: string): Promise<CpfpInfo | void> {
try {
let query = `
SELECT *
FROM transactions
LEFT JOIN cpfp_clusters AS cluster ON cluster.root = transactions.cluster
WHERE transactions.txid = ?
`;
const [rows]: any = await DB.query(query, [txid]);
if (rows.length) {
rows[0].txs = JSON.parse(rows[0].txs) as Ancestor[];
return this.convertCpfp(rows[0]);
}
} catch (e) {
logger.err('Cannot get transaction cpfp info from db. Reason: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
private convertCpfp(cpfp: CpfpSummary): CpfpInfo {
const descendants: Ancestor[] = [];
const ancestors: Ancestor[] = [];
let matched = false;
for (const tx of cpfp.txs) {
if (tx.txid === cpfp.txid) {
matched = true;
} else if (!matched) {
descendants.push(tx);
} else {
ancestors.push(tx);
}
}
return {
descendants,
ancestors,
effectiveFeePerVsize: cpfp.fee_rate
};
}
}
export default new TransactionRepository();

View File

@ -0,0 +1,225 @@
import DB from '../../database';
import logger from '../../logger';
import channelsApi from '../../api/explorer/channels.api';
import bitcoinApi from '../../api/bitcoin/bitcoin-api-factory';
import config from '../../config';
import { IEsploraApi } from '../../api/bitcoin/esplora-api.interface';
import { Common } from '../../api/common';
const throttleDelay = 20; //ms
class ForensicsService {
loggerTimer = 0;
closedChannelsScanBlock = 0;
txCache: { [txid: string]: IEsploraApi.Transaction } = {};
constructor() {}
public async $startService(): Promise<void> {
logger.info('Starting lightning network forensics service');
this.loggerTimer = new Date().getTime() / 1000;
await this.$runTasks();
}
private async $runTasks(): Promise<void> {
try {
logger.info(`Running forensics scans`);
if (config.MEMPOOL.BACKEND === 'esplora') {
await this.$runClosedChannelsForensics(false);
}
} catch (e) {
logger.err('ForensicsService.$runTasks() error: ' + (e instanceof Error ? e.message : e));
}
setTimeout(() => { this.$runTasks(); }, 1000 * config.LIGHTNING.FORENSICS_INTERVAL);
}
/*
1. Mutually closed
2. Forced closed
3. Forced closed with penalty
outputs contain revocation script? yes force close w/ penalty = 3
no
outputs contain other lightning script?
no yes
sequence starts with 0x80
and force close = 2
locktime starts with 0x20?
no
mutual close = 1
*/
public async $runClosedChannelsForensics(onlyNewChannels: boolean = false): Promise<void> {
if (config.MEMPOOL.BACKEND !== 'esplora') {
return;
}
let progress = 0;
try {
logger.info(`Started running closed channel forensics...`);
let channels;
if (onlyNewChannels) {
channels = await channelsApi.$getClosedChannelsWithoutReason();
} else {
channels = await channelsApi.$getUnresolvedClosedChannels();
}
for (const channel of channels) {
let reason = 0;
let resolvedForceClose = false;
// Only Esplora backend can retrieve spent transaction outputs
const cached: string[] = [];
try {
let outspends: IEsploraApi.Outspend[] | undefined;
try {
outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id);
await Common.sleep$(throttleDelay);
} catch (e) {
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`);
continue;
}
const lightningScriptReasons: number[] = [];
for (const outspend of outspends) {
if (outspend.spent && outspend.txid) {
let spendingTx: IEsploraApi.Transaction | undefined = this.txCache[outspend.txid];
if (!spendingTx) {
try {
spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid);
await Common.sleep$(throttleDelay);
this.txCache[outspend.txid] = spendingTx;
} catch (e) {
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + outspend.txid}. Reason ${e instanceof Error ? e.message : e}`);
continue;
}
}
cached.push(spendingTx.txid);
const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
lightningScriptReasons.push(lightningScript);
}
}
const filteredReasons = lightningScriptReasons.filter((r) => r !== 1);
if (filteredReasons.length) {
if (filteredReasons.some((r) => r === 2 || r === 4)) {
reason = 3;
} else {
reason = 2;
resolvedForceClose = true;
}
} else {
/*
We can detect a commitment transaction (force close) by reading Sequence and Locktime
https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction
*/
let closingTx: IEsploraApi.Transaction | undefined = this.txCache[channel.closing_transaction_id];
if (!closingTx) {
try {
closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id);
await Common.sleep$(throttleDelay);
this.txCache[channel.closing_transaction_id] = closingTx;
} catch (e) {
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id}. Reason ${e instanceof Error ? e.message : e}`);
continue;
}
}
cached.push(closingTx.txid);
const sequenceHex: string = closingTx.vin[0].sequence.toString(16);
const locktimeHex: string = closingTx.locktime.toString(16);
if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') {
reason = 2; // Here we can't be sure if it's a penalty or not
} else {
reason = 1;
}
}
if (reason) {
logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.');
await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]);
if (reason === 2 && resolvedForceClose) {
await DB.query(`UPDATE channels SET closing_resolved = ? WHERE id = ?`, [true, channel.id]);
}
if (reason !== 2 || resolvedForceClose) {
cached.forEach(txid => {
delete this.txCache[txid];
});
}
}
} catch (e) {
logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`);
}
++progress;
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
if (elapsedSeconds > 10) {
logger.info(`Updating channel closed channel forensics ${progress}/${channels.length}`);
this.loggerTimer = new Date().getTime() / 1000;
}
}
logger.info(`Closed channels forensics scan complete.`);
} catch (e) {
logger.err('$runClosedChannelsForensics() error: ' + (e instanceof Error ? e.message : e));
}
}
private findLightningScript(vin: IEsploraApi.Vin): number {
const topElement = vin.witness[vin.witness.length - 2];
if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(vin.inner_witnessscript_asm)) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs
if (topElement === '01') {
// top element is '01' to get in the revocation path
// 'Revoked Lightning Force Close';
// Penalty force closed
return 2;
} else {
// top element is '', this is a delayed to_local output
// 'Lightning Force Close';
return 3;
}
} else if (
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) ||
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm)
) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs
// https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs
if (topElement.length === 66) {
// top element is a public key
// 'Revoked Lightning HTLC'; Penalty force closed
return 4;
} else if (topElement) {
// top element is a preimage
// 'Lightning HTLC';
return 5;
} else {
// top element is '' to get in the expiry of the script
// 'Expired Lightning HTLC';
return 6;
}
} else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(vin.inner_witnessscript_asm)) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors
if (topElement) {
// top element is a signature
// 'Lightning Anchor';
return 7;
} else {
// top element is '', it has been swept after 16 blocks
// 'Swept Lightning Anchor';
return 8;
}
}
return 1;
}
}
export default new ForensicsService();

View File

@ -13,6 +13,8 @@ import fundingTxFetcher from './sync-tasks/funding-tx-fetcher';
import NodesSocketsRepository from '../../repositories/NodesSocketsRepository'; import NodesSocketsRepository from '../../repositories/NodesSocketsRepository';
import { Common } from '../../api/common'; import { Common } from '../../api/common';
import blocks from '../../api/blocks'; import blocks from '../../api/blocks';
import NodeRecordsRepository from '../../repositories/NodeRecordsRepository';
import forensicsService from './forensics.service';
class NetworkSyncService { class NetworkSyncService {
loggerTimer = 0; loggerTimer = 0;
@ -45,8 +47,10 @@ class NetworkSyncService {
await this.$lookUpCreationDateFromChain(); await this.$lookUpCreationDateFromChain();
await this.$updateNodeFirstSeen(); await this.$updateNodeFirstSeen();
await this.$scanForClosedChannels(); await this.$scanForClosedChannels();
if (config.MEMPOOL.BACKEND === 'esplora') { if (config.MEMPOOL.BACKEND === 'esplora') {
await this.$runClosedChannelsForensics(); // run forensics on new channels only
await forensicsService.$runClosedChannelsForensics(true);
} }
} catch (e) { } catch (e) {
@ -63,6 +67,7 @@ class NetworkSyncService {
let progress = 0; let progress = 0;
let deletedSockets = 0; let deletedSockets = 0;
let deletedRecords = 0;
const graphNodesPubkeys: string[] = []; const graphNodesPubkeys: string[] = [];
for (const node of nodes) { for (const node of nodes) {
const latestUpdated = await channelsApi.$getLatestChannelUpdateForNode(node.pub_key); const latestUpdated = await channelsApi.$getLatestChannelUpdateForNode(node.pub_key);
@ -84,8 +89,23 @@ class NetworkSyncService {
addresses.push(socket.addr); addresses.push(socket.addr);
} }
deletedSockets += await NodesSocketsRepository.$deleteUnusedSockets(node.pub_key, addresses); deletedSockets += await NodesSocketsRepository.$deleteUnusedSockets(node.pub_key, addresses);
const oldRecordTypes = await NodeRecordsRepository.$getRecordTypes(node.pub_key);
const customRecordTypes: number[] = [];
for (const [type, payload] of Object.entries(node.custom_records || {})) {
const numericalType = parseInt(type);
await NodeRecordsRepository.$saveRecord({
publicKey: node.pub_key,
type: numericalType,
payload,
});
customRecordTypes.push(numericalType);
}
if (oldRecordTypes.reduce((changed, type) => changed || customRecordTypes.indexOf(type) === -1, false)) {
deletedRecords += await NodeRecordsRepository.$deleteUnusedRecords(node.pub_key, customRecordTypes);
}
} }
logger.info(`${progress} nodes updated. ${deletedSockets} sockets deleted`); logger.info(`${progress} nodes updated. ${deletedSockets} sockets deleted. ${deletedRecords} custom records deleted.`);
// If a channel if not present in the graph, mark it as inactive // If a channel if not present in the graph, mark it as inactive
await nodesApi.$setNodesInactive(graphNodesPubkeys); await nodesApi.$setNodesInactive(graphNodesPubkeys);
@ -284,161 +304,6 @@ class NetworkSyncService {
logger.err('$scanForClosedChannels() error: ' + (e instanceof Error ? e.message : e)); logger.err('$scanForClosedChannels() error: ' + (e instanceof Error ? e.message : e));
} }
} }
/*
1. Mutually closed
2. Forced closed
3. Forced closed with penalty
outputs contain revocation script? yes force close w/ penalty = 3
no
outputs contain other lightning script?
no yes
sequence starts with 0x80
and force close = 2
locktime starts with 0x20?
no
mutual close = 1
*/
private async $runClosedChannelsForensics(): Promise<void> {
if (!config.ESPLORA.REST_API_URL) {
return;
}
let progress = 0;
try {
logger.info(`Started running closed channel forensics...`);
const channels = await channelsApi.$getClosedChannelsWithoutReason();
for (const channel of channels) {
let reason = 0;
// Only Esplora backend can retrieve spent transaction outputs
try {
let outspends: IEsploraApi.Outspend[] | undefined;
try {
outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id);
} catch (e) {
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`);
continue;
}
const lightningScriptReasons: number[] = [];
for (const outspend of outspends) {
if (outspend.spent && outspend.txid) {
let spendingTx: IEsploraApi.Transaction | undefined;
try {
spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid);
} catch (e) {
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + outspend.txid}. Reason ${e instanceof Error ? e.message : e}`);
continue;
}
const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
lightningScriptReasons.push(lightningScript);
}
}
const filteredReasons = lightningScriptReasons.filter((r) => r !== 1);
if (filteredReasons.length) {
if (filteredReasons.some((r) => r === 2 || r === 4)) {
reason = 3;
} else {
reason = 2;
}
} else {
/*
We can detect a commitment transaction (force close) by reading Sequence and Locktime
https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction
*/
let closingTx: IEsploraApi.Transaction | undefined;
try {
closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id);
} catch (e) {
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id}. Reason ${e instanceof Error ? e.message : e}`);
continue;
}
const sequenceHex: string = closingTx.vin[0].sequence.toString(16);
const locktimeHex: string = closingTx.locktime.toString(16);
if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') {
reason = 2; // Here we can't be sure if it's a penalty or not
} else {
reason = 1;
}
}
if (reason) {
logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.');
await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]);
}
} catch (e) {
logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`);
}
++progress;
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
if (elapsedSeconds > 10) {
logger.info(`Updating channel closed channel forensics ${progress}/${channels.length}`);
this.loggerTimer = new Date().getTime() / 1000;
}
}
logger.info(`Closed channels forensics scan complete.`);
} catch (e) {
logger.err('$runClosedChannelsForensics() error: ' + (e instanceof Error ? e.message : e));
}
}
private findLightningScript(vin: IEsploraApi.Vin): number {
const topElement = vin.witness[vin.witness.length - 2];
if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(vin.inner_witnessscript_asm)) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs
if (topElement === '01') {
// top element is '01' to get in the revocation path
// 'Revoked Lightning Force Close';
// Penalty force closed
return 2;
} else {
// top element is '', this is a delayed to_local output
// 'Lightning Force Close';
return 3;
}
} else if (
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) ||
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm)
) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs
// https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs
if (topElement.length === 66) {
// top element is a public key
// 'Revoked Lightning HTLC'; Penalty force closed
return 4;
} else if (topElement) {
// top element is a preimage
// 'Lightning HTLC';
return 5;
} else {
// top element is '' to get in the expiry of the script
// 'Expired Lightning HTLC';
return 6;
}
} else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(vin.inner_witnessscript_asm)) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors
if (topElement) {
// top element is a signature
// 'Lightning Anchor';
return 7;
} else {
// top element is '', it has been swept after 16 blocks
// 'Swept Lightning Anchor';
return 8;
}
}
return 1;
}
} }
export default new NetworkSyncService(); export default new NetworkSyncService();

View File

@ -6,6 +6,7 @@ import DB from '../../../database';
import logger from '../../../logger'; import logger from '../../../logger';
import { ResultSetHeader } from 'mysql2'; import { ResultSetHeader } from 'mysql2';
import * as IPCheck from '../../../utils/ipcheck.js'; import * as IPCheck from '../../../utils/ipcheck.js';
import { Reader } from 'mmdb-lib';
export async function $lookupNodeLocation(): Promise<void> { export async function $lookupNodeLocation(): Promise<void> {
let loggerTimer = new Date().getTime() / 1000; let loggerTimer = new Date().getTime() / 1000;
@ -18,7 +19,10 @@ export async function $lookupNodeLocation(): Promise<void> {
const nodes = await nodesApi.$getAllNodes(); const nodes = await nodesApi.$getAllNodes();
const lookupCity = await maxmind.open<CityResponse>(config.MAXMIND.GEOLITE2_CITY); const lookupCity = await maxmind.open<CityResponse>(config.MAXMIND.GEOLITE2_CITY);
const lookupAsn = await maxmind.open<AsnResponse>(config.MAXMIND.GEOLITE2_ASN); const lookupAsn = await maxmind.open<AsnResponse>(config.MAXMIND.GEOLITE2_ASN);
const lookupIsp = await maxmind.open<IspResponse>(config.MAXMIND.GEOIP2_ISP); let lookupIsp: Reader<IspResponse> | null = null;
try {
lookupIsp = await maxmind.open<IspResponse>(config.MAXMIND.GEOIP2_ISP);
} catch (e) { }
for (const node of nodes) { for (const node of nodes) {
const sockets: string[] = node.sockets.split(','); const sockets: string[] = node.sockets.split(',');
@ -29,7 +33,10 @@ export async function $lookupNodeLocation(): Promise<void> {
if (hasClearnet && ip !== '127.0.1.1' && ip !== '127.0.0.1') { if (hasClearnet && ip !== '127.0.1.1' && ip !== '127.0.0.1') {
const city = lookupCity.get(ip); const city = lookupCity.get(ip);
const asn = lookupAsn.get(ip); const asn = lookupAsn.get(ip);
const isp = lookupIsp.get(ip); let isp: IspResponse | null = null;
if (lookupIsp) {
isp = lookupIsp.get(ip);
}
let asOverwrite: any | undefined; let asOverwrite: any | undefined;
if (asn && (IPCheck.match(ip, '170.75.160.0/20') || IPCheck.match(ip, '172.81.176.0/21'))) { if (asn && (IPCheck.match(ip, '170.75.160.0/20') || IPCheck.match(ip, '172.81.176.0/21'))) {

View File

@ -1,43 +0,0 @@
import { query } from '../../utils/axios-query';
import priceUpdater, { PriceFeed, PriceHistory } from '../price-updater';
class FtxApi implements PriceFeed {
public name: string = 'FTX';
public currencies: string[] = ['USD', 'BRZ', 'EUR', 'JPY', 'AUD'];
public url: string = 'https://ftx.com/api/markets/BTC/';
public urlHist: string = 'https://ftx.com/api/markets/BTC/{CURRENCY}/candles?resolution={GRANULARITY}';
constructor() {
}
public async $fetchPrice(currency): Promise<number> {
const response = await query(this.url + currency);
return response ? parseInt(response['result']['last'], 10) : -1;
}
public async $fetchRecentPrice(currencies: string[], type: 'hour' | 'day'): Promise<PriceHistory> {
const priceHistory: PriceHistory = {};
for (const currency of currencies) {
if (this.currencies.includes(currency) === false) {
continue;
}
const response = await query(this.urlHist.replace('{GRANULARITY}', type === 'hour' ? '3600' : '86400').replace('{CURRENCY}', currency));
const pricesRaw = response ? response['result'] : [];
for (const price of pricesRaw as any[]) {
const time = Math.round(price['time'] / 1000);
if (priceHistory[time] === undefined) {
priceHistory[time] = priceUpdater.getEmptyPricesObj();
}
priceHistory[time][currency] = price['close'];
}
}
return priceHistory;
}
}
export default FtxApi;

View File

@ -1,13 +1,11 @@
import * as fs from 'fs'; import * as fs from 'fs';
import path from "path"; import path from "path";
import { Common } from '../api/common';
import config from '../config'; import config from '../config';
import logger from '../logger'; import logger from '../logger';
import PricesRepository from '../repositories/PricesRepository'; import PricesRepository from '../repositories/PricesRepository';
import BitfinexApi from './price-feeds/bitfinex-api'; import BitfinexApi from './price-feeds/bitfinex-api';
import BitflyerApi from './price-feeds/bitflyer-api'; import BitflyerApi from './price-feeds/bitflyer-api';
import CoinbaseApi from './price-feeds/coinbase-api'; import CoinbaseApi from './price-feeds/coinbase-api';
import FtxApi from './price-feeds/ftx-api';
import GeminiApi from './price-feeds/gemini-api'; import GeminiApi from './price-feeds/gemini-api';
import KrakenApi from './price-feeds/kraken-api'; import KrakenApi from './price-feeds/kraken-api';
@ -48,7 +46,6 @@ class PriceUpdater {
this.latestPrices = this.getEmptyPricesObj(); this.latestPrices = this.getEmptyPricesObj();
this.feeds.push(new BitflyerApi()); // Does not have historical endpoint this.feeds.push(new BitflyerApi()); // Does not have historical endpoint
this.feeds.push(new FtxApi());
this.feeds.push(new KrakenApi()); this.feeds.push(new KrakenApi());
this.feeds.push(new CoinbaseApi()); this.feeds.push(new CoinbaseApi());
this.feeds.push(new BitfinexApi()); this.feeds.push(new BitfinexApi());

View File

@ -89,6 +89,7 @@ Below we list all settings from `mempool-config.json` and the corresponding over
"MEMPOOL": { "MEMPOOL": {
"NETWORK": "mainnet", "NETWORK": "mainnet",
"BACKEND": "electrum", "BACKEND": "electrum",
"ENABLED": true,
"HTTP_PORT": 8999, "HTTP_PORT": 8999,
"SPAWN_CLUSTER_PROCS": 0, "SPAWN_CLUSTER_PROCS": 0,
"API_URL_PREFIX": "/api/v1/", "API_URL_PREFIX": "/api/v1/",

View File

@ -2,6 +2,7 @@
"MEMPOOL": { "MEMPOOL": {
"NETWORK": "__MEMPOOL_NETWORK__", "NETWORK": "__MEMPOOL_NETWORK__",
"BACKEND": "__MEMPOOL_BACKEND__", "BACKEND": "__MEMPOOL_BACKEND__",
"ENABLED": __MEMPOOL_ENABLED__,
"HTTP_PORT": __MEMPOOL_HTTP_PORT__, "HTTP_PORT": __MEMPOOL_HTTP_PORT__,
"SPAWN_CLUSTER_PROCS": __MEMPOOL_SPAWN_CLUSTER_PROCS__, "SPAWN_CLUSTER_PROCS": __MEMPOOL_SPAWN_CLUSTER_PROCS__,
"API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__", "API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__",

View File

@ -3,6 +3,7 @@
# MEMPOOL # MEMPOOL
__MEMPOOL_NETWORK__=${MEMPOOL_NETWORK:=mainnet} __MEMPOOL_NETWORK__=${MEMPOOL_NETWORK:=mainnet}
__MEMPOOL_BACKEND__=${MEMPOOL_BACKEND:=electrum} __MEMPOOL_BACKEND__=${MEMPOOL_BACKEND:=electrum}
__MEMPOOL_ENABLED__=${MEMPOOL_ENABLED:=true}
__MEMPOOL_HTTP_PORT__=${BACKEND_HTTP_PORT:=8999} __MEMPOOL_HTTP_PORT__=${BACKEND_HTTP_PORT:=8999}
__MEMPOOL_SPAWN_CLUSTER_PROCS__=${MEMPOOL_SPAWN_CLUSTER_PROCS:=0} __MEMPOOL_SPAWN_CLUSTER_PROCS__=${MEMPOOL_SPAWN_CLUSTER_PROCS:=0}
__MEMPOOL_API_URL_PREFIX__=${MEMPOOL_API_URL_PREFIX:=/api/v1/} __MEMPOOL_API_URL_PREFIX__=${MEMPOOL_API_URL_PREFIX:=/api/v1/}
@ -111,6 +112,7 @@ mkdir -p "${__MEMPOOL_CACHE_DIR__}"
sed -i "s/__MEMPOOL_NETWORK__/${__MEMPOOL_NETWORK__}/g" mempool-config.json sed -i "s/__MEMPOOL_NETWORK__/${__MEMPOOL_NETWORK__}/g" mempool-config.json
sed -i "s/__MEMPOOL_BACKEND__/${__MEMPOOL_BACKEND__}/g" mempool-config.json sed -i "s/__MEMPOOL_BACKEND__/${__MEMPOOL_BACKEND__}/g" mempool-config.json
sed -i "s/__MEMPOOL_ENABLED__/${__MEMPOOL_ENABLED__}/g" mempool-config.json
sed -i "s/__MEMPOOL_HTTP_PORT__/${__MEMPOOL_HTTP_PORT__}/g" mempool-config.json sed -i "s/__MEMPOOL_HTTP_PORT__/${__MEMPOOL_HTTP_PORT__}/g" mempool-config.json
sed -i "s/__MEMPOOL_SPAWN_CLUSTER_PROCS__/${__MEMPOOL_SPAWN_CLUSTER_PROCS__}/g" mempool-config.json sed -i "s/__MEMPOOL_SPAWN_CLUSTER_PROCS__/${__MEMPOOL_SPAWN_CLUSTER_PROCS__}/g" mempool-config.json
sed -i "s!__MEMPOOL_API_URL_PREFIX__!${__MEMPOOL_API_URL_PREFIX__}!g" mempool-config.json sed -i "s!__MEMPOOL_API_URL_PREFIX__!${__MEMPOOL_API_URL_PREFIX__}!g" mempool-config.json

View File

@ -8,7 +8,9 @@ WORKDIR /build
COPY . . COPY . .
RUN apt-get update RUN apt-get update
RUN apt-get install -y build-essential rsync RUN apt-get install -y build-essential rsync
RUN cp mempool-frontend-config.sample.json mempool-frontend-config.json
RUN npm install --omit=dev --omit=optional RUN npm install --omit=dev --omit=optional
RUN npm run build RUN npm run build
FROM nginx:1.17.8-alpine FROM nginx:1.17.8-alpine
@ -28,7 +30,9 @@ RUN chown -R 1000:1000 /patch && chmod -R 755 /patch && \
chown -R 1000:1000 /var/cache/nginx && \ chown -R 1000:1000 /var/cache/nginx && \
chown -R 1000:1000 /var/log/nginx && \ chown -R 1000:1000 /var/log/nginx && \
chown -R 1000:1000 /etc/nginx/nginx.conf && \ chown -R 1000:1000 /etc/nginx/nginx.conf && \
chown -R 1000:1000 /etc/nginx/conf.d chown -R 1000:1000 /etc/nginx/conf.d && \
chown -R 1000:1000 /var/www/mempool
RUN touch /var/run/nginx.pid && \ RUN touch /var/run/nginx.pid && \
chown -R 1000:1000 /var/run/nginx.pid chown -R 1000:1000 /var/run/nginx.pid

View File

@ -10,4 +10,51 @@ cp /etc/nginx/nginx.conf /patch/nginx.conf
sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf
cat /patch/nginx.conf > /etc/nginx/nginx.conf cat /patch/nginx.conf > /etc/nginx/nginx.conf
# Runtime overrides - read env vars defined in docker compose
__TESTNET_ENABLED__=${TESTNET_ENABLED:=false}
__SIGNET_ENABLED__=${SIGNET_ENABLED:=false}
__LIQUID_ENABLED__=${LIQUID_EANBLED:=false}
__LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false}
__BISQ_ENABLED__=${BISQ_ENABLED:=false}
__BISQ_SEPARATE_BACKEND__=${BISQ_SEPARATE_BACKEND:=false}
__ITEMS_PER_PAGE__=${ITEMS_PER_PAGE:=10}
__KEEP_BLOCKS_AMOUNT__=${KEEP_BLOCKS_AMOUNT:=8}
__NGINX_PROTOCOL__=${NGINX_PROTOCOL:=http}
__NGINX_HOSTNAME__=${NGINX_HOSTNAME:=localhost}
__NGINX_PORT__=${NGINX_PORT:=8999}
__BLOCK_WEIGHT_UNITS__=${BLOCK_WEIGHT_UNITS:=4000000}
__MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_BLOCKS_AMOUNT:=8}
__BASE_MODULE__=${BASE_MODULE:=mempool}
__MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://mempool.space}
__LIQUID_WEBSITE_URL__=${LIQUID_WEBSITE_URL:=https://liquid.network}
__BISQ_WEBSITE_URL__=${BISQ_WEBSITE_URL:=https://bisq.markets}
__MINING_DASHBOARD__=${MINING_DASHBOARD:=true}
__LIGHTNING__=${LIGHTNING:=false}
# Export as environment variables to be used by envsubst
export __TESTNET_ENABLED__
export __SIGNET_ENABLED__
export __LIQUID_ENABLED__
export __LIQUID_TESTNET_ENABLED__
export __BISQ_ENABLED__
export __BISQ_SEPARATE_BACKEND__
export __ITEMS_PER_PAGE__
export __KEEP_BLOCKS_AMOUNT__
export __NGINX_PROTOCOL__
export __NGINX_HOSTNAME__
export __NGINX_PORT__
export __BLOCK_WEIGHT_UNITS__
export __MEMPOOL_BLOCKS_AMOUNT__
export __BASE_MODULE__
export __MEMPOOL_WEBSITE_URL__
export __LIQUID_WEBSITE_URL__
export __BISQ_WEBSITE_URL__
export __MINING_DASHBOARD__
export __LIGHTNING__
folder=$(find /var/www/mempool -name "config.js" | xargs dirname)
echo ${folder}
envsubst < ${folder}/config.template.js > ${folder}/config.js
exec "$@" exec "$@"

View File

@ -152,15 +152,14 @@
"assets": [ "assets": [
"src/favicon.ico", "src/favicon.ico",
"src/resources", "src/resources",
"src/robots.txt" "src/robots.txt",
"src/config.js",
"src/config.template.js"
], ],
"styles": [ "styles": [
"src/styles.scss", "src/styles.scss",
"node_modules/@fortawesome/fontawesome-svg-core/styles.css" "node_modules/@fortawesome/fontawesome-svg-core/styles.css"
], ],
"scripts": [
"generated-config.js"
],
"vendorChunk": true, "vendorChunk": true,
"extractLicenses": false, "extractLicenses": false,
"buildOptimizer": false, "buildOptimizer": false,
@ -222,6 +221,10 @@
"proxyConfig": "proxy.conf.local.js", "proxyConfig": "proxy.conf.local.js",
"verbose": true "verbose": true
}, },
"local-esplora": {
"proxyConfig": "proxy.conf.local-esplora.js",
"verbose": true
},
"mixed": { "mixed": {
"proxyConfig": "proxy.conf.mixed.js", "proxyConfig": "proxy.conf.mixed.js",
"verbose": true "verbose": true
@ -265,57 +268,6 @@
} }
} }
}, },
"server": {
"builder": "@angular-devkit/build-angular:server",
"options": {
"outputPath": "dist/mempool/server",
"main": "server.ts",
"tsConfig": "tsconfig.server.json",
"sourceMap": true,
"optimization": false
},
"configurations": {
"production": {
"outputHashing": "media",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"sourceMap": false,
"localize": true,
"optimization": true
}
},
"defaultConfiguration": ""
},
"serve-ssr": {
"builder": "@nguniversal/builders:ssr-dev-server",
"options": {
"browserTarget": "mempool:build",
"serverTarget": "mempool:server"
},
"configurations": {
"production": {
"browserTarget": "mempool:build:production",
"serverTarget": "mempool:server:production"
}
}
},
"prerender": {
"builder": "@nguniversal/builders:prerender",
"options": {
"browserTarget": "mempool:build:production",
"serverTarget": "mempool:server:production",
"routes": [
"/"
]
},
"configurations": {
"production": {}
}
},
"cypress-run": { "cypress-run": {
"builder": "@cypress/schematic:cypress", "builder": "@cypress/schematic:cypress",
"options": { "options": {
@ -336,6 +288,5 @@
} }
} }
} }
}, }
"defaultProject": "mempool"
} }

View File

@ -2,7 +2,8 @@ var fs = require('fs');
const { spawnSync } = require('child_process'); const { spawnSync } = require('child_process');
const CONFIG_FILE_NAME = 'mempool-frontend-config.json'; const CONFIG_FILE_NAME = 'mempool-frontend-config.json';
const GENERATED_CONFIG_FILE_NAME = 'generated-config.js'; const GENERATED_CONFIG_FILE_NAME = 'src/resources/config.js';
const GENERATED_TEMPLATE_CONFIG_FILE_NAME = 'src/resources/config.template.js';
let settings = []; let settings = [];
let configContent = {}; let configContent = {};
@ -67,10 +68,17 @@ if (process.env.DOCKER_COMMIT_HASH) {
const newConfig = `(function (window) { const newConfig = `(function (window) {
window.__env = window.__env || {};${settings.reduce((str, obj) => `${str} window.__env = window.__env || {};${settings.reduce((str, obj) => `${str}
window.__env.${obj.key} = ${ typeof obj.value === 'string' ? `'${obj.value}'` : obj.value };`, '')} window.__env.${obj.key} = ${typeof obj.value === 'string' ? `'${obj.value}'` : obj.value};`, '')}
window.__env.GIT_COMMIT_HASH = '${gitCommitHash}'; window.__env.GIT_COMMIT_HASH = '${gitCommitHash}';
window.__env.PACKAGE_JSON_VERSION = '${packetJsonVersion}'; window.__env.PACKAGE_JSON_VERSION = '${packetJsonVersion}';
}(global || this));`; }(this));`;
const newConfigTemplate = `(function (window) {
window.__env = window.__env || {};${settings.reduce((str, obj) => `${str}
window.__env.${obj.key} = ${typeof obj.value === 'string' ? `'\${__${obj.key}__}'` : `\${__${obj.key}__}`};`, '')}
window.__env.GIT_COMMIT_HASH = '${gitCommitHash}';
window.__env.PACKAGE_JSON_VERSION = '${packetJsonVersion}';
}(this));`;
function readConfig(path) { function readConfig(path) {
try { try {
@ -89,6 +97,16 @@ function writeConfig(path, config) {
} }
} }
function writeConfigTemplate(path, config) {
try {
fs.writeFileSync(path, config, 'utf8');
} catch (e) {
throw new Error(e);
}
}
writeConfigTemplate(GENERATED_TEMPLATE_CONFIG_FILE_NAME, newConfigTemplate);
const currentConfig = readConfig(GENERATED_CONFIG_FILE_NAME); const currentConfig = readConfig(GENERATED_CONFIG_FILE_NAME);
if (currentConfig && currentConfig === newConfig) { if (currentConfig && currentConfig === newConfig) {
@ -106,4 +124,4 @@ if (currentConfig && currentConfig === newConfig) {
console.log('NEW CONFIG: ', newConfig); console.log('NEW CONFIG: ', newConfig);
writeConfig(GENERATED_CONFIG_FILE_NAME, newConfig); writeConfig(GENERATED_CONFIG_FILE_NAME, newConfig);
console.log(`${GENERATED_CONFIG_FILE_NAME} file updated`); console.log(`${GENERATED_CONFIG_FILE_NAME} file updated`);
}; }

13735
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,7 @@
"serve:local-prod": "npm run generate-config && npm run ng -- serve -c local-prod", "serve:local-prod": "npm run generate-config && npm run ng -- serve -c local-prod",
"serve:local-staging": "npm run generate-config && npm run ng -- serve -c local-staging", "serve:local-staging": "npm run generate-config && npm run ng -- serve -c local-staging",
"start": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local", "start": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local",
"start:local-esplora": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-esplora",
"start:stg": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c staging", "start:stg": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c staging",
"start:local-prod": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-prod", "start:local-prod": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-prod",
"start:local-staging": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-staging", "start:local-staging": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-staging",
@ -50,9 +51,6 @@
"config:defaults:mempool": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=mempool BLOCK_WEIGHT_UNITS=4000000 && npm run generate-config", "config:defaults:mempool": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=mempool BLOCK_WEIGHT_UNITS=4000000 && npm run generate-config",
"config:defaults:liquid": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=liquid BLOCK_WEIGHT_UNITS=300000 && npm run generate-config", "config:defaults:liquid": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=liquid BLOCK_WEIGHT_UNITS=300000 && npm run generate-config",
"config:defaults:bisq": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=bisq BLOCK_WEIGHT_UNITS=4000000 && npm run generate-config", "config:defaults:bisq": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=bisq BLOCK_WEIGHT_UNITS=4000000 && npm run generate-config",
"dev:ssr": "npm run generate-config && npm run ng -- run mempool:serve-ssr",
"serve:ssr": "node server.run.js",
"build:ssr": "npm run build && npm run ng -- run mempool:server:production && npm run tsc -- server.run.ts",
"prerender": "npm run ng -- run mempool:prerender", "prerender": "npm run ng -- run mempool:prerender",
"cypress:open": "cypress open", "cypress:open": "cypress open",
"cypress:run": "cypress run", "cypress:run": "cypress run",
@ -63,48 +61,44 @@
"cypress:run:ci:staging": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:run:record" "cypress:run:ci:staging": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:run:record"
}, },
"dependencies": { "dependencies": {
"@angular-devkit/build-angular": "~13.3.7", "@angular-devkit/build-angular": "^14.2.10",
"@angular/animations": "~13.3.10", "@angular/animations": "^14.2.12",
"@angular/cli": "~13.3.7", "@angular/cli": "^14.2.10",
"@angular/common": "~13.3.10", "@angular/common": "^14.2.12",
"@angular/compiler": "~13.3.10", "@angular/compiler": "^14.2.12",
"@angular/core": "~13.3.10", "@angular/core": "^14.2.12",
"@angular/forms": "~13.3.10", "@angular/forms": "^14.2.12",
"@angular/localize": "~13.3.10", "@angular/localize": "^14.2.12",
"@angular/platform-browser": "~13.3.10", "@angular/platform-browser": "^14.2.12",
"@angular/platform-browser-dynamic": "~13.3.10", "@angular/platform-browser-dynamic": "^14.2.12",
"@angular/platform-server": "~13.3.10", "@angular/platform-server": "^14.2.12",
"@angular/router": "~13.3.10", "@angular/router": "^14.2.12",
"@fortawesome/angular-fontawesome": "~0.10.2", "@fortawesome/angular-fontawesome": "~0.11.1",
"@fortawesome/fontawesome-common-types": "~6.1.1", "@fortawesome/fontawesome-common-types": "~6.2.1",
"@fortawesome/fontawesome-svg-core": "~6.1.1", "@fortawesome/fontawesome-svg-core": "~6.2.1",
"@fortawesome/free-solid-svg-icons": "~6.1.1", "@fortawesome/free-solid-svg-icons": "~6.2.1",
"@mempool/mempool.js": "2.3.0", "@mempool/mempool.js": "2.3.0",
"@ng-bootstrap/ng-bootstrap": "^11.0.0", "@ng-bootstrap/ng-bootstrap": "^13.1.1",
"@nguniversal/express-engine": "~13.1.1", "@types/qrcode": "~1.5.0",
"@types/qrcode": "~1.4.2", "bootstrap": "~4.6.1",
"bootstrap": "~4.5.0",
"browserify": "^17.0.0", "browserify": "^17.0.0",
"clipboard": "^2.0.10", "clipboard": "^2.0.11",
"domino": "^2.1.6", "domino": "^2.1.6",
"echarts": "~5.3.2", "echarts": "~5.3.2",
"echarts-gl": "^2.0.9", "echarts-gl": "^2.0.9",
"express": "^4.17.1",
"lightweight-charts": "~3.8.0", "lightweight-charts": "~3.8.0",
"ngx-echarts": "8.0.1", "ngx-echarts": "8.0.1",
"ngx-infinite-scroll": "^10.0.1", "ngx-infinite-scroll": "^14.0.1",
"qrcode": "1.5.0", "qrcode": "1.5.0",
"rxjs": "~7.5.5", "rxjs": "~7.5.7",
"tinyify": "^3.0.0", "tinyify": "^3.1.0",
"tlite": "^0.1.9", "tlite": "^0.1.9",
"tslib": "~2.4.0", "tslib": "~2.4.1",
"zone.js": "~0.11.5" "zone.js": "~0.11.5"
}, },
"devDependencies": { "devDependencies": {
"@angular/compiler-cli": "~13.3.10", "@angular/compiler-cli": "^14.2.12",
"@angular/language-service": "~13.3.10", "@angular/language-service": "^14.2.12",
"@nguniversal/builders": "~13.1.1",
"@types/express": "^4.17.0",
"@types/node": "^12.11.1", "@types/node": "^12.11.1",
"@typescript-eslint/eslint-plugin": "^5.30.5", "@typescript-eslint/eslint-plugin": "^5.30.5",
"@typescript-eslint/parser": "^5.30.5", "@typescript-eslint/parser": "^5.30.5",
@ -115,11 +109,11 @@
"typescript": "~4.6.4" "typescript": "~4.6.4"
}, },
"optionalDependencies": { "optionalDependencies": {
"@cypress/schematic": "~2.0.0", "@cypress/schematic": "~2.3.0",
"cypress": "^10.3.0", "cypress": "^11.2.0",
"cypress-fail-on-console-error": "~3.0.0", "cypress-fail-on-console-error": "~4.0.2",
"cypress-wait-until": "^1.7.2", "cypress-wait-until": "^1.7.2",
"mock-socket": "~9.1.4", "mock-socket": "~9.1.5",
"start-server-and-test": "~1.14.0" "start-server-and-test": "~1.14.0"
}, },
"scarfSettings": { "scarfSettings": {

View File

@ -0,0 +1,137 @@
const fs = require('fs');
const FRONTEND_CONFIG_FILE_NAME = 'mempool-frontend-config.json';
let configContent;
// Read frontend config
try {
const rawConfig = fs.readFileSync(FRONTEND_CONFIG_FILE_NAME);
configContent = JSON.parse(rawConfig);
console.log(`${FRONTEND_CONFIG_FILE_NAME} file found, using provided config`);
} catch (e) {
console.log(e);
if (e.code !== 'ENOENT') {
throw new Error(e);
} else {
console.log(`${FRONTEND_CONFIG_FILE_NAME} file not found, using default config`);
}
}
let PROXY_CONFIG = [];
if (configContent && configContent.BASE_MODULE === 'liquid') {
PROXY_CONFIG.push(...[
{
context: ['/liquid/api/v1/**'],
target: `http://127.0.0.1:8999`,
secure: false,
ws: true,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/liquid": ""
},
},
{
context: ['/liquid/api/**'],
target: `http://127.0.0.1:3000`,
secure: false,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/liquid/api/": ""
},
},
{
context: ['/liquidtestnet/api/v1/**'],
target: `http://127.0.0.1:8999`,
secure: false,
ws: true,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/liquidtestnet": ""
},
},
{
context: ['/liquidtestnet/api/**'],
target: `http://127.0.0.1:3000`,
secure: false,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/liquidtestnet/api/": "/"
},
},
]);
}
if (configContent && configContent.BASE_MODULE === 'bisq') {
PROXY_CONFIG.push(...[
{
context: ['/bisq/api/v1/ws'],
target: `http://127.0.0.1:8999`,
secure: false,
ws: true,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/bisq": ""
},
},
{
context: ['/bisq/api/v1/**'],
target: `http://127.0.0.1:8999`,
secure: false,
changeOrigin: true,
proxyTimeout: 30000,
},
{
context: ['/bisq/api/**'],
target: `http://127.0.0.1:8999`,
secure: false,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/bisq/api/": "/api/v1/bisq/"
},
}
]);
}
PROXY_CONFIG.push(...[
{
context: ['/testnet/api/v1/lightning/**'],
target: `http://127.0.0.1:8999`,
secure: false,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/testnet": ""
},
},
{
context: ['/api/v1/**'],
target: `http://127.0.0.1:8999`,
secure: false,
ws: true,
changeOrigin: true,
proxyTimeout: 30000,
},
{
context: ['/api/**'],
target: `http://127.0.0.1:3000`,
secure: false,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/api": ""
},
}
]);
console.log(PROXY_CONFIG);
module.exports = PROXY_CONFIG;

View File

@ -3,9 +3,9 @@ const fs = require('fs');
let PROXY_CONFIG = require('./proxy.conf'); let PROXY_CONFIG = require('./proxy.conf');
PROXY_CONFIG.forEach(entry => { PROXY_CONFIG.forEach(entry => {
entry.target = entry.target.replace("mempool.space", "mempool-staging.fra.mempool.space"); entry.target = entry.target.replace("mempool.space", "mempool-staging.tk7.mempool.space");
entry.target = entry.target.replace("liquid.network", "liquid-staging.fra.mempool.space"); entry.target = entry.target.replace("liquid.network", "liquid-staging.tk7.mempool.space");
entry.target = entry.target.replace("bisq.markets", "bisq-staging.fra.mempool.space"); entry.target = entry.target.replace("bisq.markets", "bisq-staging.tk7.mempool.space");
}); });
module.exports = PROXY_CONFIG; module.exports = PROXY_CONFIG;

View File

@ -1,96 +0,0 @@
import 'zone.js/node';
import './generated-config';
import * as domino from 'domino';
import * as express from 'express';
import * as fs from 'fs';
import * as path from 'path';
const {readFileSync, existsSync} = require('fs');
const {createProxyMiddleware} = require('http-proxy-middleware');
const template = fs.readFileSync(path.join(process.cwd(), 'dist/mempool/browser/en-US/', 'index.html')).toString();
const win = domino.createWindow(template);
// @ts-ignore
win.__env = global.__env;
// @ts-ignore
win.matchMedia = () => {
return {
matches: true
};
};
// @ts-ignore
win.setTimeout = (fn) => { fn(); };
win.document.body.scrollTo = (() => {});
// @ts-ignore
global['window'] = win;
global['document'] = win.document;
// @ts-ignore
global['history'] = { state: { } };
global['localStorage'] = {
getItem: () => '',
setItem: () => {},
removeItem: () => {},
clear: () => {},
length: 0,
key: () => '',
};
/**
* Return the list of supported and actually active locales
*/
function getActiveLocales() {
const angularConfig = JSON.parse(readFileSync('angular.json', 'utf8'));
const supportedLocales = [
angularConfig.projects.mempool.i18n.sourceLocale,
...Object.keys(angularConfig.projects.mempool.i18n.locales),
];
return supportedLocales.filter(locale => existsSync(`./dist/mempool/server/${locale}`));
}
function app() {
const server = express();
// proxy API to nginx
server.get('/api/**', createProxyMiddleware({
// @ts-ignore
target: win.__env.NGINX_PROTOCOL + '://' + win.__env.NGINX_HOSTNAME + ':' + win.__env.NGINX_PORT,
changeOrigin: true,
}));
// map / and /en to en-US
const defaultLocale = 'en-US';
console.log(`serving default locale: ${defaultLocale}`);
const appServerModule = require(`./dist/mempool/server/${defaultLocale}/main.js`);
server.use('/', appServerModule.app(defaultLocale));
server.use('/en', appServerModule.app(defaultLocale));
// map each locale to its localized main.js
getActiveLocales().forEach(locale => {
console.log('serving locale:', locale);
const appServerModule = require(`./dist/mempool/server/${locale}/main.js`);
// map everything to itself
server.use(`/${locale}`, appServerModule.app(locale));
});
return server;
}
function run() {
const port = process.env.PORT || 4000;
// Start up the Node server
app().listen(port, () => {
console.log(`Node Express server listening on port ${port}`);
});
}
run();

View File

@ -1,160 +0,0 @@
import 'zone.js/node';
import './generated-config';
import { ngExpressEngine } from '@nguniversal/express-engine';
import * as express from 'express';
import * as fs from 'fs';
import * as path from 'path';
import * as domino from 'domino';
import { join } from 'path';
import { AppServerModule } from './src/main.server';
import { APP_BASE_HREF } from '@angular/common';
import { existsSync } from 'fs';
const template = fs.readFileSync(path.join(process.cwd(), 'dist/mempool/browser/en-US/', 'index.html')).toString();
const win = domino.createWindow(template);
// @ts-ignore
win.__env = global.__env;
// @ts-ignore
win.matchMedia = () => {
return {
matches: true
};
};
// @ts-ignore
win.setTimeout = (fn) => { fn(); };
win.document.body.scrollTo = (() => {});
// @ts-ignore
global['window'] = win;
global['document'] = win.document;
// @ts-ignore
global['history'] = { state: { } };
global['localStorage'] = {
getItem: () => '',
setItem: () => {},
removeItem: () => {},
clear: () => {},
length: 0,
key: () => '',
};
// The Express app is exported so that it can be used by serverless Functions.
export function app(locale: string): express.Express {
const server = express();
const distFolder = join(process.cwd(), `dist/mempool/browser/${locale}`);
const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index';
// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
server.engine('html', ngExpressEngine({
bootstrap: AppServerModule,
}));
server.set('view engine', 'html');
server.set('views', distFolder);
// only handle URLs that actually exist
//server.get(locale, getLocalizedSSR(indexHtml));
server.get('/', getLocalizedSSR(indexHtml));
server.get('/tx/*', getLocalizedSSR(indexHtml));
server.get('/block/*', getLocalizedSSR(indexHtml));
server.get('/mempool-block/*', getLocalizedSSR(indexHtml));
server.get('/address/*', getLocalizedSSR(indexHtml));
server.get('/blocks', getLocalizedSSR(indexHtml));
server.get('/mining/pools', getLocalizedSSR(indexHtml));
server.get('/mining/pool/*', getLocalizedSSR(indexHtml));
server.get('/graphs', getLocalizedSSR(indexHtml));
server.get('/liquid', getLocalizedSSR(indexHtml));
server.get('/liquid/tx/*', getLocalizedSSR(indexHtml));
server.get('/liquid/block/*', getLocalizedSSR(indexHtml));
server.get('/liquid/mempool-block/*', getLocalizedSSR(indexHtml));
server.get('/liquid/address/*', getLocalizedSSR(indexHtml));
server.get('/liquid/asset/*', getLocalizedSSR(indexHtml));
server.get('/liquid/blocks', getLocalizedSSR(indexHtml));
server.get('/liquid/graphs', getLocalizedSSR(indexHtml));
server.get('/liquid/assets', getLocalizedSSR(indexHtml));
server.get('/liquid/api', getLocalizedSSR(indexHtml));
server.get('/liquid/tv', getLocalizedSSR(indexHtml));
server.get('/liquid/status', getLocalizedSSR(indexHtml));
server.get('/liquid/about', getLocalizedSSR(indexHtml));
server.get('/testnet', getLocalizedSSR(indexHtml));
server.get('/testnet/tx/*', getLocalizedSSR(indexHtml));
server.get('/testnet/block/*', getLocalizedSSR(indexHtml));
server.get('/testnet/mempool-block/*', getLocalizedSSR(indexHtml));
server.get('/testnet/address/*', getLocalizedSSR(indexHtml));
server.get('/testnet/blocks', getLocalizedSSR(indexHtml));
server.get('/testnet/mining/pools', getLocalizedSSR(indexHtml));
server.get('/testnet/graphs', getLocalizedSSR(indexHtml));
server.get('/testnet/api', getLocalizedSSR(indexHtml));
server.get('/testnet/tv', getLocalizedSSR(indexHtml));
server.get('/testnet/status', getLocalizedSSR(indexHtml));
server.get('/testnet/about', getLocalizedSSR(indexHtml));
server.get('/signet', getLocalizedSSR(indexHtml));
server.get('/signet/tx/*', getLocalizedSSR(indexHtml));
server.get('/signet/block/*', getLocalizedSSR(indexHtml));
server.get('/signet/mempool-block/*', getLocalizedSSR(indexHtml));
server.get('/signet/address/*', getLocalizedSSR(indexHtml));
server.get('/signet/blocks', getLocalizedSSR(indexHtml));
server.get('/signet/mining/pools', getLocalizedSSR(indexHtml));
server.get('/signet/graphs', getLocalizedSSR(indexHtml));
server.get('/signet/api', getLocalizedSSR(indexHtml));
server.get('/signet/tv', getLocalizedSSR(indexHtml));
server.get('/signet/status', getLocalizedSSR(indexHtml));
server.get('/signet/about', getLocalizedSSR(indexHtml));
server.get('/bisq', getLocalizedSSR(indexHtml));
server.get('/bisq/tx/*', getLocalizedSSR(indexHtml));
server.get('/bisq/blocks', getLocalizedSSR(indexHtml));
server.get('/bisq/block/*', getLocalizedSSR(indexHtml));
server.get('/bisq/address/*', getLocalizedSSR(indexHtml));
server.get('/bisq/stats', getLocalizedSSR(indexHtml));
server.get('/bisq/about', getLocalizedSSR(indexHtml));
server.get('/bisq/api', getLocalizedSSR(indexHtml));
server.get('/about', getLocalizedSSR(indexHtml));
server.get('/api', getLocalizedSSR(indexHtml));
server.get('/tv', getLocalizedSSR(indexHtml));
server.get('/status', getLocalizedSSR(indexHtml));
server.get('/terms-of-service', getLocalizedSSR(indexHtml));
// fallback to static file handler so we send HTTP 404 to nginx
server.get('/**', express.static(distFolder, { maxAge: '1y' }));
return server;
}
function getLocalizedSSR(indexHtml) {
return (req, res) => {
res.render(indexHtml, {
req,
providers: [
{ provide: APP_BASE_HREF, useValue: req.baseUrl }
]
});
}
}
// only used for development mode
function run(): void {
const port = process.env.PORT || 4000;
// Start up the Node server
const server = app('en-US');
server.listen(port, () => {
console.log(`Node Express server listening on port ${port}`);
});
}
// Webpack will replace 'require' with '__webpack_require__'
// '__non_webpack_require__' is a proxy to Node 'require'
// The below code is to ensure that the server is run only when not requiring the bundle.
declare const __non_webpack_require__: NodeRequire;
const mainModule = __non_webpack_require__.main;
const moduleFilename = mainModule && mainModule.filename || '';
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
run();
}
export * from './src/main.server';

View File

@ -4,7 +4,6 @@ import { AppPreloadingStrategy } from './app.preloading-strategy'
import { StartComponent } from './components/start/start.component'; import { StartComponent } from './components/start/start.component';
import { TransactionComponent } from './components/transaction/transaction.component'; import { TransactionComponent } from './components/transaction/transaction.component';
import { BlockComponent } from './components/block/block.component'; import { BlockComponent } from './components/block/block.component';
import { BlockAuditComponent } from './components/block-audit/block-audit.component';
import { AddressComponent } from './components/address/address.component'; import { AddressComponent } from './components/address/address.component';
import { MasterPageComponent } from './components/master-page/master-page.component'; import { MasterPageComponent } from './components/master-page/master-page.component';
import { AboutComponent } from './components/about/about.component'; import { AboutComponent } from './components/about/about.component';
@ -103,16 +102,6 @@ let routes: Routes = [
}, },
], ],
}, },
{
path: 'block-audit',
data: { networkSpecific: true },
children: [
{
path: ':id',
component: BlockAuditComponent,
},
],
},
{ {
path: 'docs', path: 'docs',
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule), loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule),
@ -219,16 +208,6 @@ let routes: Routes = [
}, },
], ],
}, },
{
path: 'block-audit',
data: { networkSpecific: true },
children: [
{
path: ':id',
component: BlockAuditComponent,
},
],
},
{ {
path: 'docs', path: 'docs',
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
@ -331,16 +310,6 @@ let routes: Routes = [
}, },
], ],
}, },
{
path: 'block-audit',
data: { networkSpecific: true },
children: [
{
path: ':id',
component: BlockAuditComponent
},
],
},
{ {
path: 'docs', path: 'docs',
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
@ -658,7 +627,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
@NgModule({ @NgModule({
imports: [RouterModule.forRoot(routes, { imports: [RouterModule.forRoot(routes, {
initialNavigation: 'enabled', initialNavigation: 'enabledBlocking',
scrollPositionRestoration: 'enabled', scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled', anchorScrolling: 'enabled',
preloadingStrategy: AppPreloadingStrategy preloadingStrategy: AppPreloadingStrategy

View File

@ -79,7 +79,7 @@ export const poolsColor = {
'binancepool': '#1E88E5', 'binancepool': '#1E88E5',
'viabtc': '#039BE5', 'viabtc': '#039BE5',
'btccom': '#00897B', 'btccom': '#00897B',
'slushpool': '#00ACC1', 'braiinspool': '#00ACC1',
'sbicrypto': '#43A047', 'sbicrypto': '#43A047',
'marapool': '#7CB342', 'marapool': '#7CB342',
'luxor': '#C0CA33', 'luxor': '#C0CA33',

View File

@ -10,27 +10,27 @@
</div> </div>
<form [formGroup]="radioGroupForm" class="mb-3 radio-form"> <form [formGroup]="radioGroupForm" class="mb-3 radio-form">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="interval"> <div class="btn-group btn-group-toggle" name="radioBasic">
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('interval').value === 'half_hour'">
<input ngbButton type="radio" [value]="'half_hour'" (click)="setFragment('half_hour')"> 30M <input type="radio" [value]="'half_hour'" (click)="setFragment('half_hour')" formControlName="interval"> 30M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('interval').value === 'hour'">
<input ngbButton type="radio" [value]="'hour'" (click)="setFragment('hour')"> 1H <input type="radio" [value]="'hour'" (click)="setFragment('hour')" formControlName="interval"> 1H
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('interval').value === 'half_day'">
<input ngbButton type="radio" [value]="'half_day'" (click)="setFragment('half_day')"> 12H <input type="radio" [value]="'half_day'" (click)="setFragment('half_day')" formControlName="interval"> 12H
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('interval').value === 'day'">
<input ngbButton type="radio" [value]="'day'" (click)="setFragment('day')"> 1D <input type="radio" [value]="'day'" (click)="setFragment('day')" formControlName="interval"> 1D
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('interval').value === 'week'">
<input ngbButton type="radio" [value]="'week'" (click)="setFragment('week')"> 1W <input type="radio" [value]="'week'" (click)="setFragment('week')" formControlName="interval"> 1W
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('interval').value === 'month'">
<input ngbButton type="radio" [value]="'month'" (click)="setFragment('month')"> 1M <input type="radio" [value]="'month'" (click)="setFragment('month')" formControlName="interval"> 1M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('interval').value === 'year'">
<input ngbButton type="radio" [value]="'year'" (click)="setFragment('year')"> 1Y <input type="radio" [value]="'year'" (click)="setFragment('year')" formControlName="interval"> 1Y
</label> </label>
</div> </div>
</form> </form>

View File

@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { combineLatest, merge, Observable, of } from 'rxjs'; import { combineLatest, merge, Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators'; import { map, switchMap } from 'rxjs/operators';
@ -19,7 +19,7 @@ export class BisqMarketComponent implements OnInit, OnDestroy {
currency$: Observable<any>; currency$: Observable<any>;
offers$: Observable<OffersMarket>; offers$: Observable<OffersMarket>;
trades$: Observable<Trade[]>; trades$: Observable<Trade[]>;
radioGroupForm: FormGroup; radioGroupForm: UntypedFormGroup;
defaultInterval = 'day'; defaultInterval = 'day';
isLoadingGraph = false; isLoadingGraph = false;
@ -28,7 +28,7 @@ export class BisqMarketComponent implements OnInit, OnDestroy {
private websocketService: WebsocketService, private websocketService: WebsocketService,
private route: ActivatedRoute, private route: ActivatedRoute,
private bisqApiService: BisqApiService, private bisqApiService: BisqApiService,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private seoService: SeoService, private seoService: SeoService,
private router: Router, private router: Router,
) { } ) { }

View File

@ -5,7 +5,7 @@ import { Observable, Subscription } from 'rxjs';
import { switchMap, map, tap } from 'rxjs/operators'; import { switchMap, map, tap } from 'rxjs/operators';
import { BisqApiService } from '../bisq-api.service'; import { BisqApiService } from '../bisq-api.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { FormGroup, FormBuilder } from '@angular/forms'; import { UntypedFormGroup, UntypedFormBuilder } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router'; import { Router, ActivatedRoute } from '@angular/router';
import { IMultiSelectOption, IMultiSelectSettings, IMultiSelectTexts } from '../../components/ngx-bootstrap-multiselect/types' import { IMultiSelectOption, IMultiSelectSettings, IMultiSelectTexts } from '../../components/ngx-bootstrap-multiselect/types'
import { WebsocketService } from '../../services/websocket.service'; import { WebsocketService } from '../../services/websocket.service';
@ -23,7 +23,7 @@ export class BisqTransactionsComponent implements OnInit, OnDestroy {
fiveItemsPxSize = 250; fiveItemsPxSize = 250;
isLoading = true; isLoading = true;
loadingItems: number[]; loadingItems: number[];
radioGroupForm: FormGroup; radioGroupForm: UntypedFormGroup;
types: string[] = []; types: string[] = [];
radioGroupSubscription: Subscription; radioGroupSubscription: Subscription;
@ -70,7 +70,7 @@ export class BisqTransactionsComponent implements OnInit, OnDestroy {
private websocketService: WebsocketService, private websocketService: WebsocketService,
private bisqApiService: BisqApiService, private bisqApiService: BisqApiService,
private seoService: SeoService, private seoService: SeoService,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,

View File

@ -129,7 +129,7 @@
<span>Gemini</span> <span>Gemini</span>
</a> </a>
<a href="https://exodus.com/" target="_blank" title="Exodus"> <a href="https://exodus.com/" target="_blank" title="Exodus">
<svg width="80" height="80" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="81" height="81" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="250" cy="250" r="250" fill="#1F2033"/> <circle cx="250" cy="250" r="250" fill="#1F2033"/>
<g clip-path="url(#clip0_2_14)"> <g clip-path="url(#clip0_2_14)">
<path d="M411.042 178.303L271.79 87V138.048L361.121 196.097L350.612 229.351H271.79V271.648H350.612L361.121 304.903L271.79 362.952V414L411.042 322.989L388.271 250.646L411.042 178.303Z" fill="url(#paint0_linear_2_14)"/> <path d="M411.042 178.303L271.79 87V138.048L361.121 196.097L350.612 229.351H271.79V271.648H350.612L361.121 304.903L271.79 362.952V414L411.042 322.989L388.271 250.646L411.042 178.303Z" fill="url(#paint0_linear_2_14)"/>
@ -274,6 +274,10 @@
<img class="image" src="/resources/profile/schildbach.svg" /> <img class="image" src="/resources/profile/schildbach.svg" />
<span>Schildbach</span> <span>Schildbach</span>
</a> </a>
<a href="https://github.com/nunchuk-io" target="_blank" title="Nunchuck">
<img class="image" src="/resources/profile/nunchuk.svg" />
<span>Nunchuk</span>
</a>
</div> </div>
</div> </div>

View File

@ -3,8 +3,8 @@
text-align: center; text-align: center;
.image { .image {
width: 80px; width: 81px;
height: 80px; height: 81px;
background-size: 100%, 100%; background-size: 100%, 100%;
border-radius: 50%; border-radius: 50%;
margin: 25px; margin: 25px;

View File

@ -4,7 +4,7 @@ import { map } from 'rxjs/operators';
import { moveDec } from '../../bitcoin.utils'; import { moveDec } from '../../bitcoin.utils';
import { AssetsService } from '../../services/assets.service'; import { AssetsService } from '../../services/assets.service';
import { ElectrsApiService } from '../../services/electrs-api.service'; import { ElectrsApiService } from '../../services/electrs-api.service';
import { environment } from 'src/environments/environment'; import { environment } from '../../../environments/environment';
@Component({ @Component({
selector: 'app-asset-circulation', selector: 'app-asset-circulation',

View File

@ -9,7 +9,7 @@ import { AudioService } from '../../services/audio.service';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { of, merge, Subscription, combineLatest } from 'rxjs'; import { of, merge, Subscription, combineLatest } from 'rxjs';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { environment } from 'src/environments/environment'; import { environment } from '../../../environments/environment';
import { AssetsService } from '../../services/assets.service'; import { AssetsService } from '../../services/assets.service';
import { moveDec } from '../../bitcoin.utils'; import { moveDec } from '../../bitcoin.utils';

View File

@ -1,5 +1,5 @@
import { Component, OnInit, ViewChild } from '@angular/core'; import { Component, OnInit, ViewChild } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; import { NgbTypeahead } from '@ng-bootstrap/ng-bootstrap';
import { merge, Observable, of, Subject } from 'rxjs'; import { merge, Observable, of, Subject } from 'rxjs';
@ -9,7 +9,7 @@ import { AssetsService } from '../../../services/assets.service';
import { SeoService } from '../../../services/seo.service'; import { SeoService } from '../../../services/seo.service';
import { StateService } from '../../../services/state.service'; import { StateService } from '../../../services/state.service';
import { RelativeUrlPipe } from '../../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '../../../shared/pipes/relative-url/relative-url.pipe';
import { environment } from 'src/environments/environment'; import { environment } from '../../../../environments/environment';
@Component({ @Component({
selector: 'app-assets-nav', selector: 'app-assets-nav',
@ -19,7 +19,7 @@ import { environment } from 'src/environments/environment';
export class AssetsNavComponent implements OnInit { export class AssetsNavComponent implements OnInit {
@ViewChild('instance', {static: true}) instance: NgbTypeahead; @ViewChild('instance', {static: true}) instance: NgbTypeahead;
nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId; nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId;
searchForm: FormGroup; searchForm: UntypedFormGroup;
assetsCache: AssetExtended[]; assetsCache: AssetExtended[];
typeaheadSearchFn: ((text: Observable<string>) => Observable<readonly any[]>); typeaheadSearchFn: ((text: Observable<string>) => Observable<readonly any[]>);
@ -30,7 +30,7 @@ export class AssetsNavComponent implements OnInit {
itemsPerPage = 15; itemsPerPage = 15;
constructor( constructor(
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private seoService: SeoService, private seoService: SeoService,
private router: Router, private router: Router,
private assetsService: AssetsService, private assetsService: AssetsService,

View File

@ -1,7 +1,7 @@
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { AssetsService } from '../../services/assets.service'; import { AssetsService } from '../../services/assets.service';
import { environment } from 'src/environments/environment'; import { environment } from '../../../environments/environment';
import { FormGroup } from '@angular/forms'; import { UntypedFormGroup } from '@angular/forms';
import { filter, map, switchMap, take } from 'rxjs/operators'; import { filter, map, switchMap, take } from 'rxjs/operators';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { combineLatest, Observable } from 'rxjs'; import { combineLatest, Observable } from 'rxjs';
@ -22,7 +22,7 @@ export class AssetsComponent implements OnInit {
assets: AssetExtended[]; assets: AssetExtended[];
assetsCache: AssetExtended[]; assetsCache: AssetExtended[];
searchForm: FormGroup; searchForm: UntypedFormGroup;
assets$: Observable<AssetExtended[]>; assets$: Observable<AssetExtended[]>;
page = 1; page = 1;

View File

@ -1,197 +0,0 @@
<div class="container-xl" (window:resize)="onResize($event)">
<div class="title-block" id="block">
<h1>
<span class="next-previous-blocks">
<span i18n="shared.block-audit-title">Block Audit</span>
&nbsp;
<a *ngIf="blockAudit" [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockAudit.height }}</a>
&nbsp;
</span>
</h1>
<div class="grow"></div>
<button [routerLink]="['/block/' | relativeUrl, blockHash]" class="btn btn-sm">&#10005;</button>
</div>
<div *ngIf="!error && !isLoading">
<!-- OVERVIEW -->
<div class="box mb-3">
<div class="row">
<!-- LEFT COLUMN -->
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="td-width" i18n="block.hash">Hash</td>
<td><a [routerLink]="['/block/' | relativeUrl, blockHash]" title="{{ blockHash }}">{{ blockHash | shortenString : 13 }}</a>
<app-clipboard class="d-none d-sm-inline-block" [text]="blockHash"></app-clipboard>
</td>
</tr>
<tr>
<td i18n="blockAudit.timestamp">Timestamp</td>
<td>
&lrm;{{ blockAudit.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
<div class="lg-inline">
<i class="symbol">(<app-time-since [time]="blockAudit.timestamp" [fastRender]="true">
</app-time-since>)</i>
</div>
</td>
</tr>
<tr>
<td class="td-width" i18n="shared.transaction-count">Transactions</td>
<td>{{ blockAudit.tx_count }}</td>
</tr>
<tr>
<td i18n="blockAudit.size">Size</td>
<td [innerHTML]="'&lrm;' + (blockAudit.size | bytes: 2)"></td>
</tr>
<tr>
<td i18n="block.weight">Weight</td>
<td [innerHTML]="'&lrm;' + (blockAudit.weight | wuBytes: 2)"></td>
</tr>
</tbody>
</table>
</div>
<!-- RIGHT COLUMN -->
<div class="col-sm" *ngIf="blockAudit">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td i18n="block.health">Block health</td>
<td>{{ blockAudit.matchRate }}%</td>
</tr>
<tr>
<td i18n="block.missing-txs">Removed txs</td>
<td>{{ blockAudit.missingTxs.length }}</td>
</tr>
<tr>
<td i18n="block.missing-txs">Omitted txs</td>
<td>{{ numMissing }}</td>
</tr>
<tr>
<td i18n="block.added-txs">Added txs</td>
<td>{{ blockAudit.addedTxs.length }}</td>
</tr>
<tr>
<td i18n="block.missing-txs">Included txs</td>
<td>{{ numUnexpected }}</td>
</tr>
</tbody>
</table>
</div>
</div> <!-- row -->
</div> <!-- box -->
<!-- ADDED vs MISSING button -->
<div class="d-flex justify-content-center menu mt-3 mb-3" *ngIf="isMobile">
<a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'projected'" i18n="block.projected"
fragment="projected" (click)="changeMode('projected')">Projected</a>
<a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'actual'" i18n="block.actual"
fragment="actual" (click)="changeMode('actual')">Actual</a>
</div>
</div>
<ng-template [ngIf]="!error && isLoading">
<div class="title-block" id="block">
<h1>
<span class="next-previous-blocks">
<span i18n="shared.block-audit-title">Block Audit</span>
&nbsp;
<a *ngIf="blockAudit" [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockAudit.height }}</a>
&nbsp;
</span>
</h1>
<div class="grow"></div>
<button [routerLink]="['/' | relativeUrl]" class="btn btn-sm">&#10005;</button>
</div>
<!-- OVERVIEW -->
<div class="box mb-3">
<div class="row">
<!-- LEFT COLUMN -->
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
</tbody>
</table>
</div>
<!-- RIGHT COLUMN -->
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
</tbody>
</table>
</div>
</div> <!-- row -->
</div> <!-- box -->
<!-- ADDED vs MISSING button -->
<div class="d-flex justify-content-center menu mt-3 mb-3" *ngIf="isMobile">
<a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'projected'" i18n="block.projected"
fragment="projected" (click)="changeMode('projected')">Projected</a>
<a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'actual'" i18n="block.actual"
fragment="actual" (click)="changeMode('actual')">Actual</a>
</div>
</ng-template>
<ng-template [ngIf]="error">
<div *ngIf="error && error.status === 404; else generalError" class="text-center">
<br>
<b i18n="error.audit-unavailable">audit unavailable</b>
<br><br>
<i>{{ error.error }}</i>
<br>
<br>
</div>
<ng-template #generalError>
<div class="text-center">
<br>
<span i18n="error.general-loading-data">Error loading data.</span>
<br><br>
<i>{{ error }}</i>
<br>
<br>
</div>
</ng-template>
</ng-template>
<!-- VISUALIZATIONS -->
<div class="box" *ngIf="!error">
<div class="row">
<!-- MISSING TX RENDERING -->
<div class="col-sm" *ngIf="webGlEnabled">
<h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.projected-block">Projected Block</h3>
<app-block-overview-graph #blockGraphProjected [isLoading]="isLoading" [resolution]="75"
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false"
(txClickEvent)="onTxClick($event)"></app-block-overview-graph>
</div>
<!-- ADDED TX RENDERING -->
<div class="col-sm" *ngIf="webGlEnabled && !isMobile">
<h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.actual-block">Actual Block</h3>
<app-block-overview-graph #blockGraphActual [isLoading]="isLoading" [resolution]="75"
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false"
(txClickEvent)="onTxClick($event)"></app-block-overview-graph>
</div>
</div> <!-- row -->
</div> <!-- box -->
</div>

View File

@ -1,44 +0,0 @@
.title-block {
border-top: none;
}
.table {
tr td {
&:last-child {
text-align: right;
@media (min-width: 768px) {
text-align: left;
}
}
}
}
.block-tx-title {
display: flex;
justify-content: space-between;
flex-direction: column;
position: relative;
@media (min-width: 550px) {
flex-direction: row;
}
h2 {
line-height: 1;
margin: 0;
position: relative;
padding-bottom: 10px;
@media (min-width: 550px) {
padding-bottom: 0px;
align-self: end;
}
}
}
.menu-button {
@media (min-width: 768px) {
max-width: 150px;
}
}
.block-subtitle {
text-align: center;
}

View File

@ -1,192 +0,0 @@
import { Component, OnDestroy, OnInit, AfterViewInit, ViewChildren, QueryList } from '@angular/core';
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { Subscription, combineLatest } from 'rxjs';
import { map, switchMap, startWith, catchError } from 'rxjs/operators';
import { BlockAudit, TransactionStripped } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service';
import { StateService } from '../../services/state.service';
import { detectWebGL } from '../../shared/graphs.utils';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { BlockOverviewGraphComponent } from '../block-overview-graph/block-overview-graph.component';
@Component({
selector: 'app-block-audit',
templateUrl: './block-audit.component.html',
styleUrls: ['./block-audit.component.scss'],
styles: [`
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
}
`],
})
export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy {
blockAudit: BlockAudit = undefined;
transactions: string[];
auditSubscription: Subscription;
urlFragmentSubscription: Subscription;
paginationMaxSize: number;
page = 1;
itemsPerPage: number;
mode: 'projected' | 'actual' = 'projected';
error: any;
isLoading = true;
webGlEnabled = true;
isMobile = window.innerWidth <= 767.98;
childChangeSubscription: Subscription;
blockHash: string;
numMissing: number = 0;
numUnexpected: number = 0;
@ViewChildren('blockGraphProjected') blockGraphProjected: QueryList<BlockOverviewGraphComponent>;
@ViewChildren('blockGraphActual') blockGraphActual: QueryList<BlockOverviewGraphComponent>;
constructor(
private route: ActivatedRoute,
public stateService: StateService,
private router: Router,
private apiService: ApiService
) {
this.webGlEnabled = detectWebGL();
}
ngOnDestroy() {
this.childChangeSubscription.unsubscribe();
this.urlFragmentSubscription.unsubscribe();
}
ngOnInit(): void {
this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE;
this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => {
if (fragment === 'actual') {
this.mode = 'actual';
} else {
this.mode = 'projected'
}
this.setupBlockGraphs();
});
this.auditSubscription = this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
this.blockHash = params.get('id') || null;
if (!this.blockHash) {
return null;
}
return this.apiService.getBlockAudit$(this.blockHash)
.pipe(
map((response) => {
const blockAudit = response.body;
const inTemplate = {};
const inBlock = {};
const isAdded = {};
const isCensored = {};
const isMissing = {};
const isSelected = {};
this.numMissing = 0;
this.numUnexpected = 0;
for (const tx of blockAudit.template) {
inTemplate[tx.txid] = true;
}
for (const tx of blockAudit.transactions) {
inBlock[tx.txid] = true;
}
for (const txid of blockAudit.addedTxs) {
isAdded[txid] = true;
}
for (const txid of blockAudit.missingTxs) {
isCensored[txid] = true;
}
// set transaction statuses
for (const tx of blockAudit.template) {
if (isCensored[tx.txid]) {
tx.status = 'censored';
} else if (inBlock[tx.txid]) {
tx.status = 'found';
} else {
tx.status = 'missing';
isMissing[tx.txid] = true;
this.numMissing++;
}
}
for (const [index, tx] of blockAudit.transactions.entries()) {
if (isAdded[tx.txid]) {
tx.status = 'added';
} else if (index === 0 || inTemplate[tx.txid]) {
tx.status = 'found';
} else {
tx.status = 'selected';
isSelected[tx.txid] = true;
this.numUnexpected++;
}
}
for (const tx of blockAudit.transactions) {
inBlock[tx.txid] = true;
}
return blockAudit;
})
);
}),
catchError((err) => {
console.log(err);
this.error = err;
this.isLoading = false;
return null;
}),
).subscribe((blockAudit) => {
this.blockAudit = blockAudit;
this.setupBlockGraphs();
this.isLoading = false;
});
}
ngAfterViewInit() {
this.childChangeSubscription = combineLatest([this.blockGraphProjected.changes.pipe(startWith(null)), this.blockGraphActual.changes.pipe(startWith(null))]).subscribe(() => {
this.setupBlockGraphs();
})
}
setupBlockGraphs() {
if (this.blockAudit) {
this.blockGraphProjected.forEach(graph => {
graph.destroy();
if (this.isMobile && this.mode === 'actual') {
graph.setup(this.blockAudit.transactions);
} else {
graph.setup(this.blockAudit.template);
}
})
this.blockGraphActual.forEach(graph => {
graph.destroy();
graph.setup(this.blockAudit.transactions);
})
}
}
onResize(event: any) {
const isMobile = event.target.innerWidth <= 767.98;
const changed = isMobile !== this.isMobile;
this.isMobile = isMobile;
this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5;
if (changed) {
this.changeMode(this.mode);
}
}
changeMode(mode: 'projected' | 'actual') {
this.router.navigate([], { fragment: mode });
}
onTxClick(event: TransactionStripped): void {
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
this.router.navigate([url]);
}
}

View File

@ -10,36 +10,36 @@
</div> </div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats"> <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> <div class="btn-group btn-group-toggle" name="radioBasic">
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 144"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 144" [class.active]="radioGroupForm.get('dateSpan').value === '24h'">
<input ngbButton type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 24h <input type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 24h
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 432"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 432" [class.active]="radioGroupForm.get('dateSpan').value === '3d'">
<input ngbButton type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 3D <input type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 3D
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 1008"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 1008" [class.active]="radioGroupForm.get('dateSpan').value === '1w'">
<input ngbButton type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 1W <input type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 1W
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 4320" [class.active]="radioGroupForm.get('dateSpan').value === '1m'">
<input ngbButton type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 1M <input type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 1M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 12960"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 12960" [class.active]="radioGroupForm.get('dateSpan').value === '3m'">
<input ngbButton type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 3M <input type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 3M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 25920" [class.active]="radioGroupForm.get('dateSpan').value === '6m'">
<input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 6M <input type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 6M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 52560"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'">
<input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 1Y <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 1Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 105120"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'">
<input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 2Y <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 2Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 157680"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'">
<input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> 3Y <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 3Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === 'all'">
<input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"> ALL <input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> ALL
</label> </label>
</div> </div>
</form> </form>

View File

@ -5,7 +5,7 @@ import { map, share, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { formatNumber } from '@angular/common'; import { formatNumber } from '@angular/common';
import { FormBuilder, FormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils'; import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils';
import { StorageService } from '../../services/storage.service'; import { StorageService } from '../../services/storage.service';
import { MiningService } from '../../services/mining.service'; import { MiningService } from '../../services/mining.service';
@ -33,7 +33,7 @@ export class BlockFeeRatesGraphComponent implements OnInit {
@Input() left: number | string = 75; @Input() left: number | string = 75;
miningWindowPreference: string; miningWindowPreference: string;
radioGroupForm: FormGroup; radioGroupForm: UntypedFormGroup;
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
chartInitOptions = { chartInitOptions = {
@ -50,7 +50,7 @@ export class BlockFeeRatesGraphComponent implements OnInit {
@Inject(LOCALE_ID) public locale: string, @Inject(LOCALE_ID) public locale: string,
private seoService: SeoService, private seoService: SeoService,
private apiService: ApiService, private apiService: ApiService,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private storageService: StorageService, private storageService: StorageService,
private miningService: MiningService, private miningService: MiningService,
private stateService: StateService, private stateService: StateService,

View File

@ -10,27 +10,27 @@
</div> </div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats"> <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> <div class="btn-group btn-group-toggle" name="radioBasic">
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 4320" [class.active]="radioGroupForm.get('dateSpan').value === '1m'">
<input ngbButton type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"> 1M <input type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]" formControlName="dateSpan"> 1M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 12960"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 12960" [class.active]="radioGroupForm.get('dateSpan').value === '3m'">
<input ngbButton type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"> 3M <input type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]" formControlName="dateSpan"> 3M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 25920" [class.active]="radioGroupForm.get('dateSpan').value === '6m'">
<input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"> 6M <input type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]" formControlName="dateSpan"> 6M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 52560"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'">
<input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"> 1Y <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]" formControlName="dateSpan"> 1Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 105120"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'">
<input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"> 2Y <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]" formControlName="dateSpan"> 2Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 157680"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'">
<input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"> 3Y <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]" formControlName="dateSpan"> 3Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === 'all'">
<input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"> ALL <input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]" formControlName="dateSpan"> ALL
</label> </label>
</div> </div>
</form> </form>

View File

@ -5,7 +5,7 @@ import { map, share, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { formatCurrency, formatNumber, getCurrencySymbol } from '@angular/common'; import { formatCurrency, formatNumber, getCurrencySymbol } from '@angular/common';
import { FormBuilder, FormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils'; import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils';
import { StorageService } from '../../services/storage.service'; import { StorageService } from '../../services/storage.service';
import { MiningService } from '../../services/mining.service'; import { MiningService } from '../../services/mining.service';
@ -31,7 +31,7 @@ export class BlockFeesGraphComponent implements OnInit {
@Input() left: number | string = 75; @Input() left: number | string = 75;
miningWindowPreference: string; miningWindowPreference: string;
radioGroupForm: FormGroup; radioGroupForm: UntypedFormGroup;
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
chartInitOptions = { chartInitOptions = {
@ -48,7 +48,7 @@ export class BlockFeesGraphComponent implements OnInit {
@Inject(LOCALE_ID) public locale: string, @Inject(LOCALE_ID) public locale: string,
private seoService: SeoService, private seoService: SeoService,
private apiService: ApiService, private apiService: ApiService,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private storageService: StorageService, private storageService: StorageService,
private miningService: MiningService, private miningService: MiningService,
private route: ActivatedRoute, private route: ActivatedRoute,

View File

@ -1,7 +1,8 @@
<div class="block-overview-graph"> <div class="block-overview-graph">
<canvas class="block-overview-canvas" [class.clickable]="!!hoverTx" #blockCanvas></canvas> <canvas class="block-overview-canvas" [class.clickable]="!!hoverTx" #blockCanvas></canvas>
<div class="loader-wrapper" [class.hidden]="!isLoading || disableSpinner"> <div class="loader-wrapper" [class.hidden]="(!isLoading || disableSpinner) && !unavailable">
<div class="spinner-border ml-3 loading" role="status"></div> <div *ngIf="isLoading" class="spinner-border ml-3 loading" role="status"></div>
<div *ngIf="!isLoading && unavailable" class="ml-3" i18n="block.not-available">not available</div>
</div> </div>
<app-block-overview-tooltip <app-block-overview-tooltip

View File

@ -18,7 +18,10 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
@Input() orientation = 'left'; @Input() orientation = 'left';
@Input() flip = true; @Input() flip = true;
@Input() disableSpinner = false; @Input() disableSpinner = false;
@Input() mirrorTxid: string | void;
@Input() unavailable: boolean = false;
@Output() txClickEvent = new EventEmitter<TransactionStripped>(); @Output() txClickEvent = new EventEmitter<TransactionStripped>();
@Output() txHoverEvent = new EventEmitter<string>();
@Output() readyEvent = new EventEmitter(); @Output() readyEvent = new EventEmitter();
@ViewChild('blockCanvas') @ViewChild('blockCanvas')
@ -37,6 +40,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
scene: BlockScene; scene: BlockScene;
hoverTx: TxView | void; hoverTx: TxView | void;
selectedTx: TxView | void; selectedTx: TxView | void;
mirrorTx: TxView | void;
tooltipPosition: Position; tooltipPosition: Position;
readyNextFrame = false; readyNextFrame = false;
@ -63,6 +67,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
this.scene.setOrientation(this.orientation, this.flip); this.scene.setOrientation(this.orientation, this.flip);
} }
} }
if (changes.mirrorTxid) {
this.setMirror(this.mirrorTxid);
}
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@ -76,6 +83,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
this.exit(direction); this.exit(direction);
this.hoverTx = null; this.hoverTx = null;
this.selectedTx = null; this.selectedTx = null;
this.onTxHover(null);
this.start(); this.start();
} }
@ -181,7 +189,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
this.gl.viewport(0, 0, this.displayWidth, this.displayHeight); this.gl.viewport(0, 0, this.displayWidth, this.displayHeight);
} }
if (this.scene) { if (this.scene) {
this.scene.resize({ width: this.displayWidth, height: this.displayHeight }); this.scene.resize({ width: this.displayWidth, height: this.displayHeight, animate: false });
this.start(); this.start();
} else { } else {
this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution,
@ -301,6 +309,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
} }
this.hoverTx = null; this.hoverTx = null;
this.selectedTx = null; this.selectedTx = null;
this.onTxHover(null);
} }
} }
@ -352,17 +361,20 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
this.selectedTx = selected; this.selectedTx = selected;
} else { } else {
this.hoverTx = selected; this.hoverTx = selected;
this.onTxHover(this.hoverTx ? this.hoverTx.txid : null);
} }
} else { } else {
if (clicked) { if (clicked) {
this.selectedTx = null; this.selectedTx = null;
} }
this.hoverTx = null; this.hoverTx = null;
this.onTxHover(null);
} }
} else if (clicked) { } else if (clicked) {
if (selected === this.selectedTx) { if (selected === this.selectedTx) {
this.hoverTx = this.selectedTx; this.hoverTx = this.selectedTx;
this.selectedTx = null; this.selectedTx = null;
this.onTxHover(this.hoverTx ? this.hoverTx.txid : null);
} else { } else {
this.selectedTx = selected; this.selectedTx = selected;
} }
@ -370,6 +382,18 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
} }
} }
setMirror(txid: string | void) {
if (this.mirrorTx) {
this.scene.setHover(this.mirrorTx, false);
this.start();
}
if (txid && this.scene.txs[txid]) {
this.mirrorTx = this.scene.txs[txid];
this.scene.setHover(this.mirrorTx, true);
this.start();
}
}
onTxClick(cssX: number, cssY: number) { onTxClick(cssX: number, cssY: number) {
const x = cssX * window.devicePixelRatio; const x = cssX * window.devicePixelRatio;
const y = cssY * window.devicePixelRatio; const y = cssY * window.devicePixelRatio;
@ -378,6 +402,10 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
this.txClickEvent.emit(selected); this.txClickEvent.emit(selected);
} }
} }
onTxHover(hoverId: string) {
this.txHoverEvent.emit(hoverId);
}
} }
// WebGL shader attributes // WebGL shader attributes

View File

@ -29,7 +29,7 @@ export default class BlockScene {
this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray }); this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray });
} }
resize({ width = this.width, height = this.height }: { width?: number, height?: number}): void { resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void {
this.width = width; this.width = width;
this.height = height; this.height = height;
this.gridSize = this.width / this.gridWidth; this.gridSize = this.width / this.gridWidth;
@ -38,7 +38,7 @@ export default class BlockScene {
this.dirty = true; this.dirty = true;
if (this.initialised && this.scene) { if (this.initialised && this.scene) {
this.updateAll(performance.now(), 50); this.updateAll(performance.now(), 50, 'left', animate);
} }
} }
@ -212,7 +212,7 @@ export default class BlockScene {
this.vbytesPerUnit = blockLimit / Math.pow(resolution / 1.02, 2); this.vbytesPerUnit = blockLimit / Math.pow(resolution / 1.02, 2);
this.gridWidth = resolution; this.gridWidth = resolution;
this.gridHeight = resolution; this.gridHeight = resolution;
this.resize({ width, height }); this.resize({ width, height, animate: true });
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }); this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight });
this.txs = {}; this.txs = {};
@ -225,14 +225,14 @@ export default class BlockScene {
this.animateUntil = Math.max(this.animateUntil, tx.update(update)); this.animateUntil = Math.max(this.animateUntil, tx.update(update));
} }
private updateTx(tx: TxView, startTime: number, delay: number, direction: string = 'left'): void { private updateTx(tx: TxView, startTime: number, delay: number, direction: string = 'left', animate: boolean = true): void {
if (tx.dirty || this.dirty) { if (tx.dirty || this.dirty) {
this.saveGridToScreenPosition(tx); this.saveGridToScreenPosition(tx);
this.setTxOnScreen(tx, startTime, delay, direction); this.setTxOnScreen(tx, startTime, delay, direction, animate);
} }
} }
private setTxOnScreen(tx: TxView, startTime: number, delay: number = 50, direction: string = 'left'): void { private setTxOnScreen(tx: TxView, startTime: number, delay: number = 50, direction: string = 'left', animate: boolean = true): void {
if (!tx.initialised) { if (!tx.initialised) {
const txColor = tx.getColor(); const txColor = tx.getColor();
this.applyTxUpdate(tx, { this.applyTxUpdate(tx, {
@ -252,30 +252,42 @@ export default class BlockScene {
position: tx.screenPosition, position: tx.screenPosition,
color: txColor color: txColor
}, },
duration: 1000, duration: animate ? 1000 : 1,
start: startTime, start: startTime,
delay, delay: animate ? delay : 0,
}); });
} else { } else {
this.applyTxUpdate(tx, { this.applyTxUpdate(tx, {
display: { display: {
position: tx.screenPosition position: tx.screenPosition
}, },
duration: 1000, duration: animate ? 1000 : 0,
minDuration: 500, minDuration: animate ? 500 : 0,
start: startTime, start: startTime,
delay, delay: animate ? delay : 0,
adjust: true adjust: animate
}); });
if (!animate) {
this.applyTxUpdate(tx, {
display: {
position: tx.screenPosition
},
duration: 0,
minDuration: 0,
start: startTime,
delay: 0,
adjust: false
});
}
} }
} }
private updateAll(startTime: number, delay: number = 50, direction: string = 'left'): void { private updateAll(startTime: number, delay: number = 50, direction: string = 'left', animate: boolean = true): void {
this.scene.count = 0; this.scene.count = 0;
const ids = this.getTxList(); const ids = this.getTxList();
startTime = startTime || performance.now(); startTime = startTime || performance.now();
for (const id of ids) { for (const id of ids) {
this.updateTx(this.txs[id], startTime, delay, direction); this.updateTx(this.txs[id], startTime, delay, direction, animate);
} }
this.dirty = false; this.dirty = false;
} }

View File

@ -3,17 +3,18 @@ import { FastVertexArray } from './fast-vertex-array';
import { TransactionStripped } from '../../interfaces/websocket.interface'; import { TransactionStripped } from '../../interfaces/websocket.interface';
import { SpriteUpdateParams, Square, Color, ViewUpdateParams } from './sprite-types'; import { SpriteUpdateParams, Square, Color, ViewUpdateParams } from './sprite-types';
import { feeLevels, mempoolFeeColors } from '../../app.constants'; import { feeLevels, mempoolFeeColors } from '../../app.constants';
import BlockScene from './block-scene';
const hoverTransitionTime = 300; const hoverTransitionTime = 300;
const defaultHoverColor = hexToColor('1bd8f4'); const defaultHoverColor = hexToColor('1bd8f4');
const feeColors = mempoolFeeColors.map(hexToColor); const feeColors = mempoolFeeColors.map(hexToColor);
const auditFeeColors = feeColors.map((color) => desaturate(color, 0.3)); const auditFeeColors = feeColors.map((color) => darken(desaturate(color, 0.3), 0.9));
const auditColors = { const auditColors = {
censored: hexToColor('f344df'), censored: hexToColor('f344df'),
missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7), missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7),
added: hexToColor('03E1E5'), added: hexToColor('0099ff'),
selected: darken(desaturate(hexToColor('039BE5'), 0.3), 0.7), selected: darken(desaturate(hexToColor('0099ff'), 0.3), 0.7),
} }
// convert from this class's update format to TxSprite's update format // convert from this class's update format to TxSprite's update format
@ -34,7 +35,8 @@ export default class TxView implements TransactionStripped {
vsize: number; vsize: number;
value: number; value: number;
feerate: number; feerate: number;
status?: 'found' | 'missing' | 'added' | 'censored' | 'selected'; status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected';
context?: 'projected' | 'actual';
initialised: boolean; initialised: boolean;
vertexArray: FastVertexArray; vertexArray: FastVertexArray;
@ -48,6 +50,7 @@ export default class TxView implements TransactionStripped {
dirty: boolean; dirty: boolean;
constructor(tx: TransactionStripped, vertexArray: FastVertexArray) { constructor(tx: TransactionStripped, vertexArray: FastVertexArray) {
this.context = tx.context;
this.txid = tx.txid; this.txid = tx.txid;
this.fee = tx.fee; this.fee = tx.fee;
this.vsize = tx.vsize; this.vsize = tx.vsize;
@ -159,12 +162,18 @@ export default class TxView implements TransactionStripped {
return auditColors.censored; return auditColors.censored;
case 'missing': case 'missing':
return auditColors.missing; return auditColors.missing;
case 'fresh':
return auditColors.missing;
case 'added': case 'added':
return auditColors.added; return auditColors.added;
case 'selected': case 'selected':
return auditColors.selected; return auditColors.selected;
case 'found': case 'found':
return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1]; if (this.context === 'projected') {
return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1];
} else {
return feeLevelColor;
}
default: default:
return feeLevelColor; return feeLevelColor;
} }

View File

@ -37,9 +37,10 @@
<ng-container [ngSwitch]="tx?.status"> <ng-container [ngSwitch]="tx?.status">
<td *ngSwitchCase="'found'" i18n="transaction.audit.match">match</td> <td *ngSwitchCase="'found'" i18n="transaction.audit.match">match</td>
<td *ngSwitchCase="'censored'" i18n="transaction.audit.removed">removed</td> <td *ngSwitchCase="'censored'" i18n="transaction.audit.removed">removed</td>
<td *ngSwitchCase="'missing'" i18n="transaction.audit.missing">missing</td> <td *ngSwitchCase="'missing'" i18n="transaction.audit.marginal">marginal fee rate</td>
<td *ngSwitchCase="'fresh'" i18n="transaction.audit.recently-broadcast">recently broadcast</td>
<td *ngSwitchCase="'added'" i18n="transaction.audit.added">added</td> <td *ngSwitchCase="'added'" i18n="transaction.audit.added">added</td>
<td *ngSwitchCase="'selected'" i18n="transaction.audit.included">included</td> <td *ngSwitchCase="'selected'" i18n="transaction.audit.marginal">marginal fee rate</td>
</ng-container> </ng-container>
</tr> </tr>
</tbody> </tbody>

View File

@ -10,36 +10,36 @@
</div> </div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats"> <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> <div class="btn-group btn-group-toggle" name="radioBasic">
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 144"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 144" [class.active]="radioGroupForm.get('dateSpan').value === '24h'">
<input ngbButton type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 24h <input type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 24h
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 432"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 432" [class.active]="radioGroupForm.get('dateSpan').value === '3d'">
<input ngbButton type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 3D <input type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 3D
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 1008"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 1008" [class.active]="radioGroupForm.get('dateSpan').value === '1w'">
<input ngbButton type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 1W <input type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 1W
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 4320" [class.active]="radioGroupForm.get('dateSpan').value === '1m'">
<input ngbButton type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 1M <input type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 1M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 12960"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 12960" [class.active]="radioGroupForm.get('dateSpan').value === '3m'">
<input ngbButton type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 3M <input type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 3M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 25920" [class.active]="radioGroupForm.get('dateSpan').value === '6m'">
<input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 6M <input type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 6M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 52560"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'">
<input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 1Y <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 1Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 105120"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'">
<input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 2Y <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 2Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 157680"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'">
<input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> 3Y <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> 3Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount > 157680"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount > 157680" [class.active]="radioGroupForm.get('dateSpan').value === 'all'">
<input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]"> ALL <input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" formControlName="dateSpan"> ALL
</label> </label>
</div> </div>
</form> </form>

View File

@ -5,7 +5,7 @@ import { map, share, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { formatNumber } from '@angular/common'; import { formatNumber } from '@angular/common';
import { FormBuilder, FormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils'; import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils';
import { StorageService } from '../../services/storage.service'; import { StorageService } from '../../services/storage.service';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@ -31,7 +31,7 @@ export class BlockPredictionGraphComponent implements OnInit {
@Input() left: number | string = 75; @Input() left: number | string = 75;
miningWindowPreference: string; miningWindowPreference: string;
radioGroupForm: FormGroup; radioGroupForm: UntypedFormGroup;
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
chartInitOptions = { chartInitOptions = {
@ -48,7 +48,7 @@ export class BlockPredictionGraphComponent implements OnInit {
@Inject(LOCALE_ID) public locale: string, @Inject(LOCALE_ID) public locale: string,
private seoService: SeoService, private seoService: SeoService,
private apiService: ApiService, private apiService: ApiService,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private storageService: StorageService, private storageService: StorageService,
private zone: NgZone, private zone: NgZone,
private route: ActivatedRoute, private route: ActivatedRoute,

View File

@ -11,27 +11,27 @@
</div> </div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats"> <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> <div class="btn-group btn-group-toggle" name="radioBasic">
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 4320" [class.active]="radioGroupForm.get('dateSpan').value === '1m'">
<input ngbButton type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> 1M <input type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" formControlName="dateSpan"> 1M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 12960"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 12960" [class.active]="radioGroupForm.get('dateSpan').value === '3m'">
<input ngbButton type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> 3M <input type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" formControlName="dateSpan"> 3M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 25920" [class.active]="radioGroupForm.get('dateSpan').value === '6m'">
<input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> 6M <input type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" formControlName="dateSpan"> 6M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 52560"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'">
<input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> 1Y <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" formControlName="dateSpan"> 1Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 105120"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'">
<input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> 2Y <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" formControlName="dateSpan"> 2Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 157680"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'">
<input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> 3Y <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" formControlName="dateSpan"> 3Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === 'all'">
<input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> ALL <input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"> ALL
</label> </label>
</div> </div>
</form> </form>

View File

@ -5,7 +5,7 @@ import { map, share, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { formatCurrency, formatNumber, getCurrencySymbol } from '@angular/common'; import { formatCurrency, formatNumber, getCurrencySymbol } from '@angular/common';
import { FormBuilder, FormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils'; import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils';
import { MiningService } from '../../services/mining.service'; import { MiningService } from '../../services/mining.service';
import { StorageService } from '../../services/storage.service'; import { StorageService } from '../../services/storage.service';
@ -31,7 +31,7 @@ export class BlockRewardsGraphComponent implements OnInit {
@Input() left: number | string = 75; @Input() left: number | string = 75;
miningWindowPreference: string; miningWindowPreference: string;
radioGroupForm: FormGroup; radioGroupForm: UntypedFormGroup;
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
chartInitOptions = { chartInitOptions = {
@ -48,7 +48,7 @@ export class BlockRewardsGraphComponent implements OnInit {
@Inject(LOCALE_ID) public locale: string, @Inject(LOCALE_ID) public locale: string,
private seoService: SeoService, private seoService: SeoService,
private apiService: ApiService, private apiService: ApiService,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private miningService: MiningService, private miningService: MiningService,
private storageService: StorageService, private storageService: StorageService,
private route: ActivatedRoute, private route: ActivatedRoute,

View File

@ -9,36 +9,36 @@
</div> </div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(blockSizesWeightsObservable$ | async) as stats"> <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(blockSizesWeightsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> <div class="btn-group btn-group-toggle" name="radioBasic">
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 144"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 144" [class.active]="radioGroupForm.get('dateSpan').value === '24h'">
<input ngbButton type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 24h <input type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 24h
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 432"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 432" [class.active]="radioGroupForm.get('dateSpan').value === '3d'">
<input ngbButton type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 3D <input type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 3D
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 1008"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 1008" [class.active]="radioGroupForm.get('dateSpan').value === '1w'">
<input ngbButton type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 1W <input type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 1W
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 4320" [class.active]="radioGroupForm.get('dateSpan').value === '1m'">
<input ngbButton type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 1M <input type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 1M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 12960"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 12960" [class.active]="radioGroupForm.get('dateSpan').value === '3m'">
<input ngbButton type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 3M <input type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 3M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 25920" [class.active]="radioGroupForm.get('dateSpan').value === '6m'">
<input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 6M <input type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 6M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 52560"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'">
<input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 1Y <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 1Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 105120"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'">
<input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 2Y <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 2Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 157680"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'">
<input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> 3Y <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 3Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === 'all'">
<input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]"> ALL <input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> ALL
</label> </label>
</div> </div>
</form> </form>

View File

@ -5,7 +5,7 @@ import { map, share, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { formatNumber } from '@angular/common'; import { formatNumber } from '@angular/common';
import { FormBuilder, FormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { StorageService } from '../../services/storage.service'; import { StorageService } from '../../services/storage.service';
import { MiningService } from '../../services/mining.service'; import { MiningService } from '../../services/mining.service';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
@ -30,7 +30,7 @@ export class BlockSizesWeightsGraphComponent implements OnInit {
@Input() left: number | string = 75; @Input() left: number | string = 75;
miningWindowPreference: string; miningWindowPreference: string;
radioGroupForm: FormGroup; radioGroupForm: UntypedFormGroup;
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
chartInitOptions = { chartInitOptions = {
@ -49,7 +49,7 @@ export class BlockSizesWeightsGraphComponent implements OnInit {
@Inject(LOCALE_ID) public locale: string, @Inject(LOCALE_ID) public locale: string,
private seoService: SeoService, private seoService: SeoService,
private apiService: ApiService, private apiService: ApiService,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private storageService: StorageService, private storageService: StorageService,
private miningService: MiningService, private miningService: MiningService,
private route: ActivatedRoute, private route: ActivatedRoute,

View File

@ -2,7 +2,7 @@
<div class="title-block" [class.time-ltr]="timeLtr" id="block"> <div class="title-block" [class.time-ltr]="timeLtr" id="block">
<h1> <h1>
<ng-container *ngIf="blockHeight > 0; else genesis" i18n="shared.block-title">Block</ng-container> <ng-container *ngIf="blockHeight == null || blockHeight > 0; else genesis" i18n="shared.block-title">Block</ng-container>
<ng-template #genesis i18n="@@2303359202781425764">Genesis</ng-template> <ng-template #genesis i18n="@@2303359202781425764">Genesis</ng-template>
<span class="next-previous-blocks"> <span class="next-previous-blocks">
<a *ngIf="showNextBlocklink" class="nav-arrow next" [routerLink]="['/block/' | relativeUrl, nextBlockHeight]" (click)="navigateToNextBlock()" i18n-ngbTooltip="Next Block" ngbTooltip="Next Block" placement="bottom"> <a *ngIf="showNextBlocklink" class="nav-arrow next" [routerLink]="['/block/' | relativeUrl, nextBlockHeight]" (click)="navigateToNextBlock()" i18n-ngbTooltip="Next Block" ngbTooltip="Next Block" placement="bottom">
@ -54,7 +54,19 @@
<td i18n="block.weight">Weight</td> <td i18n="block.weight">Weight</td>
<td [innerHTML]="'&lrm;' + (block.weight | wuBytes: 2)"></td> <td [innerHTML]="'&lrm;' + (block.weight | wuBytes: 2)"></td>
</tr> </tr>
<ng-template [ngIf]="webGlEnabled"> <tr *ngIf="auditEnabled">
<td i18n="block.health">Block health</td>
<td>
<span *ngIf="blockAudit?.matchRate != null">{{ blockAudit.matchRate }}%</span>
<span *ngIf="blockAudit?.matchRate === null" i18n="unknown">Unknown</span>
</td>
</tr>
<ng-container *ngIf="!indexingAvailable && webGlEnabled">
<tr *ngIf="isMobile && auditEnabled"></tr>
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<td i18n="mempool-block.fee-span">Fee span</td>
<td><span>{{ block.extras.feeRange[0] | number:'1.0-0' }} - {{ block.extras.feeRange[block.extras.feeRange.length - 1] | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span></td>
</tr>
<tr *ngIf="block?.extras?.medianFee != undefined"> <tr *ngIf="block?.extras?.medianFee != undefined">
<td class="td-width" i18n="block.median-fee">Median fee</td> <td class="td-width" i18n="block.median-fee">Median fee</td>
<td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td> <td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td>
@ -98,26 +110,19 @@
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<td i18n="block.miner">Miner</td> <td i18n="block.miner">Miner</td>
<td *ngIf="stateService.env.MINING_DASHBOARD"> <td *ngIf="stateService.env.MINING_DASHBOARD">
<a [attr.data-cy]="'block-details-miner-badge'" placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge" <a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge"
[class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'"> [class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'">
{{ block.extras.pool.name }} {{ block.extras.pool.name }}
</a> </a>
</td> </td>
<td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'"> <td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'">
<span [attr.data-cy]="'block-details-miner-badge'" placement="bottom" class="badge" <span placement="bottom" class="badge"
[class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'"> [class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'">
{{ block.extras.pool.name }} {{ block.extras.pool.name }}
</span> </span>
</td> </td>
</tr> </tr>
<tr *ngIf="indexingAvailable"> </ng-container>
<td i18n="block.health">Block health</td>
<td>
<a *ngIf="block.extras?.matchRate != null" [routerLink]="['/block-audit/' | relativeUrl, blockHash]">{{ block.extras.matchRate }}%</a>
<span *ngIf="block.extras?.matchRate == null" i18n="unknown">Unknown</span>
</td>
</tr>
</ng-template>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -138,7 +143,11 @@
<tr> <tr>
<td colspan="2"><span class="skeleton-loader"></span></td> <td colspan="2"><span class="skeleton-loader"></span></td>
</tr> </tr>
<ng-template [ngIf]="webGlEnabled"> <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<ng-container *ngIf="!indexingAvailable && webGlEnabled">
<tr *ngIf="isMobile && !auditEnabled"></tr>
<tr> <tr>
<td class="td-width" colspan="2"><span class="skeleton-loader"></span></td> <td class="td-width" colspan="2"><span class="skeleton-loader"></span></td>
</tr> </tr>
@ -148,17 +157,25 @@
<tr> <tr>
<td colspan="2"><span class="skeleton-loader"></span></td> <td colspan="2"><span class="skeleton-loader"></span></td>
</tr> </tr>
<tr> <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<td colspan="2"><span class="skeleton-loader"></span></td> <td colspan="2"><span class="skeleton-loader"></span></td>
</tr> </tr>
</ng-template> <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
</ng-container>
</tbody> </tbody>
</table> </table>
</div> </div>
</ng-template> </ng-template>
<div class="col-sm" *ngIf="!webGlEnabled"> <div class="col-sm">
<table class="table table-borderless table-striped" *ngIf="!isLoadingBlock"> <table class="table table-borderless table-striped" *ngIf="!isLoadingBlock && (indexingAvailable || !webGlEnabled)">
<tbody> <tbody>
<tr *ngIf="isMobile && auditEnabled"></tr>
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<td i18n="mempool-block.fee-span">Fee span</td>
<td><span>{{ block.extras.feeRange[0] | number:'1.0-0' }} - {{ block.extras.feeRange[block.extras.feeRange.length - 1] | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span></td>
</tr>
<tr *ngIf="block?.extras?.medianFee != undefined"> <tr *ngIf="block?.extras?.medianFee != undefined">
<td class="td-width" i18n="block.median-fee">Median fee</td> <td class="td-width" i18n="block.median-fee">Median fee</td>
<td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td> <td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td>
@ -216,8 +233,9 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<table class="table table-borderless table-striped" *ngIf="isLoadingBlock"> <table class="table table-borderless table-striped" *ngIf="isLoadingBlock && (indexingAvailable || !webGlEnabled)">
<tbody> <tbody>
<tr *ngIf="isMobile && !auditEnabled"></tr>
<tr> <tr>
<td class="td-width" colspan="2"><span class="skeleton-loader"></span></td> <td class="td-width" colspan="2"><span class="skeleton-loader"></span></td>
</tr> </tr>
@ -230,22 +248,54 @@
<tr> <tr>
<td colspan="2"><span class="skeleton-loader"></span></td> <td colspan="2"><span class="skeleton-loader"></span></td>
</tr> </tr>
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
</tbody> </tbody>
</table> </table>
</div> <div class="col-sm chart-container" *ngIf="webGlEnabled && !indexingAvailable">
<div class="col-sm chart-container" *ngIf="webGlEnabled"> <app-block-overview-graph
<app-block-overview-graph #blockGraphActual
#blockGraph [isLoading]="isLoadingOverview"
[isLoading]="isLoadingOverview" [resolution]="75"
[resolution]="75" [blockLimit]="stateService.blockVSize"
[blockLimit]="stateService.blockVSize" [orientation]="'top'"
[orientation]="'top'" [flip]="false"
[flip]="false" (txClickEvent)="onTxClick($event)"
(txClickEvent)="onTxClick($event)" ></app-block-overview-graph>
></app-block-overview-graph> </div>
</div> </div>
</div> </div>
</div> </div>
<span id="overview"></span>
<br>
<!-- VISUALIZATIONS -->
<div class="box" *ngIf="!error && webGlEnabled && indexingAvailable">
<div class="nav nav-tabs" *ngIf="isMobile && auditEnabled">
<a class="nav-link" [class.active]="mode === 'projected'" i18n="block.projected"
fragment="projected" (click)="changeMode('projected')">Projected</a>
<a class="nav-link" [class.active]="mode === 'actual'" i18n="block.actual"
fragment="actual" (click)="changeMode('actual')">Actual</a>
</div>
<div class="row">
<div class="col-sm">
<h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.projected-block">Projected Block</h3>
<app-block-overview-graph #blockGraphProjected [isLoading]="isLoadingOverview" [resolution]="75"
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" [mirrorTxid]="hoverTx"
(txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($event)" [unavailable]="!isMobile && !auditEnabled"></app-block-overview-graph>
</div>
<div class="col-sm" *ngIf="!isMobile">
<h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.actual-block">Actual Block</h3>
<app-block-overview-graph #blockGraphActual [isLoading]="isLoadingOverview" [resolution]="75"
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" [mirrorTxid]="hoverTx" mode="mined"
(txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($event)" [unavailable]="isMobile && !auditEnabled"></app-block-overview-graph>
</div>
</div>
</div>
<ng-template [ngIf]="!isLoadingBlock && !error"> <ng-template [ngIf]="!isLoadingBlock && !error">
<div [hidden]="!showDetails" id="details"> <div [hidden]="!showDetails" id="details">
<br> <br>
@ -273,6 +323,7 @@
<div class="col-sm" *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> <div class="col-sm" *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<table class="table table-borderless table-striped"> <table class="table table-borderless table-striped">
<tbody> <tbody>
<tr *ngIf="isMobile"></tr>
<tr> <tr>
<td class="td-width" i18n="block.difficulty">Difficulty</td> <td class="td-width" i18n="block.difficulty">Difficulty</td>
<td>{{ block.difficulty }}</td> <td>{{ block.difficulty }}</td>

View File

@ -171,3 +171,35 @@ h1 {
margin: auto; margin: auto;
} }
} }
.menu-button {
@media (min-width: 768px) {
max-width: 150px;
}
}
.block-subtitle {
text-align: center;
}
.nav-tabs {
border-color: white;
border-width: 1px;
}
.nav-tabs .nav-link {
background: inherit;
border-width: 1px;
border-bottom: none;
border-color: transparent;
margin-bottom: -1px;
cursor: pointer;
&.active {
background: #24273e;
}
&.active, &:hover {
border-color: white;
}
}

View File

@ -1,15 +1,15 @@
import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; import { Component, OnInit, OnDestroy, ViewChildren, QueryList } from '@angular/core';
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service'; import { ElectrsApiService } from '../../services/electrs-api.service';
import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise } from 'rxjs/operators'; import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise, filter } from 'rxjs/operators';
import { Transaction, Vout } from '../../interfaces/electrs.interface'; import { Transaction, Vout } from '../../interfaces/electrs.interface';
import { Observable, of, Subscription, asyncScheduler, EMPTY } from 'rxjs'; import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest } from 'rxjs';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { WebsocketService } from '../../services/websocket.service'; import { WebsocketService } from '../../services/websocket.service';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; import { BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component';
import { detectWebGL } from '../../shared/graphs.utils'; import { detectWebGL } from '../../shared/graphs.utils';
@ -17,11 +17,20 @@ import { detectWebGL } from '../../shared/graphs.utils';
@Component({ @Component({
selector: 'app-block', selector: 'app-block',
templateUrl: './block.component.html', templateUrl: './block.component.html',
styleUrls: ['./block.component.scss'] styleUrls: ['./block.component.scss'],
styles: [`
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
}
`],
}) })
export class BlockComponent implements OnInit, OnDestroy { export class BlockComponent implements OnInit, OnDestroy {
network = ''; network = '';
block: BlockExtended; block: BlockExtended;
blockAudit: BlockAudit = undefined;
blockHeight: number; blockHeight: number;
lastBlockHeight: number; lastBlockHeight: number;
nextBlockHeight: number; nextBlockHeight: number;
@ -48,9 +57,16 @@ export class BlockComponent implements OnInit, OnDestroy {
overviewError: any = null; overviewError: any = null;
webGlEnabled = true; webGlEnabled = true;
indexingAvailable = false; indexingAvailable = false;
auditEnabled = true;
isMobile = window.innerWidth <= 767.98;
hoverTx: string;
numMissing: number = 0;
numUnexpected: number = 0;
mode: 'projected' | 'actual' = 'projected';
transactionSubscription: Subscription; transactionSubscription: Subscription;
overviewSubscription: Subscription; overviewSubscription: Subscription;
auditSubscription: Subscription;
keyNavigationSubscription: Subscription; keyNavigationSubscription: Subscription;
blocksSubscription: Subscription; blocksSubscription: Subscription;
networkChangedSubscription: Subscription; networkChangedSubscription: Subscription;
@ -60,8 +76,10 @@ export class BlockComponent implements OnInit, OnDestroy {
nextBlockTxListSubscription: Subscription = undefined; nextBlockTxListSubscription: Subscription = undefined;
timeLtrSubscription: Subscription; timeLtrSubscription: Subscription;
timeLtr: boolean; timeLtr: boolean;
childChangeSubscription: Subscription;
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; @ViewChildren('blockGraphProjected') blockGraphProjected: QueryList<BlockOverviewGraphComponent>;
@ViewChildren('blockGraphActual') blockGraphActual: QueryList<BlockOverviewGraphComponent>;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@ -87,8 +105,8 @@ export class BlockComponent implements OnInit, OnDestroy {
this.timeLtr = !!ltr; this.timeLtr = !!ltr;
}); });
this.indexingAvailable = (this.stateService.env.BASE_MODULE === 'mempool' && this.indexingAvailable = (this.stateService.env.BASE_MODULE === 'mempool' && this.stateService.env.MINING_DASHBOARD === true);
this.stateService.env.MINING_DASHBOARD === true); this.auditEnabled = this.indexingAvailable;
this.txsLoadingStatus$ = this.route.paramMap this.txsLoadingStatus$ = this.route.paramMap
.pipe( .pipe(
@ -192,7 +210,7 @@ export class BlockComponent implements OnInit, OnDestroy {
setTimeout(() => { setTimeout(() => {
this.nextBlockSubscription = this.apiService.getBlock$(block.previousblockhash).subscribe(); this.nextBlockSubscription = this.apiService.getBlock$(block.previousblockhash).subscribe();
this.nextBlockTxListSubscription = this.electrsApiService.getBlockTransactions$(block.previousblockhash).subscribe(); this.nextBlockTxListSubscription = this.electrsApiService.getBlockTransactions$(block.previousblockhash).subscribe();
this.nextBlockSummarySubscription = this.apiService.getStrippedBlockTransactions$(block.previousblockhash).subscribe(); this.apiService.getBlockAudit$(block.previousblockhash);
}, 100); }, 100);
} }
@ -240,40 +258,126 @@ export class BlockComponent implements OnInit, OnDestroy {
this.isLoadingOverview = false; this.isLoadingOverview = false;
}); });
this.overviewSubscription = block$.pipe( if (!this.indexingAvailable) {
startWith(null), this.overviewSubscription = block$.pipe(
pairwise(), startWith(null),
switchMap(([prevBlock, block]) => this.apiService.getStrippedBlockTransactions$(block.id) pairwise(),
.pipe( switchMap(([prevBlock, block]) => this.apiService.getStrippedBlockTransactions$(block.id)
catchError((err) => { .pipe(
this.overviewError = err; catchError((err) => {
return of([]); this.overviewError = err;
}), return of([]);
switchMap((transactions) => { }),
if (prevBlock) { switchMap((transactions) => {
return of({ transactions, direction: (prevBlock.height < block.height) ? 'right' : 'left' }); if (prevBlock) {
} else { return of({ transactions, direction: (prevBlock.height < block.height) ? 'right' : 'left' });
return of({ transactions, direction: 'down' }); } else {
return of({ transactions, direction: 'down' });
}
})
)
),
)
.subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => {
this.strippedTransactions = transactions;
this.isLoadingOverview = false;
this.setupBlockGraphs();
},
(error) => {
this.error = error;
this.isLoadingOverview = false;
});
}
if (this.indexingAvailable) {
this.auditSubscription = block$.pipe(
startWith(null),
pairwise(),
switchMap(([prevBlock, block]) => this.apiService.getBlockAudit$(block.id)
.pipe(
catchError((err) => {
this.overviewError = err;
return of([]);
})
)
),
filter((response) => response != null),
map((response) => {
const blockAudit = response.body;
const inTemplate = {};
const inBlock = {};
const isAdded = {};
const isCensored = {};
const isMissing = {};
const isSelected = {};
const isFresh = {};
this.numMissing = 0;
this.numUnexpected = 0;
if (blockAudit?.template) {
for (const tx of blockAudit.template) {
inTemplate[tx.txid] = true;
} }
}) for (const tx of blockAudit.transactions) {
) inBlock[tx.txid] = true;
), }
) for (const txid of blockAudit.addedTxs) {
.subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => { isAdded[txid] = true;
this.strippedTransactions = transactions; }
this.isLoadingOverview = false; for (const txid of blockAudit.missingTxs) {
if (this.blockGraph) { isCensored[txid] = true;
this.blockGraph.destroy(); }
this.blockGraph.setup(this.strippedTransactions); for (const txid of blockAudit.freshTxs || []) {
} isFresh[txid] = true;
}, }
(error) => { // set transaction statuses
this.error = error; for (const tx of blockAudit.template) {
this.isLoadingOverview = false; tx.context = 'projected';
if (this.blockGraph) { if (isCensored[tx.txid]) {
this.blockGraph.destroy(); tx.status = 'censored';
} } else if (inBlock[tx.txid]) {
}); tx.status = 'found';
} else {
tx.status = isFresh[tx.txid] ? 'fresh' : 'missing';
isMissing[tx.txid] = true;
this.numMissing++;
}
}
for (const [index, tx] of blockAudit.transactions.entries()) {
tx.context = 'actual';
if (index === 0) {
tx.status = null;
} else if (isAdded[tx.txid]) {
tx.status = 'added';
} else if (inTemplate[tx.txid]) {
tx.status = 'found';
} else {
tx.status = 'selected';
isSelected[tx.txid] = true;
this.numUnexpected++;
}
}
for (const tx of blockAudit.transactions) {
inBlock[tx.txid] = true;
}
this.auditEnabled = true;
} else {
this.auditEnabled = false;
}
return blockAudit;
}),
catchError((err) => {
console.log(err);
this.error = err;
this.isLoadingOverview = false;
return of(null);
}),
).subscribe((blockAudit) => {
this.blockAudit = blockAudit;
this.setupBlockGraphs();
this.isLoadingOverview = false;
});
}
this.networkChangedSubscription = this.stateService.networkChanged$ this.networkChangedSubscription = this.stateService.networkChanged$
.subscribe((network) => this.network = network); .subscribe((network) => this.network = network);
@ -284,6 +388,12 @@ export class BlockComponent implements OnInit, OnDestroy {
} else { } else {
this.showDetails = false; this.showDetails = false;
} }
if (params.view === 'projected') {
this.mode = 'projected';
} else {
this.mode = 'actual';
}
this.setupBlockGraphs();
}); });
this.keyNavigationSubscription = this.stateService.keyNavigation$.subscribe((event) => { this.keyNavigationSubscription = this.stateService.keyNavigation$.subscribe((event) => {
@ -302,16 +412,24 @@ export class BlockComponent implements OnInit, OnDestroy {
}); });
} }
ngAfterViewInit(): void {
this.childChangeSubscription = combineLatest([this.blockGraphProjected.changes.pipe(startWith(null)), this.blockGraphActual.changes.pipe(startWith(null))]).subscribe(() => {
this.setupBlockGraphs();
});
}
ngOnDestroy() { ngOnDestroy() {
this.stateService.markBlock$.next({}); this.stateService.markBlock$.next({});
this.transactionSubscription.unsubscribe(); this.transactionSubscription.unsubscribe();
this.overviewSubscription.unsubscribe(); this.overviewSubscription?.unsubscribe();
this.auditSubscription?.unsubscribe();
this.keyNavigationSubscription.unsubscribe(); this.keyNavigationSubscription.unsubscribe();
this.blocksSubscription.unsubscribe(); this.blocksSubscription.unsubscribe();
this.networkChangedSubscription.unsubscribe(); this.networkChangedSubscription.unsubscribe();
this.queryParamsSubscription.unsubscribe(); this.queryParamsSubscription.unsubscribe();
this.timeLtrSubscription.unsubscribe(); this.timeLtrSubscription.unsubscribe();
this.unsubscribeNextBlockSubscriptions(); this.unsubscribeNextBlockSubscriptions();
this.childChangeSubscription.unsubscribe();
} }
unsubscribeNextBlockSubscriptions() { unsubscribeNextBlockSubscriptions() {
@ -358,7 +476,7 @@ export class BlockComponent implements OnInit, OnDestroy {
this.showDetails = false; this.showDetails = false;
this.router.navigate([], { this.router.navigate([], {
relativeTo: this.route, relativeTo: this.route,
queryParams: { showDetails: false }, queryParams: { showDetails: false, view: this.mode },
queryParamsHandling: 'merge', queryParamsHandling: 'merge',
fragment: 'block' fragment: 'block'
}); });
@ -366,7 +484,7 @@ export class BlockComponent implements OnInit, OnDestroy {
this.showDetails = true; this.showDetails = true;
this.router.navigate([], { this.router.navigate([], {
relativeTo: this.route, relativeTo: this.route,
queryParams: { showDetails: true }, queryParams: { showDetails: true, view: this.mode },
queryParamsHandling: 'merge', queryParamsHandling: 'merge',
fragment: 'details' fragment: 'details'
}); });
@ -385,10 +503,6 @@ export class BlockComponent implements OnInit, OnDestroy {
return this.block && this.block.height > 681393 && (new Date().getTime() / 1000) < 1628640000; return this.block && this.block.height > 681393 && (new Date().getTime() / 1000) < 1628640000;
} }
onResize(event: any) {
this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5;
}
navigateToPreviousBlock() { navigateToPreviousBlock() {
if (!this.block) { if (!this.block) {
return; return;
@ -419,8 +533,53 @@ export class BlockComponent implements OnInit, OnDestroy {
} }
} }
setupBlockGraphs(): void {
if (this.blockAudit || this.strippedTransactions) {
this.blockGraphProjected.forEach(graph => {
graph.destroy();
if (this.isMobile && this.mode === 'actual') {
graph.setup(this.blockAudit?.transactions || this.strippedTransactions || []);
} else {
graph.setup(this.blockAudit?.template || []);
}
});
this.blockGraphActual.forEach(graph => {
graph.destroy();
graph.setup(this.blockAudit?.transactions || this.strippedTransactions || []);
});
}
}
onResize(event: any): void {
const isMobile = event.target.innerWidth <= 767.98;
const changed = isMobile !== this.isMobile;
this.isMobile = isMobile;
this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5;
if (changed) {
this.changeMode(this.mode);
}
}
changeMode(mode: 'projected' | 'actual'): void {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { showDetails: this.showDetails, view: mode },
queryParamsHandling: 'merge',
fragment: 'overview'
});
}
onTxClick(event: TransactionStripped): void { onTxClick(event: TransactionStripped): void {
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
this.router.navigate([url]); this.router.navigate([url]);
} }
onTxHover(txid: string): void {
if (txid && txid.length) {
this.hoverTx = txid;
} else {
this.hoverTx = null;
}
}
} }

View File

@ -46,22 +46,16 @@
<app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since> <app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since>
</td> </td>
<td *ngIf="indexingAvailable" class="health text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"> <td *ngIf="indexingAvailable" class="health text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<a *ngIf="block.extras?.matchRate != null" class="clear-link" [routerLink]="['/block-audit/' | relativeUrl, block.id]"> <a class="clear-link" [routerLink]="auditScores[block.id] != null ? ['/block/' | relativeUrl, block.id] : null">
<div class="progress progress-health"> <div class="progress progress-health">
<div class="progress-bar progress-bar-health" role="progressbar" <div class="progress-bar progress-bar-health" role="progressbar"
[ngStyle]="{'width': (100 - (block.extras?.matchRate || 0)) + '%' }"></div> [ngStyle]="{'width': (100 - (auditScores[block.id] || 0)) + '%' }"></div>
<div class="progress-text"> <div class="progress-text">
<span>{{ block.extras.matchRate }}%</span> <span *ngIf="auditScores[block.id] != null;">{{ auditScores[block.id] }}%</span>
<span *ngIf="auditScores[block.id] == null">~</span>
</div> </div>
</div> </div>
</a> </a>
<div *ngIf="block.extras?.matchRate == null" class="progress progress-health">
<div class="progress-bar progress-bar-health" role="progressbar"
[ngStyle]="{'width': '100%' }"></div>
<div class="progress-text">
<span>~</span>
</div>
</div>
</td> </td>
<td *ngIf="indexingAvailable" class="reward text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"> <td *ngIf="indexingAvailable" class="reward text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<app-amount [satoshis]="block.extras.reward" [noFiat]="true" digitsInfo="1.2-2"></app-amount> <app-amount [satoshis]="block.extras.reward" [noFiat]="true" digitsInfo="1.2-2"></app-amount>

View File

@ -196,6 +196,10 @@ tr, td, th {
@media (max-width: 950px) { @media (max-width: 950px) {
display: none; display: none;
} }
.progress-text .skeleton-loader {
top: -8.5px;
}
} }
.health.widget { .health.widget {
width: 25%; width: 25%;

View File

@ -1,6 +1,6 @@
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core'; import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input } from '@angular/core';
import { BehaviorSubject, combineLatest, concat, Observable, timer } from 'rxjs'; import { BehaviorSubject, combineLatest, concat, Observable, timer, EMPTY, Subscription, of } from 'rxjs';
import { delayWhen, map, retryWhen, scan, skip, switchMap, tap } from 'rxjs/operators'; import { catchError, delayWhen, map, retryWhen, scan, skip, switchMap, tap } from 'rxjs/operators';
import { BlockExtended } from '../../interfaces/node-api.interface'; import { BlockExtended } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
@ -12,10 +12,14 @@ import { WebsocketService } from '../../services/websocket.service';
styleUrls: ['./blocks-list.component.scss'], styleUrls: ['./blocks-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class BlocksList implements OnInit { export class BlocksList implements OnInit, OnDestroy {
@Input() widget: boolean = false; @Input() widget: boolean = false;
blocks$: Observable<BlockExtended[]> = undefined; blocks$: Observable<BlockExtended[]> = undefined;
auditScores: { [hash: string]: number | void } = {};
auditScoreSubscription: Subscription;
latestScoreSubscription: Subscription;
indexingAvailable = false; indexingAvailable = false;
isLoading = true; isLoading = true;
@ -105,6 +109,53 @@ export class BlocksList implements OnInit {
return acc; return acc;
}, []) }, [])
); );
if (this.indexingAvailable) {
this.auditScoreSubscription = this.fromHeightSubject.pipe(
switchMap((fromBlockHeight) => {
return this.apiService.getBlockAuditScores$(this.page === 1 ? undefined : fromBlockHeight)
.pipe(
catchError(() => {
return EMPTY;
})
);
})
).subscribe((scores) => {
Object.values(scores).forEach(score => {
this.auditScores[score.hash] = score?.matchRate != null ? score.matchRate : null;
});
});
this.latestScoreSubscription = this.stateService.blocks$.pipe(
switchMap((block) => {
if (block[0]?.extras?.matchRate != null) {
return of({
hash: block[0].id,
matchRate: block[0]?.extras?.matchRate,
});
}
else if (block[0]?.id && this.auditScores[block[0].id] === undefined) {
return this.apiService.getBlockAuditScore$(block[0].id)
.pipe(
catchError(() => {
return EMPTY;
})
);
} else {
return EMPTY;
}
}),
).subscribe((score) => {
if (score && score.hash) {
this.auditScores[score.hash] = score?.matchRate != null ? score.matchRate : null;
}
});
}
}
ngOnDestroy(): void {
this.auditScoreSubscription?.unsubscribe();
this.latestScoreSubscription?.unsubscribe();
} }
pageChange(page: number) { pageChange(page: number) {

View File

@ -31,24 +31,24 @@
</div> </div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats"> <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> <div class="btn-group btn-group-toggle" name="radioBasic">
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 12960"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 12960" [class.active]="radioGroupForm.get('dateSpan').value === '3m'">
<input ngbButton type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'3m'"> 3M <input type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'3m'" formControlName="dateSpan"> 3M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 25920" [class.active]="radioGroupForm.get('dateSpan').value === '6m'">
<input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'6m'"> 6M <input type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'6m'" formControlName="dateSpan"> 6M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 52560"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'">
<input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'1y'"> 1Y <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'1y'" formControlName="dateSpan"> 1Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 105120"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'">
<input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'2y'"> 2Y <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'2y'" formControlName="dateSpan"> 2Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 157680"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'">
<input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'3y'"> 3Y <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'3y'" formControlName="dateSpan"> 3Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === 'all'">
<input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'all'"> ALL <input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'all'" formControlName="dateSpan"> ALL
</label> </label>
</div> </div>
</form> </form>

View File

@ -5,7 +5,7 @@ import { map, share, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { formatNumber } from '@angular/common'; import { formatNumber } from '@angular/common';
import { FormBuilder, FormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { selectPowerOfTen } from '../../bitcoin.utils'; import { selectPowerOfTen } from '../../bitcoin.utils';
import { StorageService } from '../../services/storage.service'; import { StorageService } from '../../services/storage.service';
import { MiningService } from '../../services/mining.service'; import { MiningService } from '../../services/mining.service';
@ -34,7 +34,7 @@ export class HashrateChartComponent implements OnInit {
@Input() left: number | string = 75; @Input() left: number | string = 75;
miningWindowPreference: string; miningWindowPreference: string;
radioGroupForm: FormGroup; radioGroupForm: UntypedFormGroup;
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
chartInitOptions = { chartInitOptions = {
@ -54,7 +54,7 @@ export class HashrateChartComponent implements OnInit {
@Inject(LOCALE_ID) public locale: string, @Inject(LOCALE_ID) public locale: string,
private seoService: SeoService, private seoService: SeoService,
private apiService: ApiService, private apiService: ApiService,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private storageService: StorageService, private storageService: StorageService,
private miningService: MiningService, private miningService: MiningService,
private route: ActivatedRoute, private route: ActivatedRoute,

View File

@ -11,21 +11,21 @@
</div> </div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats"> <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> <div class="btn-group btn-group-toggle" name="radioBasic">
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 25920" [class.active]="radioGroupForm.get('dateSpan').value === '6m'">
<input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'6m'"> 6M <input type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'6m'" formControlName="dateSpan"> 6M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 52560"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'">
<input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'1y'"> 1Y <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'1y'" formControlName="dateSpan"> 1Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 105120"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'">
<input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'2y'"> 2Y <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'2y'" formControlName="dateSpan"> 2Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 157680"> <label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'">
<input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'3y'"> 3Y <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'3y'" formControlName="dateSpan"> 3Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === 'all'">
<input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'all'"> ALL <input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'all'" formControlName="dateSpan"> ALL
</label> </label>
</div> </div>
</form> </form>

View File

@ -4,7 +4,7 @@ import { Observable } from 'rxjs';
import { delay, map, retryWhen, share, startWith, switchMap, tap } from 'rxjs/operators'; import { delay, map, retryWhen, share, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { FormBuilder, FormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { poolsColor } from '../../app.constants'; import { poolsColor } from '../../app.constants';
import { StorageService } from '../../services/storage.service'; import { StorageService } from '../../services/storage.service';
import { MiningService } from '../../services/mining.service'; import { MiningService } from '../../services/mining.service';
@ -30,7 +30,7 @@ export class HashrateChartPoolsComponent implements OnInit {
@Input() left: number | string = 25; @Input() left: number | string = 25;
miningWindowPreference: string; miningWindowPreference: string;
radioGroupForm: FormGroup; radioGroupForm: UntypedFormGroup;
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
chartInitOptions = { chartInitOptions = {
@ -48,7 +48,7 @@ export class HashrateChartPoolsComponent implements OnInit {
@Inject(LOCALE_ID) public locale: string, @Inject(LOCALE_ID) public locale: string,
private seoService: SeoService, private seoService: SeoService,
private apiService: ApiService, private apiService: ApiService,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
private storageService: StorageService, private storageService: StorageService,
private miningService: MiningService, private miningService: MiningService,

View File

@ -1,6 +1,6 @@
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { languages } from '../../app.constants'; import { languages } from '../../app.constants';
import { LanguageService } from '../../services/language.service'; import { LanguageService } from '../../services/language.service';
@ -11,12 +11,12 @@ import { LanguageService } from '../../services/language.service';
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class LanguageSelectorComponent implements OnInit { export class LanguageSelectorComponent implements OnInit {
languageForm: FormGroup; languageForm: UntypedFormGroup;
languages = languages; languages = languages;
constructor( constructor(
@Inject(DOCUMENT) private document: Document, @Inject(DOCUMENT) private document: Document,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private languageService: LanguageService, private languageService: LanguageService,
) { } ) { }

View File

@ -17,8 +17,8 @@ import {
import { import {
AbstractControl, AbstractControl,
ControlValueAccessor, ControlValueAccessor,
FormBuilder, UntypedFormBuilder,
FormControl, UntypedFormControl,
NG_VALUE_ACCESSOR, NG_VALUE_ACCESSOR,
Validator, Validator,
} from '@angular/forms'; } from '@angular/forms';
@ -52,7 +52,7 @@ export class NgxDropdownMultiselectComponent implements OnInit,
private localIsVisible = false; private localIsVisible = false;
private workerDocClicked = false; private workerDocClicked = false;
filterControl: FormControl = this.fb.control(''); filterControl: UntypedFormControl = this.fb.control('');
@Input() options: Array<IMultiSelectOption>; @Input() options: Array<IMultiSelectOption>;
@Input() settings: IMultiSelectSettings; @Input() settings: IMultiSelectSettings;
@ -151,7 +151,7 @@ export class NgxDropdownMultiselectComponent implements OnInit,
} }
constructor( constructor(
private fb: FormBuilder, private fb: UntypedFormBuilder,
private searchFilter: MultiSelectSearchFilter, private searchFilter: MultiSelectSearchFilter,
differs: IterableDiffers, differs: IterableDiffers,
private cdRef: ChangeDetectorRef private cdRef: ChangeDetectorRef

View File

@ -40,36 +40,36 @@
</div> </div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" <form [formGroup]="radioGroupForm" class="formRadioGroup"
*ngIf="!widget && (miningStatsObservable$ | async) as stats"> *ngIf="!widget && (miningStatsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> <div class="btn-group btn-group-toggle" name="radioBasic">
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 144"> <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 144" [class.active]="radioGroupForm.get('dateSpan').value === '24h'">
<input ngbButton type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'24h'"> 24h <input type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'24h'" formControlName="dateSpan"> 24h
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 432"> <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 432" [class.active]="radioGroupForm.get('dateSpan').value === '3d'">
<input ngbButton type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'3d'"> 3D <input type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'3d'" formControlName="dateSpan"> 3D
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 1008"> <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 1008" [class.active]="radioGroupForm.get('dateSpan').value === '1w'">
<input ngbButton type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'1w'"> 1W <input type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'1w'" formControlName="dateSpan"> 1W
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 4320"> <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 4320" [class.active]="radioGroupForm.get('dateSpan').value === '1m'">
<input ngbButton type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'1m'"> 1M <input type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'1m'" formControlName="dateSpan"> 1M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 12960"> <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 12960" [class.active]="radioGroupForm.get('dateSpan').value === '3m'">
<input ngbButton type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'3m'"> 3M <input type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'3m'" formControlName="dateSpan"> 3M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 25920"> <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 25920" [class.active]="radioGroupForm.get('dateSpan').value === '6m'">
<input ngbButton type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'6m'"> 6M <input type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'6m'" formControlName="dateSpan"> 6M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 52560"> <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'">
<input ngbButton type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'1y'"> 1Y <input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'1y'" formControlName="dateSpan"> 1Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 105120"> <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'">
<input ngbButton type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'2y'"> 2Y <input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'2y'" formControlName="dateSpan"> 2Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 157680"> <label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'">
<input ngbButton type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'3y'"> 3Y <input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'3y'" formControlName="dateSpan"> 3Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === 'all'">
<input ngbButton type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'all'"><span i18n>All</span> <input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'all'" formControlName="dateSpan"><span i18n>All</span>
</label> </label>
</div> </div>
</form> </form>

View File

@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, Input, NgZone, OnInit, HostBinding } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, NgZone, OnInit, HostBinding } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { EChartsOption, PieSeriesOption } from 'echarts'; import { EChartsOption, PieSeriesOption } from 'echarts';
import { concat, Observable } from 'rxjs'; import { concat, Observable } from 'rxjs';
@ -24,7 +24,7 @@ export class PoolRankingComponent implements OnInit {
@Input() widget = false; @Input() widget = false;
miningWindowPreference: string; miningWindowPreference: string;
radioGroupForm: FormGroup; radioGroupForm: UntypedFormGroup;
isLoading = true; isLoading = true;
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
@ -41,7 +41,7 @@ export class PoolRankingComponent implements OnInit {
constructor( constructor(
private stateService: StateService, private stateService: StateService,
private storageService: StorageService, private storageService: StorageService,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private miningService: MiningService, private miningService: MiningService,
private seoService: SeoService, private seoService: SeoService,
private router: Router, private router: Router,

View File

@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
@Component({ @Component({
@ -8,13 +8,13 @@ import { ApiService } from '../../services/api.service';
styleUrls: ['./push-transaction.component.scss'] styleUrls: ['./push-transaction.component.scss']
}) })
export class PushTransactionComponent implements OnInit { export class PushTransactionComponent implements OnInit {
pushTxForm: FormGroup; pushTxForm: UntypedFormGroup;
error: string = ''; error: string = '';
txId: string = ''; txId: string = '';
isLoading = false; isLoading = false;
constructor( constructor(
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private apiService: ApiService, private apiService: ApiService,
) { } ) { }

View File

@ -2,9 +2,7 @@
<div class="d-flex"> <div class="d-flex">
<div class="search-box-container mr-2"> <div class="search-box-container mr-2">
<input (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="Explore the full Bitcoin ecosystem"> <input (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="Explore the full Bitcoin ecosystem">
<app-search-results #searchResults [hidden]="dropdownHidden" [results]="typeAhead$ | async" (selectedResult)="selectedResult($event)"></app-search-results>
<app-search-results #searchResults [results]="typeAhead$ | async" (selectedResult)="selectedResult($event)"></app-search-results>
</div> </div>
<div> <div>
<button [disabled]="isSearching" type="submit" class="btn btn-block btn-primary"> <button [disabled]="isSearching" type="submit" class="btn btn-block btn-primary">

View File

@ -1,5 +1,5 @@
import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener } from '@angular/core'; import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener, ElementRef } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AssetsService } from '../../services/assets.service'; import { AssetsService } from '../../services/assets.service';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
@ -22,7 +22,17 @@ export class SearchFormComponent implements OnInit {
isSearching = false; isSearching = false;
isTypeaheading$ = new BehaviorSubject<boolean>(false); isTypeaheading$ = new BehaviorSubject<boolean>(false);
typeAhead$: Observable<any>; typeAhead$: Observable<any>;
searchForm: FormGroup; searchForm: UntypedFormGroup;
dropdownHidden = false;
@HostListener('document:click', ['$event'])
onDocumentClick(event) {
if (this.elementRef.nativeElement.contains(event.target)) {
this.dropdownHidden = false;
} else {
this.dropdownHidden = true;
}
}
regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[A-z]{2,5}1[a-zA-HJ-NP-Z0-9]{39,59})$/; regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[A-z]{2,5}1[a-zA-HJ-NP-Z0-9]{39,59})$/;
regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/; regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/;
@ -38,13 +48,14 @@ export class SearchFormComponent implements OnInit {
} }
constructor( constructor(
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private router: Router, private router: Router,
private assetsService: AssetsService, private assetsService: AssetsService,
private stateService: StateService, private stateService: StateService,
private electrsApiService: ElectrsApiService, private electrsApiService: ElectrsApiService,
private apiService: ApiService, private apiService: ApiService,
private relativeUrlPipe: RelativeUrlPipe, private relativeUrlPipe: RelativeUrlPipe,
private elementRef: ElementRef,
) { } ) { }
ngOnInit(): void { ngOnInit(): void {

View File

@ -13,46 +13,46 @@
<form [formGroup]="radioGroupForm" class="formRadioGroup" <form [formGroup]="radioGroupForm" class="formRadioGroup"
[class]="(stateService.env.MINING_DASHBOARD || stateService.env.LIGHTNING) ? 'mining' : 'no-menu'" (click)="saveGraphPreference()"> [class]="(stateService.env.MINING_DASHBOARD || stateService.env.LIGHTNING) ? 'mining' : 'no-menu'" (click)="saveGraphPreference()">
<div *ngIf="!isMobile()" class="btn-group btn-group-toggle"> <div *ngIf="!isMobile()" class="btn-group btn-group-toggle">
<label ngbButtonLabel class="btn-primary btn-sm mr-2"> <label class="btn btn-primary btn-sm mr-2">
<a [routerLink]="['/tv' | relativeUrl]" style="color: white" id="btn-tv"> <a [routerLink]="['/tv' | relativeUrl]" style="color: white" id="btn-tv">
<fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon> <fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon>
</a> </a>
</label> </label>
</div> </div>
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> <div class="btn-group btn-group-toggle" name="radioBasic">
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '2h'">
<input ngbButton type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h"> 2H <input type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h" formControlName="dateSpan"> 2H
(LIVE) (LIVE)
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '24h'">
<input ngbButton type="radio" [value]="'24h'" [routerLink]="['/graphs' | relativeUrl]" fragment="24h"> <input type="radio" [value]="'24h'" [routerLink]="['/graphs' | relativeUrl]" fragment="24h" formControlName="dateSpan">
24H 24H
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1w'">
<input ngbButton type="radio" [value]="'1w'" [routerLink]="['/graphs' | relativeUrl]" fragment="1w"> 1W <input type="radio" [value]="'1w'" [routerLink]="['/graphs' | relativeUrl]" fragment="1w" formControlName="dateSpan"> 1W
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1m'">
<input ngbButton type="radio" [value]="'1m'" [routerLink]="['/graphs' | relativeUrl]" fragment="1m"> 1M <input type="radio" [value]="'1m'" [routerLink]="['/graphs' | relativeUrl]" fragment="1m" formControlName="dateSpan"> 1M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '3m'">
<input ngbButton type="radio" [value]="'3m'" [routerLink]="['/graphs' | relativeUrl]" fragment="3m"> 3M <input type="radio" [value]="'3m'" [routerLink]="['/graphs' | relativeUrl]" fragment="3m" formControlName="dateSpan"> 3M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '6m'">
<input ngbButton type="radio" [value]="'6m'" [routerLink]="['/graphs' | relativeUrl]" fragment="6m"> 6M <input type="radio" [value]="'6m'" [routerLink]="['/graphs' | relativeUrl]" fragment="6m" formControlName="dateSpan"> 6M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1y'">
<input ngbButton type="radio" [value]="'1y'" [routerLink]="['/graphs' | relativeUrl]" fragment="1y"> 1Y <input type="radio" [value]="'1y'" [routerLink]="['/graphs' | relativeUrl]" fragment="1y" formControlName="dateSpan"> 1Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '2y'">
<input ngbButton type="radio" [value]="'2y'" [routerLink]="['/graphs' | relativeUrl]" fragment="2y"> 2Y <input type="radio" [value]="'2y'" [routerLink]="['/graphs' | relativeUrl]" fragment="2y" formControlName="dateSpan"> 2Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '3y'">
<input ngbButton type="radio" [value]="'3y'" [routerLink]="['/graphs' | relativeUrl]" fragment="3y"> 3Y <input type="radio" [value]="'3y'" [routerLink]="['/graphs' | relativeUrl]" fragment="3y" formControlName="dateSpan"> 3Y
</label> </label>
</div> </div>
<div class="small-buttons"> <div class="small-buttons">
<div ngbDropdown #myDrop="ngbDropdown"> <div ngbDropdown #myDrop="ngbDropdown">
<button class="btn btn-primary btn-sm" id="dropdownFees" ngbDropdownAnchor (click)="myDrop.toggle()"> <button class="btn btn btn-primary btn-sm" id="dropdownFees" ngbDropdownAnchor (click)="myDrop.toggle()">
<fa-icon [icon]="['fas', 'filter']" [fixedWidth]="true" i18n-title="statistics.component-filter.title" <fa-icon [icon]="['fas', 'filter']" [fixedWidth]="true" i18n-title="statistics.component-filter.title"
title="Filter"></fa-icon> title="Filter"></fa-icon>
</button> </button>
@ -71,7 +71,7 @@
</div> </div>
</div> </div>
<button (click)="invertGraph()" class="btn btn-primary btn-sm"> <button (click)="invertGraph()" class="btn btn btn-primary btn-sm">
<fa-icon [icon]="['fas', 'exchange-alt']" [rotate]="90" [fixedWidth]="true" <fa-icon [icon]="['fas', 'exchange-alt']" [rotate]="90" [fixedWidth]="true"
i18n-title="statistics.component-invert.title" title="Invert"></fa-icon> i18n-title="statistics.component-invert.title" title="Invert"></fa-icon>
</button> </button>

View File

@ -1,6 +1,6 @@
import { Component, OnInit, LOCALE_ID, Inject, ViewChild, ElementRef } from '@angular/core'; import { Component, OnInit, LOCALE_ID, Inject, ViewChild, ElementRef } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { FormGroup, FormBuilder } from '@angular/forms'; import { UntypedFormGroup, UntypedFormBuilder } from '@angular/forms';
import { of, merge} from 'rxjs'; import { of, merge} from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
@ -39,7 +39,7 @@ export class StatisticsComponent implements OnInit {
mempoolUnconfirmedTransactionsData: any; mempoolUnconfirmedTransactionsData: any;
mempoolTransactionsWeightPerSecondData: any; mempoolTransactionsWeightPerSecondData: any;
radioGroupForm: FormGroup; radioGroupForm: UntypedFormGroup;
graphWindowPreference: string; graphWindowPreference: string;
inverted: boolean; inverted: boolean;
feeLevelDropdownData = []; feeLevelDropdownData = [];
@ -47,7 +47,7 @@ export class StatisticsComponent implements OnInit {
constructor( constructor(
@Inject(LOCALE_ID) private locale: string, @Inject(LOCALE_ID) private locale: string,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private route: ActivatedRoute, private route: ActivatedRoute,
private websocketService: WebsocketService, private websocketService: WebsocketService,
private apiService: ApiService, private apiService: ApiService,

View File

@ -126,9 +126,13 @@ export class LiquidUnblinding {
} }
async checkUnblindedTx(tx: Transaction) { async checkUnblindedTx(tx: Transaction) {
const windowLocationHash = window.location.hash.substring('#blinded='.length); if (!window.location.hash?.length) {
if (windowLocationHash.length > 0) { return tx;
const blinders = this.parseBlinders(windowLocationHash); }
const fragmentParams = new URLSearchParams(window.location.hash.slice(1) || '');
const blinderStr = fragmentParams.get('blinded');
if (blinderStr && blinderStr.length) {
const blinders = this.parseBlinders(blinderStr);
if (blinders) { if (blinders) {
this.commitments = await this.makeCommitmentMap(blinders); this.commitments = await this.makeCommitmentMap(blinders);
return this.tryUnblindTx(tx); return this.tryUnblindTx(tx);

View File

@ -8,10 +8,10 @@
</div> </div>
<div *ngIf="network !== 'liquid' && network !== 'liquidtestnet'" class="features"> <div *ngIf="network !== 'liquid' && network !== 'liquidtestnet'" class="features">
<app-tx-features [tx]="tx"></app-tx-features> <app-tx-features [tx]="tx"></app-tx-features>
<span *ngIf="cpfpInfo && cpfpInfo.bestDescendant" class="badge badge-primary mr-1"> <span *ngIf="cpfpInfo && (cpfpInfo.bestDescendant || cpfpInfo.descendants.length)" class="badge badge-primary mr-1">
CPFP CPFP
</span> </span>
<span *ngIf="cpfpInfo && !cpfpInfo.bestDescendant && cpfpInfo.ancestors.length" class="badge badge-info mr-1"> <span *ngIf="cpfpInfo && !cpfpInfo.bestDescendant && !cpfpInfo.descendants.length && cpfpInfo.ancestors.length" class="badge badge-info mr-1">
CPFP CPFP
</span> </span>
</div> </div>
@ -29,7 +29,7 @@
<div class="row graph-wrapper"> <div class="row graph-wrapper">
<tx-bowtie-graph [tx]="tx" [width]="1112" [height]="346" [network]="network"></tx-bowtie-graph> <tx-bowtie-graph [tx]="tx" [width]="1132" [height]="346" [network]="network"></tx-bowtie-graph>
<div class="above-bow"> <div class="above-bow">
<p class="field pair"> <p class="field pair">
<span [innerHTML]="'&lrm;' + (tx.size | bytes: 2)"></span> <span [innerHTML]="'&lrm;' + (tx.size | bytes: 2)"></span>
@ -41,24 +41,20 @@
</div> </div>
<div class="overlaid"> <div class="overlaid">
<ng-container [ngSwitch]="extraData"> <ng-container [ngSwitch]="extraData">
<table class="opreturns" *ngSwitchCase="'coinbase'"> <div class="opreturns" *ngSwitchCase="'coinbase'">
<tbody> <div class="opreturn-row">
<tr> <span class="label">Coinbase</span>
<td class="label">Coinbase</td> <span class="message">{{ tx.vin[0].scriptsig | hex2ascii }}</span>
<td class="message">{{ tx.vin[0].scriptsig | hex2ascii }}</td> </div>
</tr> </div>
</tbody> <div class="opreturns" *ngSwitchCase="'opreturn'">
</table>
<table class="opreturns" *ngSwitchCase="'opreturn'">
<tbody>
<ng-container *ngFor="let vout of opReturns.slice(0,3)"> <ng-container *ngFor="let vout of opReturns.slice(0,3)">
<tr> <div class="opreturn-row">
<td class="label">OP_RETURN</td> <span class="label">OP_RETURN</span>
<td *ngIf="vout.scriptpubkey_asm !== 'OP_RETURN'" class="message">{{ vout.scriptpubkey_asm | hex2ascii }}</td> <span *ngIf="vout.scriptpubkey_asm !== 'OP_RETURN'" class="message">{{ vout.scriptpubkey_asm | hex2ascii }}</span>
</tr> </div>
</ng-container> </ng-container>
</tbody> </div>
</table>
</ng-container> </ng-container>
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More