Compare commits

...

68 Commits

Author SHA1 Message Date
hunicus
5a633892dc Update ronindojo link on about page 2023-06-20 19:49:58 -04:00
hunicus
e7a1817475 Add luminex as enterprise sponsor on about page 2023-06-20 19:49:17 -04:00
softsimon
e5efc2957a Merge pull request #3871 from mempool/mononaut/rbf-removed-not-mined
Change RBF status badges
2023-06-17 15:05:12 +02:00
softsimon
20d7e56de2 Update i18n 2023-06-17 15:04:46 +02:00
softsimon
0586e04d67 Merge pull request #3873 from mempool/simon/sanitize-pubkey-search
Sanitize node pubkey search
2023-06-17 11:22:28 +02:00
wiz
39bde61538 ops: Set cache time for /api/v1 endpoints to 2 sec 2023-06-16 17:15:41 -07:00
softsimon
0fb92e6ebb Merge pull request #3872 from mempool/simon/mempool-loop-timeout
Reinstate the mempool loop time limit
2023-06-17 00:56:00 +02:00
softsimon
c8d3653ef3 Updating tests 2023-06-17 00:32:58 +02:00
softsimon
a5575c0876 Sanitize node pubkey search 2023-06-16 23:42:57 +02:00
Mononaut
1872e5d12f change "removed" and "replaced" badges to yellow 2023-06-16 17:35:07 -04:00
softsimon
176f5e1377 Reinstate the mempool loop time limit 2023-06-16 20:42:31 +02:00
Mononaut
c0e235c01a Mark RBF transactions as removed if earlier version is mined 2023-06-16 13:47:09 -04:00
softsimon
2faeb1071e Merge pull request #3864 from mempool/mononaut/blocks-api-calls
remove redundant audit score api calls from blocks list
2023-06-16 19:37:48 +02:00
Mononaut
618ba56c42 remove redundant audit score api calls from blocks list 2023-06-15 12:57:20 -04:00
softsimon
d955dbff55 Merge pull request #3842 from mempool/mononaut/consistent-fee-ranges
Fix fee range inconsistencies
2023-06-15 16:49:07 +02:00
Mononaut
ee39283241 precompute block fee spans 2023-06-15 09:54:08 -04:00
wiz
c42670259e ops: Fix crash while building electrs in install script 2023-06-15 02:37:30 -07:00
Mononaut
bb61ff97fa continue to skip first rate in simple fee ranges 2023-06-14 19:04:09 -04:00
softsimon
c630d705df Merge pull request #3839 from mempool/mononaut/clean-up-legacy-cpfp
Clean up legacy CPFP calculations
2023-06-14 23:14:07 +02:00
softsimon
c5bf167e36 Merge pull request #3846 from mempool/mononaut/audit-details
Add expected vs actual audit details comparison table
2023-06-14 23:05:07 +02:00
softsimon
e8420853e2 Merge pull request #3838 from mempool/mononaut/dependent-rate-indexing
calculate & index ancestor-dependent effective rates
2023-06-14 23:02:25 +02:00
softsimon
a8a5b733e5 Merge pull request #3860 from mempool/simon/disable-liquid-tests
Disabling Liquid tests for now
2023-06-14 22:28:08 +02:00
Mononaut
30f8d5cf96 add missing markForChecks in blocks list 2023-06-14 16:23:57 -04:00
softsimon
f09939a201 Disabling Liquid tests for now 2023-06-14 22:20:18 +02:00
softsimon
a99515e94a Merge pull request #3834 from mempool/mononaut/fix-double-mined-rbf
Fix multiple mined RBF replacements of the same tx
2023-06-14 22:15:53 +02:00
Mononaut
c4f7b99978 add backfilled audit stats to cached blocks 2023-06-14 16:15:33 -04:00
softsimon
dcf73ec3f3 Merge pull request #3831 from mempool/mononaut/clock
Interactive clock
2023-06-14 22:14:26 +02:00
softsimon
bbe0579cdd Changing default clock to mempool 2023-06-14 21:53:51 +02:00
softsimon
4390ffe3b6 Merge pull request #3820 from mempool/nymkappa/reindexing-pools-update
Mining pool update / re-indexer improvment
2023-06-14 19:16:08 +02:00
Mononaut
6b93e61b56 minor audit details fixes 2023-06-14 11:28:39 -04:00
softsimon
c79d031c86 Merge pull request #3668 from mempool/simon/ignore-existing-mining-pools
Skip existing mining pool logos when syncing assets
2023-06-13 11:15:02 +02:00
Mononaut
ae5a0312be change audit detail labels 2023-06-12 12:40:19 -04:00
softsimon
0b2ffb3e91 Merge branch 'master' into nymkappa/reindexing-pools-update 2023-06-12 15:53:46 +02:00
softsimon
d009edbbf3 Merge pull request #3822 from mempool/nymkappa/fix-possible-crash
fix possible backend crash
2023-06-12 15:52:47 +02:00
softsimon
1fbdf97639 Merge branch 'master' into nymkappa/fix-possible-crash 2023-06-12 15:45:02 +02:00
softsimon
a36303e1fb Merge branch 'master' into simon/ignore-existing-mining-pools 2023-06-12 14:29:40 +02:00
softsimon
27a3a1575d Merge pull request #3849 from mempool/mononaut/icon-reset-scroll
Reset blockchain scroll on logo click
2023-06-11 18:43:44 +02:00
Mononaut
93d24d1cf7 Add expected fee % diff to blocks list page 2023-06-10 12:35:15 -04:00
Mononaut
bfb842d7ea Add % difference to weight and tx count in audit details 2023-06-10 12:35:13 -04:00
Mononaut
5b62966863 Add indexer task to backfill audit fee/weight stats 2023-06-10 12:34:16 -04:00
Mononaut
3013386ca5 Add expected weight to audit table 2023-06-10 12:32:53 -04:00
Mononaut
aedaf53137 Merge branch 'master' into merged-expected-block-fees 2023-06-10 12:15:29 -04:00
Joost Jager
7157efcf79 Add CLA for joostjager
Signed-off-by: Joost Jager <joost.jager@gmail.com>
2023-06-10 11:10:05 +02:00
Mononaut
57ac1486a0 Reset blockchain scroll on logo click 2023-06-09 19:03:47 -04:00
Mononaut
3c022ad755 Fix fee range inconsistencies 2023-06-07 11:59:31 -04:00
Mononaut
ca9b48283d calculate & index ancestor-dependent effective rates 2023-06-06 18:23:06 -04:00
Mononaut
c8fc416c88 Remove legacy mined block cpfp loop, reset stale ancestors 2023-06-06 14:19:30 -04:00
Mononaut
35d80eec1c Fix multiple mined RBF replacements of the same tx 2023-06-06 11:01:01 -04:00
Joost Jager
74b2014dff Show expected fees in blocks list 2023-06-06 08:52:29 +02:00
Mononaut
9883e59f12 fix graph page links layout 2023-06-05 14:09:02 -04:00
Mononaut
30396c5dca Add link to clock from graph page 2023-06-05 13:28:00 -04:00
Mononaut
ec0d5e0c23 Polish clocks, fix urls, make interactive 2023-06-05 13:27:17 -04:00
Joost Jager
3c0bb11208 Add expected total fees audit 2023-06-05 14:19:16 +02:00
nymkappa
0b74cf1d89 fix possible backend crash x2, remove dead code, improve log 2023-05-31 09:58:29 -07:00
nymkappa
c558c85f36 fix possible backend crash 2023-05-31 09:48:44 -07:00
nymkappa
ea51ab8d0b [indexer] show github sha when successufly updated pools json 2023-05-30 10:42:41 -07:00
nymkappa
62169cee3f [indexer] oldest known mining pool block per network 2023-05-30 10:25:41 -07:00
nymkappa
e7e7b30807 fix log 2023-05-30 10:16:56 -07:00
nymkappa
107bdbc209 [indexer] show indexer progress in /status component 2023-05-30 10:13:07 -07:00
nymkappa
0b4615cbf0 [indexer] reindex diff adjustments and hashrates upon mining pool update 2023-05-30 10:05:10 -07:00
softsimon
aa9fd845ef Path fix 2023-05-14 14:21:41 -05:00
softsimon
e41ce16bbb Merge branch 'master' into simon/ignore-existing-mining-pools 2023-05-14 13:19:22 -05:00
softsimon
f12403747d Adding video to sha1 check and chaining requests in promises. 2023-05-14 12:45:42 -05:00
softsimon
da3c3e8f5c Re-use variable fix 2023-05-13 13:24:44 -05:00
softsimon
a447887901 Adding missing slash 2023-05-13 13:23:27 -05:00
softsimon
d3bd434255 Use sha hashes to compare before downloading 2023-05-12 16:01:45 -05:00
softsimon
49e057e726 Fixing trailing slash issue 2023-05-12 11:05:20 -05:00
softsimon
7f3e4eb534 Skip existing mining pool logos when syncing assets 2023-05-12 11:05:19 -05:00
58 changed files with 856 additions and 278 deletions

View File

@@ -127,14 +127,6 @@ class Blocks {
}
}
if (addMempoolData) {
transactions.forEach((tx) => {
if (!tx.cpfpChecked) {
Common.setRelativesAndGetCpfpInfo(tx as MempoolTransactionExtended, mempool); // Child Pay For Parent
}
});
}
if (!quiet) {
logger.debug(`${transactionsFound} of ${txIds.length} found in mempool. ${transactionsFetched} fetched through backend service.`);
}
@@ -282,10 +274,14 @@ class Blocks {
}
extras.matchRate = null;
extras.expectedFees = null;
extras.expectedWeight = null;
if (config.MEMPOOL.AUDIT) {
const auditScore = await BlocksAuditsRepository.$getBlockAuditScore(block.id);
if (auditScore != null) {
extras.matchRate = auditScore.matchRate;
extras.expectedFees = auditScore.expectedFees;
extras.expectedWeight = auditScore.expectedWeight;
}
}
}
@@ -455,6 +451,46 @@ class Blocks {
}
}
/**
* [INDEXING] Index expected fees & weight for all audited blocks
*/
public async $generateAuditStats(): Promise<void> {
const blockIds = await BlocksAuditsRepository.$getBlocksWithoutSummaries();
if (!blockIds?.length) {
return;
}
let timer = Date.now();
let indexedThisRun = 0;
let indexedTotal = 0;
logger.debug(`Indexing ${blockIds.length} block audit details`);
for (const hash of blockIds) {
const summary = await BlocksSummariesRepository.$getTemplate(hash);
let totalFees = 0;
let totalWeight = 0;
for (const tx of summary?.transactions || []) {
totalFees += tx.fee;
totalWeight += (tx.vsize * 4);
}
await BlocksAuditsRepository.$setSummary(hash, totalFees, totalWeight);
const cachedBlock = this.blocks.find(block => block.id === hash);
if (cachedBlock) {
cachedBlock.extras.expectedFees = totalFees;
cachedBlock.extras.expectedWeight = totalWeight;
}
indexedThisRun++;
indexedTotal++;
const elapsedSeconds = (Date.now() - timer) / 1000;
if (elapsedSeconds > 5) {
const blockPerSeconds = indexedThisRun / elapsedSeconds;
logger.debug(`Indexed ${indexedTotal} / ${blockIds.length} block audit details (${blockPerSeconds.toFixed(1)}/s)`);
timer = Date.now();
indexedThisRun = 0;
}
}
logger.debug(`Indexing block audit details completed`);
}
/**
* [INDEXING] Index all blocks metadata for the mining dashboard
*/
@@ -645,9 +681,12 @@ class Blocks {
logger.info(`Re-indexed 10 blocks and summaries. Also re-indexed the last difficulty adjustments. Will re-index latest hashrates in a few seconds.`, logger.tags.mining);
indexer.reindex();
}
await blocksRepository.$saveBlockInDatabase(blockExtended);
this.updateTimerProgress(timer, `saved ${this.currentBlockHeight} to database`);
}
await blocksRepository.$saveBlockInDatabase(blockExtended);
this.updateTimerProgress(timer, `saved ${this.currentBlockHeight} to database`);
if (!fastForwarded) {
const lastestPriceId = await PricesRepository.$getLatestPriceId();
this.updateTimerProgress(timer, `got latest price id ${this.currentBlockHeight}`);
if (priceUpdater.historyInserted === true && lastestPriceId !== null) {

View File

@@ -1,4 +1,4 @@
import { Ancestor, CpfpInfo, CpfpSummary, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats } from '../mempool.interfaces';
import { Ancestor, CpfpInfo, CpfpSummary, CpfpCluster, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats } from '../mempool.interfaces';
import config from '../config';
import { NodeSocket } from '../repositories/NodesSocketsRepository';
import { isIP } from 'net';
@@ -91,6 +91,14 @@ export class Common {
if (replaced.size) {
matches[tx.txid] = { replaced: Array.from(replaced), replacedBy: tx };
}
// remove this tx from the spendMap
// prevents the same tx being replaced more than once
for (const vin of tx.vin) {
const key = `${vin.txid}:${vin.vout}`;
if (spendMap.get(key)?.txid === tx.txid) {
spendMap.delete(key);
}
}
}
return matches;
}
@@ -366,40 +374,80 @@ export class Common {
}
static calculateCpfp(height: number, transactions: TransactionExtended[]): CpfpSummary {
const clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[] = [];
let cluster: TransactionExtended[] = [];
let ancestors: { [txid: string]: boolean } = {};
const clusters: CpfpCluster[] = []; // list of all cpfp clusters in this block
const clusterMap: { [txid: string]: CpfpCluster } = {}; // map transactions to their cpfp cluster
let clusterTxs: TransactionExtended[] = []; // working list of elements of the current cluster
let ancestors: { [txid: string]: boolean } = {}; // working set of ancestors of the current cluster root
const txMap = {};
// initialize the txMap
for (const tx of transactions) {
txMap[tx.txid] = tx;
}
// reverse pass to identify CPFP clusters
for (let i = transactions.length - 1; i >= 0; i--) {
const tx = transactions[i];
txMap[tx.txid] = tx;
if (!ancestors[tx.txid]) {
let totalFee = 0;
let totalVSize = 0;
cluster.forEach(tx => {
clusterTxs.forEach(tx => {
totalFee += tx?.fee || 0;
totalVSize += (tx.weight / 4);
});
const effectiveFeePerVsize = totalFee / totalVSize;
if (cluster.length > 1) {
clusters.push({
root: cluster[0].txid,
let cluster: CpfpCluster;
if (clusterTxs.length > 1) {
cluster = {
root: clusterTxs[0].txid,
height,
txs: cluster.map(tx => { return { txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }; }),
txs: clusterTxs.map(tx => { return { txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }; }),
effectiveFeePerVsize,
});
};
clusters.push(cluster);
}
cluster.forEach(tx => {
clusterTxs.forEach(tx => {
txMap[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize;
if (cluster) {
clusterMap[tx.txid] = cluster;
}
});
cluster = [];
// reset working vars
clusterTxs = [];
ancestors = {};
}
cluster.push(tx);
clusterTxs.push(tx);
tx.vin.forEach(vin => {
ancestors[vin.txid] = true;
});
}
// forward pass to enforce ancestor rate caps
for (const tx of transactions) {
let minAncestorRate = tx.effectiveFeePerVsize;
for (const vin of tx.vin) {
if (txMap[vin.txid]?.effectiveFeePerVsize) {
minAncestorRate = Math.min(minAncestorRate, txMap[vin.txid].effectiveFeePerVsize);
}
}
// check rounded values to skip cases with almost identical fees
const roundedMinAncestorRate = Math.ceil(minAncestorRate);
const roundedEffectiveFeeRate = Math.floor(tx.effectiveFeePerVsize);
if (roundedMinAncestorRate < roundedEffectiveFeeRate) {
tx.effectiveFeePerVsize = minAncestorRate;
if (!clusterMap[tx.txid]) {
// add a single-tx cluster to record the dependent rate
const cluster = {
root: tx.txid,
height,
txs: [{ txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }],
effectiveFeePerVsize: minAncestorRate,
};
clusterMap[tx.txid] = cluster;
clusters.push(cluster);
} else {
// update the existing cluster with the dependent rate
clusterMap[tx.txid].effectiveFeePerVsize = minAncestorRate;
}
}
}
return {
transactions,
clusters,

View File

@@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
import { RowDataPacket } from 'mysql2';
class DatabaseMigration {
private static currentVersion = 61;
private static currentVersion = 62;
private queryTimeout = 3600_000;
private statisticsAddedIndexed = false;
private uniqueLogs: string[] = [];
@@ -533,6 +533,12 @@ class DatabaseMigration {
await this.updateToSchemaVersion(61);
}
if (databaseSchemaVersion < 62 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD expected_fees BIGINT UNSIGNED DEFAULT NULL');
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD expected_weight BIGINT UNSIGNED DEFAULT NULL');
await this.updateToSchemaVersion(62);
}
}
/**
@@ -1051,7 +1057,7 @@ class DatabaseMigration {
}
public async $blocksReindexingTruncate(): Promise<void> {
logger.warn(`Truncating pools, blocks and hashrates for re-indexing (using '--reindex-blocks'). You can cancel this command within 5 seconds`);
logger.warn(`Truncating pools, blocks, hashrates and difficulty_adjustments tables for re-indexing (using '--reindex-blocks'). You can cancel this command within 5 seconds`);
await Common.sleep$(5000);
await this.$executeQuery(`TRUNCATE blocks`);

View File

@@ -373,7 +373,7 @@ class NodesApi {
public async $searchNodeByPublicKeyOrAlias(search: string) {
try {
const publicKeySearch = search.replace('%', '') + '%';
const publicKeySearch = search.replace(/[^a-zA-Z0-9]/g, '') + '%';
const aliasSearch = search
.replace(/[-_.]/g, ' ') // Replace all -_. characters with empty space. Eg: "ln.nicehash" becomes "ln nicehash".
.replace(/[^a-zA-Z0-9 ]/g, '') // Remove all special characters and keep just A to Z, 0 to 9.

View File

@@ -358,6 +358,9 @@ class MempoolBlocks {
block: blockIndex,
vsize: totalVsize + (mempoolTx.vsize / 2),
};
mempoolTx.ancestors = [];
mempoolTx.descendants = [];
mempoolTx.bestDescendant = null;
mempoolTx.cpfpChecked = true;
// online calculation of stack-of-blocks fee stats

View File

@@ -186,6 +186,12 @@ class Mempool {
loadingIndicators.setProgress('mempool', progress);
loggerTimer = new Date().getTime() / 1000;
}
// Break and restart mempool loop if we spend too much time processing
// new transactions that may lead to falling behind on block height
if (this.inSync && (new Date().getTime()) - start > 10_000) {
logger.debug('Breaking mempool loop because the 10s time limit exceeded.');
break;
}
}
// Reset esplora 404 counter and log a warning if needed

View File

@@ -13,11 +13,15 @@ import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository';
import PricesRepository from '../../repositories/PricesRepository';
import { bitcoinCoreApi } from '../bitcoin/bitcoin-api-factory';
import { IEsploraApi } from '../bitcoin/esplora-api.interface';
import database from '../../database';
class Mining {
private blocksPriceIndexingRunning = false;
public lastHashrateIndexingDate: number | null = null;
public lastWeeklyHashrateIndexingDate: number | null = null;
public reindexHashrateRequested = false;
public reindexDifficultyAdjustmentRequested = false;
/**
* Get historical blocks health
@@ -289,6 +293,14 @@ class Mining {
* Generate daily hashrate data
*/
public async $generateNetworkHashrateHistory(): Promise<void> {
// If a re-index was requested, truncate first
if (this.reindexHashrateRequested === true) {
logger.notice(`hashrates will now be re-indexed`);
await database.query(`TRUNCATE hashrates`);
this.lastHashrateIndexingDate = 0;
this.reindexHashrateRequested = false;
}
// We only run this once a day around midnight
const today = new Date().getUTCDate();
if (today === this.lastHashrateIndexingDate) {
@@ -394,6 +406,13 @@ class Mining {
* Index difficulty adjustments
*/
public async $indexDifficultyAdjustments(): Promise<void> {
// If a re-index was requested, truncate first
if (this.reindexDifficultyAdjustmentRequested === true) {
logger.notice(`difficulty_adjustments will now be re-indexed`);
await database.query(`TRUNCATE difficulty_adjustments`);
this.reindexDifficultyAdjustmentRequested = false;
}
const indexedHeightsArray = await DifficultyAdjustmentsRepository.$getAdjustmentsHeights();
const indexedHeights = {};
for (const height of indexedHeightsArray) {
@@ -472,11 +491,11 @@ class Mining {
}
this.blocksPriceIndexingRunning = true;
let totalInserted = 0;
try {
const prices: any[] = await PricesRepository.$getPricesTimesAndId();
const blocksWithoutPrices: any[] = await BlocksRepository.$getBlocksWithoutPrice();
let totalInserted = 0;
const blocksPrices: BlockPrice[] = [];
for (const block of blocksWithoutPrices) {
@@ -521,7 +540,13 @@ class Mining {
}
} catch (e) {
this.blocksPriceIndexingRunning = false;
throw e;
logger.err(`Cannot index block prices. ${e}`);
}
if (totalInserted > 0) {
logger.info(`Indexing blocks prices completed. Indexed ${totalInserted}`, logger.tags.mining);
} else {
logger.debug(`Indexing blocks prices completed. Indexed 0.`, logger.tags.mining);
}
this.blocksPriceIndexingRunning = false;

View File

@@ -4,6 +4,7 @@ import config from '../config';
import PoolsRepository from '../repositories/PoolsRepository';
import { PoolTag } from '../mempool.interfaces';
import diskCache from './disk-cache';
import mining from './mining/mining';
class PoolsParser {
miningPools: any[] = [];
@@ -73,14 +74,12 @@ class PoolsParser {
if (JSON.stringify(pool.addresses) !== poolDB.addresses ||
JSON.stringify(pool.regexes) !== poolDB.regexes) {
// Pool addresses changed or coinbase tags changed
logger.notice(`Updating addresses and/or coinbase tags for ${pool.name} mining pool. If 'AUTOMATIC_BLOCK_REINDEXING' is enabled, we will re-index its blocks and 'unknown' blocks`);
logger.notice(`Updating addresses and/or coinbase tags for ${pool.name} mining pool.`);
await PoolsRepository.$updateMiningPoolTags(poolDB.id, pool.addresses, pool.regexes);
await this.$deleteBlocksForPool(poolDB);
}
}
}
logger.info('Mining pools-v2.json import completed');
}
/**
@@ -128,7 +127,15 @@ class PoolsParser {
LIMIT 1`,
[pool.id]
);
const oldestBlockHeight = oldestPoolBlock.length ?? 0 > 0 ? oldestPoolBlock[0].height : 130635;
let firstKnownBlockPool = 130635; // https://mempool.space/block/0000000000000a067d94ff753eec72830f1205ad3a4c216a08a80c832e551a52
if (config.MEMPOOL.NETWORK === 'testnet') {
firstKnownBlockPool = 21106; // https://mempool.space/testnet/block/0000000070b701a5b6a1b965f6a38e0472e70b2bb31b973e4638dec400877581
} else if (config.MEMPOOL.NETWORK === 'signet') {
firstKnownBlockPool = 0;
}
const oldestBlockHeight = oldestPoolBlock.length ?? 0 > 0 ? oldestPoolBlock[0].height : firstKnownBlockPool;
const [unknownPool] = await DB.query(`SELECT id from pools where slug = "unknown"`);
this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height ${oldestBlockHeight} for re-indexing`);
await DB.query(`
@@ -142,16 +149,31 @@ class PoolsParser {
WHERE pool_id = ?`,
[pool.id]
);
// Re-index hashrates and difficulty adjustments later
mining.reindexHashrateRequested = true;
mining.reindexDifficultyAdjustmentRequested = true;
}
private async $deleteUnknownBlocks(): Promise<void> {
let firstKnownBlockPool = 130635; // https://mempool.space/block/0000000000000a067d94ff753eec72830f1205ad3a4c216a08a80c832e551a52
if (config.MEMPOOL.NETWORK === 'testnet') {
firstKnownBlockPool = 21106; // https://mempool.space/testnet/block/0000000070b701a5b6a1b965f6a38e0472e70b2bb31b973e4638dec400877581
} else if (config.MEMPOOL.NETWORK === 'signet') {
firstKnownBlockPool = 0;
}
const [unknownPool] = await DB.query(`SELECT id from pools where slug = "unknown"`);
this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height 130635 for re-indexing`);
this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height ${firstKnownBlockPool} for re-indexing`);
await DB.query(`
DELETE FROM blocks
WHERE pool_id = ? AND height >= 130635`,
WHERE pool_id = ? AND height >= ${firstKnownBlockPool}`,
[unknownPool[0].id]
);
// Re-index hashrates and difficulty adjustments later
mining.reindexHashrateRequested = true;
mining.reindexDifficultyAdjustmentRequested = true;
}
}

View File

@@ -571,11 +571,18 @@ class WebsocketHandler {
};
}) : [];
let totalFees = 0;
let totalWeight = 0;
for (const tx of stripped) {
totalFees += tx.fee;
totalWeight += (tx.vsize * 4);
}
BlocksSummariesRepository.$saveTemplate({
height: block.height,
template: {
id: block.id,
transactions: stripped
transactions: stripped,
}
});
@@ -588,10 +595,14 @@ class WebsocketHandler {
freshTxs: fresh,
sigopTxs: sigop,
matchRate: matchRate,
expectedFees: totalFees,
expectedWeight: totalWeight,
});
if (block.extras) {
block.extras.matchRate = matchRate;
block.extras.expectedFees = totalFees;
block.extras.expectedWeight = totalWeight;
block.extras.similarity = similarity;
}
}

View File

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

View File

@@ -35,11 +35,15 @@ export interface BlockAudit {
sigopTxs: string[],
addedTxs: string[],
matchRate: number,
expectedFees?: number,
expectedWeight?: number,
}
export interface AuditScore {
hash: string,
matchRate?: number,
expectedFees?: number
expectedWeight?: number
}
export interface MempoolBlock {
@@ -182,6 +186,8 @@ export interface BlockExtension {
feeRange: number[]; // fee rate percentiles
reward: number;
matchRate: number | null;
expectedFees: number | null;
expectedWeight: number | null;
similarity?: number;
pool: {
id: number; // Note - This is the `unique_id`, not to mix with the auto increment `id`
@@ -253,9 +259,16 @@ export interface WorkingEffectiveFeeStats extends EffectiveFeeStats {
maxFee: number;
}
export interface CpfpCluster {
root: string,
height: number,
txs: Ancestor[],
effectiveFeePerVsize: number,
}
export interface CpfpSummary {
transactions: TransactionExtended[];
clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[];
clusters: CpfpCluster[];
}
export interface Statistic {

View File

@@ -6,9 +6,9 @@ import { BlockAudit, AuditScore } from '../mempool.interfaces';
class BlocksAuditRepositories {
public async $saveAudit(audit: BlockAudit): Promise<void> {
try {
await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, fresh_txs, sigop_txs, match_rate)
VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs),
JSON.stringify(audit.addedTxs), JSON.stringify(audit.freshTxs), JSON.stringify(audit.sigopTxs), audit.matchRate]);
await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, fresh_txs, sigop_txs, match_rate, expected_fees, expected_weight)
VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs),
JSON.stringify(audit.addedTxs), JSON.stringify(audit.freshTxs), JSON.stringify(audit.sigopTxs), audit.matchRate, audit.expectedFees, audit.expectedWeight]);
} catch (e: any) {
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`);
@@ -18,6 +18,19 @@ class BlocksAuditRepositories {
}
}
public async $setSummary(hash: string, expectedFees: number, expectedWeight: number) {
try {
await DB.query(`
UPDATE blocks_audits SET
expected_fees = ?,
expected_weight = ?
WHERE hash = ?
`, [expectedFees, expectedWeight, hash]);
} catch (e: any) {
logger.err(`Cannot update block audit in db. Reason: ` + (e instanceof Error ? e.message : e));
}
}
public async $getBlocksHealthHistory(div: number, interval: string | null): Promise<any> {
try {
let query = `SELECT UNIX_TIMESTAMP(time) as time, height, match_rate FROM blocks_audits`;
@@ -51,7 +64,15 @@ class BlocksAuditRepositories {
const [rows]: any[] = await DB.query(
`SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size,
blocks.weight, blocks.tx_count,
transactions, template, missing_txs as missingTxs, added_txs as addedTxs, fresh_txs as freshTxs, sigop_txs as sigopTxs, match_rate as matchRate
transactions,
template,
missing_txs as missingTxs,
added_txs as addedTxs,
fresh_txs as freshTxs,
sigop_txs as sigopTxs,
match_rate as matchRate,
expected_fees as expectedFees,
expected_weight as expectedWeight
FROM blocks_audits
JOIN blocks ON blocks.hash = blocks_audits.hash
JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash
@@ -81,7 +102,7 @@ class BlocksAuditRepositories {
public async $getBlockAuditScore(hash: string): Promise<AuditScore> {
try {
const [rows]: any[] = await DB.query(
`SELECT hash, match_rate as matchRate
`SELECT hash, match_rate as matchRate, expected_fees as expectedFees, expected_weight as expectedWeight
FROM blocks_audits
WHERE blocks_audits.hash = "${hash}"
`);
@@ -95,7 +116,7 @@ class BlocksAuditRepositories {
public async $getBlockAuditScores(maxHeight: number, minHeight: number): Promise<AuditScore[]> {
try {
const [rows]: any[] = await DB.query(
`SELECT hash, match_rate as matchRate
`SELECT hash, match_rate as matchRate, expected_fees as expectedFees, expected_weight as expectedWeight
FROM blocks_audits
WHERE blocks_audits.height BETWEEN ? AND ?
`, [minHeight, maxHeight]);
@@ -105,6 +126,32 @@ class BlocksAuditRepositories {
throw e;
}
}
public async $getBlocksWithoutSummaries(): Promise<string[]> {
try {
const [fromRows]: any[] = await DB.query(`
SELECT height
FROM blocks_audits
WHERE expected_fees IS NULL
ORDER BY height DESC
LIMIT 1
`);
if (!fromRows?.length) {
return [];
}
const fromHeight = fromRows[0].height;
const [idRows]: any[] = await DB.query(`
SELECT hash
FROM blocks_audits
WHERE height <= ?
ORDER BY height DESC
`, [fromHeight]);
return idRows.map(row => row.hash);
} catch (e: any) {
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
}
export default new BlocksAuditRepositories();

View File

@@ -577,19 +577,6 @@ class BlocksRepository {
}
}
/**
* Return blocks height
*/
public async $getBlocksHeightsAndTimestamp(): Promise<object[]> {
try {
const [rows]: any[] = await DB.query(`SELECT height, blockTimestamp as timestamp FROM blocks`);
return rows;
} catch (e) {
logger.err('Cannot get blocks height and timestamp from the db. Reason: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
/**
* Get general block stats
*/
@@ -877,7 +864,7 @@ class BlocksRepository {
/**
* Get all blocks which have not be linked to a price yet
*/
public async $getBlocksWithoutPrice(): Promise<object[]> {
public async $getBlocksWithoutPrice(): Promise<object[]> {
try {
const [rows]: any[] = await DB.query(`
SELECT UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.height
@@ -889,7 +876,7 @@ class BlocksRepository {
return rows;
} catch (e) {
logger.err('Cannot get blocks height and timestamp from the db. Reason: ' + (e instanceof Error ? e.message : e));
throw e;
return [];
}
}
@@ -909,7 +896,6 @@ class BlocksRepository {
logger.debug(`Cannot save blocks prices for blocks [${blockPrices[0].height} to ${blockPrices[blockPrices.length - 1].height}] because it has already been indexed, ignoring`);
} else {
logger.err(`Cannot save blocks prices for blocks [${blockPrices[0].height} to ${blockPrices[blockPrices.length - 1].height}] into db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
}
@@ -928,7 +914,7 @@ class BlocksRepository {
return blocks;
} catch (e) {
logger.err(`Cannot get blocks with missing coinstatsindex. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
return [];
}
}
@@ -1032,10 +1018,14 @@ class BlocksRepository {
// Match rate is not part of the blocks table, but it is part of APIs so we must include it
extras.matchRate = null;
extras.expectedFees = null;
extras.expectedWeight = null;
if (config.MEMPOOL.AUDIT) {
const auditScore = await BlocksAuditsRepository.$getBlockAuditScore(dbBlk.id);
if (auditScore != null) {
extras.matchRate = auditScore.matchRate;
extras.expectedFees = auditScore.expectedFees;
extras.expectedWeight = auditScore.expectedWeight;
}
}

View File

@@ -50,6 +50,21 @@ class BlocksSummariesRepository {
}
}
public async $getTemplate(id: string): Promise<BlockSummary | undefined> {
try {
const [templates]: any[] = await DB.query(`SELECT * from blocks_templates WHERE id = ?`, [id]);
if (templates.length > 0) {
return {
id: templates[0].id,
transactions: JSON.parse(templates[0].template),
};
}
} catch (e) {
logger.err(`Cannot get block template for block id ${id}. Reason: ` + (e instanceof Error ? e.message : e));
}
return undefined;
}
public async $getIndexedSummariesId(): Promise<string[]> {
try {
const [rows]: any[] = await DB.query(`SELECT id from blocks_summaries`);

View File

@@ -1,8 +1,7 @@
import cluster, { Cluster } from 'cluster';
import { RowDataPacket } from 'mysql2';
import DB from '../database';
import logger from '../logger';
import { Ancestor } from '../mempool.interfaces';
import { Ancestor, CpfpCluster } from '../mempool.interfaces';
import transactionRepository from '../repositories/TransactionRepository';
class CpfpRepository {
@@ -12,7 +11,7 @@ class CpfpRepository {
}
// skip clusters of transactions with the same fees
const roundedEffectiveFee = Math.round(effectiveFeePerVsize * 100) / 100;
const equalFee = txs.reduce((acc, tx) => {
const equalFee = txs.length > 1 && txs.reduce((acc, tx) => {
return (acc && Math.round(((tx.fee || 0) / (tx.weight / 4)) * 100) / 100 === roundedEffectiveFee);
}, true);
if (equalFee) {
@@ -54,9 +53,9 @@ class CpfpRepository {
const txs: any[] = [];
for (const cluster of clusters) {
if (cluster.txs?.length > 1) {
if (cluster.txs?.length) {
const roundedEffectiveFee = Math.round(cluster.effectiveFeePerVsize * 100) / 100;
const equalFee = cluster.txs.reduce((acc, tx) => {
const equalFee = cluster.txs.length > 1 && cluster.txs.reduce((acc, tx) => {
return (acc && Math.round(((tx.fee || 0) / (tx.weight / 4)) * 100) / 100 === roundedEffectiveFee);
}, true);
if (!equalFee) {
@@ -111,7 +110,7 @@ class CpfpRepository {
}
}
public async $getCluster(clusterRoot: string): Promise<Cluster | void> {
public async $getCluster(clusterRoot: string): Promise<CpfpCluster | void> {
const [clusterRows]: any = await DB.query(
`
SELECT *
@@ -121,6 +120,7 @@ class CpfpRepository {
[clusterRoot]
);
const cluster = clusterRows[0];
cluster.effectiveFeePerVsize = cluster.fee_rate;
if (cluster?.txs) {
cluster.txs = this.unpack(cluster.txs);
return cluster;

View File

@@ -105,6 +105,7 @@ class TransactionRepository {
return {
descendants,
ancestors,
effectiveFeePerVsize: cluster.effectiveFeePerVsize,
};
}
}

View File

@@ -71,7 +71,7 @@ class PoolsUpdater {
poolsParser.setMiningPools(poolsJson);
if (config.DATABASE.ENABLED === false) { // Don't run db operations
logger.info('Mining pools-v2.json import completed (no database)');
logger.info(`Mining pools-v2.json (${githubSha}) import completed (no database)`);
return;
}
@@ -84,7 +84,7 @@ class PoolsUpdater {
logger.err(`Could not migrate mining pools, rolling back. Exception: ${JSON.stringify(e)}`, logger.tags.mining);
await DB.query('ROLLBACK;');
}
logger.info('PoolsUpdater completed');
logger.info(`Mining pools-v2.json (${githubSha}) import completed`);
} catch (e) {
this.lastRun = now - (oneWeek - oneDay); // Try again in 24h instead of waiting next week

View File

@@ -0,0 +1,3 @@
I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of January 25, 2022.
Signed: joostjager

View File

@@ -1,4 +1,4 @@
describe('Liquid', () => {
describe.skip('Liquid', () => {
const baseModule = Cypress.env('BASE_MODULE');
const basePath = '';

View File

@@ -1,4 +1,4 @@
describe('Liquid Testnet', () => {
describe.skip('Liquid Testnet', () => {
const baseModule = Cypress.env('BASE_MODULE');
const basePath = '/testnet';

View File

@@ -537,7 +537,7 @@ describe('Mainnet', () => {
cy.get('.container-xl > :nth-child(3)').invoke('css', 'width').should('equal', alertWidth);
});
cy.get('.btn-danger').then(getRectangle).then((rectA) => {
cy.get('.btn-warning').then(getRectangle).then((rectA) => {
cy.get('.alert').then(getRectangle).then((rectB) => {
expect(areOverlapping(rectA, rectB), 'Confirmations box and RBF alert are overlapping').to.be.false;
});
@@ -582,7 +582,7 @@ describe('Mainnet', () => {
cy.get(alertLocator).invoke('css', 'width').should('equal', firstWidth);
});
cy.get('.btn-danger').then(getRectangle).then((rectA) => {
cy.get('.btn-warning').then(getRectangle).then((rectA) => {
cy.get('.alert').then(getRectangle).then((rectB) => {
expect(areOverlapping(rectA, rectB), 'Confirmations box and RBF alert are overlapping').to.be.false;
});

View File

@@ -35,7 +35,7 @@
"start:local-staging": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-staging",
"start:mixed": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c mixed",
"build": "npm run generate-config && npm run ng -- build --configuration production --localize && npm run sync-assets && npm run build-mempool.js",
"sync-assets": "rsync -av ./src/resources ./dist/mempool/browser && node sync-assets.js 'dist/mempool/browser/resources/'",
"sync-assets": "rsync -av ./src/resources ./dist/mempool/browser && node sync-assets.js 'dist/mempool/browser/resources'",
"sync-assets-dev": "node sync-assets.js 'src/resources/'",
"generate-config": "node generate-config.js",
"build-mempool.js": "npm run build-mempool-js && npm run build-mempool-liquid-js && npm run build-mempool-bisq-js",

View File

@@ -4,8 +4,7 @@ import { AppPreloadingStrategy } from './app.preloading-strategy'
import { StartComponent } from './components/start/start.component';
import { TransactionComponent } from './components/transaction/transaction.component';
import { BlockComponent } from './components/block/block.component';
import { ClockMinedComponent as ClockMinedComponent } from './components/clock/clock-mined.component';
import { ClockMempoolComponent as ClockMempoolComponent } from './components/clock/clock-mempool.component';
import { ClockComponent } from './components/clock/clock.component';
import { AddressComponent } from './components/address/address.component';
import { MasterPageComponent } from './components/master-page/master-page.component';
import { AboutComponent } from './components/about/about.component';
@@ -358,12 +357,16 @@ let routes: Routes = [
],
},
{
path: 'clock-mined',
component: ClockMinedComponent,
path: 'clock',
redirectTo: 'clock/mempool/0'
},
{
path: 'clock-mempool',
component: ClockMempoolComponent,
path: 'clock/:mode',
redirectTo: 'clock/:mode/0'
},
{
path: 'clock/:mode/:index',
component: ClockComponent,
},
{
path: 'status',

View File

@@ -173,6 +173,21 @@
</svg>
<span>Exodus</span>
</a>
<a href="https://www.luminex.io" target="_blank" title="Luminex">
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="66.95" height="80" viewBox="0 0 300.43 385" style="padding-top: 10px;">
<defs>
<style>
.cls-1 {
fill: #f2ea25;
}
</style>
</defs>
<path class="cls-1" d="m309.02,90.04c0,49.65-38.73,90.04-95.34,90.04s-95.34-40.39-95.34-90.04S153.77,0,213.69,0c56.28,0,95.34,40.39,95.34,90.04Zm-63.56,0c0-20.52-14.23-37.07-31.78-37.07s-31.78,16.55-31.78,37.07,14.23,37.07,31.78,37.07,31.78-16.55,31.78-37.07Z"/>
<path class="cls-1" d="m311.87,372.67h-66.34l-31.84-47.76-31.84,47.76h-66.34l58.38-90.22-53.07-79.61h66.34l26.54,42.46,26.53-42.46h66.34l-53.07,79.61,58.38,90.22Z"/>
<rect class="cls-1" width="60.69" height="372.67"/>
</svg>
<span>Luminex</span>
</a>
</div>
</div>
@@ -205,7 +220,7 @@
<img class="image" src="/resources/profile/mynodebtc.png" />
<span>myNode</span>
</a>
<a href="https://github.com/RoninDojo/RoninDojo" target="_blank" title="RoninDojo">
<a href="https://code.samourai.io/ronindojo/RoninDojo" target="_blank" title="RoninDojo">
<img class="image" src="/resources/profile/ronindojo.png" />
<span>RoninDojo</span>
</a>

View File

@@ -121,7 +121,7 @@
<ng-container *ngIf="!isLoadingBlock; else loadingRest">
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<td i18n="mempool-block.fee-span">Fee span</td>
<td><span>{{ block.extras.feeRange[1] | 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>
<td><span>{{ block?.extras?.minFee | number:'1.0-0' }} - {{ block?.extras?.maxFee | 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">
<td class="td-width" i18n="block.median-fee">Median fee</td>
@@ -226,6 +226,9 @@
(txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($event)" [unavailable]="!isMobile && !showAudit"></app-block-overview-graph>
<ng-container *ngIf="!isMobile || mode !== 'actual'; else emptyBlockInfo"></ng-container>
</div>
<ng-container *ngIf="network !== 'liquid'">
<ng-container *ngTemplateOutlet="isMobile && mode === 'actual' ? actualDetails : expectedDetails"></ng-container>
</ng-container>
</div>
<div class="col-sm" *ngIf="!isMobile">
<h3 class="block-subtitle actual" *ngIf="!isMobile"><ng-container i18n="block.actual-block">Actual Block</ng-container> <a class="info-link" [routerLink]="['/docs/faq' | relativeUrl ]" fragment="how-do-block-audits-work"><fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon></a></h3>
@@ -235,6 +238,9 @@
(txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($event)" [unavailable]="isMobile && !showAudit"></app-block-overview-graph>
<ng-container *ngTemplateOutlet="emptyBlockInfo"></ng-container>
</div>
<ng-container *ngIf="network !== 'liquid'">
<ng-container *ngTemplateOutlet="actualDetails"></ng-container>
</ng-container>
</div>
</div>
</div>
@@ -385,5 +391,60 @@
</a>
</ng-template>
<ng-template #expectedDetails>
<table *ngIf="block && blockAudit && blockAudit.expectedFees != null" class="table table-borderless table-striped audit-details-table">
<tbody>
<tr>
<td i18n="block.total-fees|Total fees in a block">Total fees</td>
<td>
<app-amount [satoshis]="blockAudit.expectedFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
</td>
</tr>
<tr>
<td i18n="block.weight">Weight</td>
<td [innerHTML]="'&lrm;' + (blockAudit.expectedWeight | wuBytes: 2)"></td>
</tr>
<tr>
<td i18n="mempool-block.transactions">Transactions</td>
<td>{{ blockAudit.template?.length || 0 }}</td>
</tr>
</tbody>
</table>
</ng-template>
<ng-template #actualDetails>
<table *ngIf="block && blockAudit && blockAudit.expectedFees != null" class="table table-borderless table-striped audit-details-table">
<tbody>
<tr>
<td i18n="block.total-fees|Total fees in a block">Total fees</td>
<td>
<app-amount [satoshis]="block.extras.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
<span *ngIf="blockAudit.feeDelta" class="difference" [class.positive]="blockAudit.feeDelta <= 0" [class.negative]="blockAudit.feeDelta > 0">
{{ blockAudit.feeDelta < 0 ? '+' : '' }}{{ (-blockAudit.feeDelta * 100) | amountShortener: 2 }}%
</span>
</td>
</tr>
<tr>
<td i18n="block.weight">Weight</td>
<td [innerHTML]>
<span [innerHTML]="'&lrm;' + (block.weight | wuBytes: 2)"></span>
<span *ngIf="blockAudit.weightDelta" class="difference" [class.positive]="blockAudit.weightDelta <= 0" [class.negative]="blockAudit.weightDelta > 0">
{{ blockAudit.weightDelta < 0 ? '+' : '' }}{{ (-blockAudit.weightDelta * 100) | amountShortener: 2 }}%
</span>
</td>
</tr>
<tr>
<td i18n="mempool-block.transactions">Transactions</td>
<td>
{{ block.tx_count }}
<span *ngIf="blockAudit.txDelta" class="difference" [class.positive]="blockAudit.txDelta <= 0" [class.negative]="blockAudit.txDelta > 0">
{{ blockAudit.txDelta < 0 ? '+' : '' }}{{ (-blockAudit.txDelta * 100) | amountShortener: 2 }}%
</span>
</td>
</tr>
</tbody>
</table>
</ng-template>
<br>
<br>

View File

@@ -38,6 +38,17 @@
color: rgba(255, 255, 255, 0.4);
margin-left: 5px;
}
.difference {
margin-left: 0.5em;
&.positive {
color: rgb(66, 183, 71);
}
&.negative {
color: rgb(183, 66, 66);
}
}
}
}
@@ -252,3 +263,10 @@ h1 {
top: 11px;
margin-left: 10px;
}
.audit-details-table {
margin-top: 1.25rem;
@media (max-width: 767.98px) {
margin-top: 0.75rem;
}
}

View File

@@ -138,6 +138,8 @@ export class BlockComponent implements OnInit, OnDestroy {
if (block.id === this.blockHash) {
this.block = block;
block.extras.minFee = this.getMinBlockFee(block);
block.extras.maxFee = this.getMaxBlockFee(block);
if (block?.extras?.reward != undefined) {
this.fees = block.extras.reward / 100000000 - this.blockSubsidy;
}
@@ -234,6 +236,8 @@ export class BlockComponent implements OnInit, OnDestroy {
}
this.updateAuditAvailableFromBlockHeight(block.height);
this.block = block;
block.extras.minFee = this.getMinBlockFee(block);
block.extras.maxFee = this.getMaxBlockFee(block);
this.blockHeight = block.height;
this.lastBlockHeight = this.blockHeight;
this.nextBlockHeight = block.height + 1;
@@ -388,6 +392,11 @@ export class BlockComponent implements OnInit, OnDestroy {
for (const tx of blockAudit.transactions) {
inBlock[tx.txid] = true;
}
blockAudit.feeDelta = blockAudit.expectedFees > 0 ? (blockAudit.expectedFees - this.block.extras.totalFees) / blockAudit.expectedFees : 0;
blockAudit.weightDelta = blockAudit.expectedWeight > 0 ? (blockAudit.expectedWeight - this.block.weight) / blockAudit.expectedWeight : 0;
blockAudit.txDelta = blockAudit.template.length > 0 ? (blockAudit.template.length - this.block.tx_count) / blockAudit.template.length : 0;
this.setAuditAvailable(true);
} else {
this.setAuditAvailable(false);
@@ -663,4 +672,23 @@ export class BlockComponent implements OnInit, OnDestroy {
}
}
}
getMinBlockFee(block: BlockExtended): number {
if (block?.extras?.feeRange) {
// heuristic to check if feeRange is adjusted for effective rates
if (block.extras.medianFee === block.extras.feeRange[3]) {
return block.extras.feeRange[1];
} else {
return block.extras.feeRange[0];
}
}
return 0;
}
getMaxBlockFee(block: BlockExtended): number {
if (block?.extras?.feeRange) {
return block.extras.feeRange[block.extras.feeRange.length - 1];
}
return 0;
}
}

View File

@@ -13,10 +13,10 @@
[class.offscreen]="!static && count && i >= count"
id="bitcoin-block-{{ block.height }}" [ngStyle]="blockStyles[i]"
[class.blink-bg]="isSpecial(block.height)">
<a draggable="false" [routerLink]="['/block/' | relativeUrl, block.id]" [state]="{ data: { block: block } }"
<a draggable="false" [routerLink]="[getHref(i, block) | relativeUrl]" [state]="{ data: { block: block } }"
class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}">&nbsp;</a>
<div *ngIf="!minimal" [attr.data-cy]="'bitcoin-block-' + i + '-height'" class="block-height">
<a [routerLink]="['/block/' | relativeUrl, block.id]" [state]="{ data: { block: block } }">{{ block.height
<a [routerLink]="[getHref(i, block) | relativeUrl]" [state]="{ data: { block: block } }">{{ block.height
}}</a>
</div>
<div class="block-body">
@@ -31,9 +31,8 @@
</div>
</ng-template>
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-fee-span'" class="fee-span"
*ngIf="block?.extras?.feeRange; else emptyfeespan">
{{ block?.extras?.feeRange?.[0] | number:feeRounding }} - {{
block?.extras?.feeRange[block?.extras?.feeRange?.length - 1] | number:feeRounding }} <ng-container
*ngIf="block?.extras?.minFee != null && block?.extras?.maxFee != null; else emptyfeespan">
{{ block.extras.minFee | number:feeRounding }} - {{ block.extras.maxFee | number:feeRounding }} <ng-container
i18n="shared.sat-vbyte|sat/vB">sat/vB</ng-container>
</div>
<ng-template #emptyfeespan>

View File

@@ -27,6 +27,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
@Input() minimal: boolean = false;
@Input() blockWidth: number = 125;
@Input() spotlight: number = 0;
@Input() getHref?: (index, block) => string = (index, block) => `/block/${block.id}`;
specialBlocks = specialBlocks;
network = '';
@@ -113,6 +114,9 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
this.blocksFilled = false;
}
block.extras.minFee = this.getMinBlockFee(block);
block.extras.maxFee = this.getMaxBlockFee(block);
this.blocks.unshift(block);
this.blocks = this.blocks.slice(0, this.dynamicBlocksAmount);
@@ -239,6 +243,10 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
if (height >= 0) {
this.cacheService.loadBlock(height);
block = this.cacheService.getCachedBlock(height) || null;
if (block) {
block.extras.minFee = this.getMinBlockFee(block);
block.extras.maxFee = this.getMaxBlockFee(block);
}
}
this.blocks.push(block || {
placeholder: height < 0,
@@ -277,6 +285,8 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
onBlockLoaded(block: BlockExtended) {
const blockIndex = this.height - block.height;
if (blockIndex >= 0 && blockIndex < this.blocks.length) {
block.extras.minFee = this.getMinBlockFee(block);
block.extras.maxFee = this.getMaxBlockFee(block);
this.blocks[blockIndex] = block;
this.blockStyles[blockIndex] = this.getStyleForBlock(block, blockIndex);
}
@@ -365,4 +375,23 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
}
return emptyBlocks;
}
getMinBlockFee(block: BlockExtended): number {
if (block?.extras?.feeRange) {
// heuristic to check if feeRange is adjusted for effective rates
if (block.extras.medianFee === block.extras.feeRange[3]) {
return block.extras.feeRange[1];
} else {
return block.extras.feeRange[0];
}
}
return 0;
}
getMaxBlockFee(block: BlockExtended): number {
if (block?.extras?.feeRange) {
return block.extras.feeRange[block.extras.feeRange.length - 1];
}
return 0;
}
}

View File

@@ -13,12 +13,12 @@
<th *ngIf="indexingAvailable" class="pool text-left" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}" i18n="mining.pool-name"
i18n-ngbTooltip="mining.pool-name" ngbTooltip="Pool" placement="bottom" #miningpool [disableTooltip]="!isEllipsisActive(miningpool)">Pool</th>
<th class="timestamp" i18n="latest-blocks.timestamp" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">Timestamp</th>
<th class="mined" i18n="latest-blocks.mined" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">Mined</th>
<th *ngIf="auditAvailable" class="health text-right" i18n="latest-blocks.health" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"
i18n-ngbTooltip="latest-blocks.health" ngbTooltip="Health" placement="bottom" #health [disableTooltip]="!isEllipsisActive(health)">Health</th>
<th *ngIf="indexingAvailable" class="reward text-right" i18n="latest-blocks.reward" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"
i18n-ngbTooltip="latest-blocks.reward" ngbTooltip="Reward" placement="bottom" #reward [disableTooltip]="!isEllipsisActive(reward)">Reward</th>
<th *ngIf="indexingAvailable && !widget" class="fees text-right" i18n="latest-blocks.fees" [class]="indexingAvailable ? '' : 'legacy'">Fees</th>
<th *ngIf="auditAvailable && !widget" class="fee-delta" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"></th>
<th *ngIf="indexingAvailable" class="txs text-right" i18n="dashboard.txs" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"
i18n-ngbTooltip="dashboard.txs" ngbTooltip="TXs" placement="bottom" #txs [disableTooltip]="!isEllipsisActive(txs)">TXs</th>
<th *ngIf="!indexingAvailable" class="txs text-right" i18n="dashboard.txs" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">Transactions</th>
@@ -42,26 +42,18 @@
<td class="timestamp" *ngIf="!widget" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
&lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
</td>
<td class="mined" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">
<app-time kind="since" [time]="block.timestamp" [fastRender]="true"></app-time>
</td>
<td *ngIf="auditAvailable" class="health text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<a
*ngIf="block?.extras?.matchRate != null; else nullHealth"
class="health-badge badge"
[class.badge-success]="auditScores[block.id] >= 99"
[class.badge-warning]="auditScores[block.id] >= 75 && auditScores[block.id] < 99"
[class.badge-danger]="auditScores[block.id] < 75"
[routerLink]="auditScores[block.id] != null ? ['/block/' | relativeUrl, block.id] : null"
[class.badge-success]="block.extras.matchRate >= 99"
[class.badge-warning]="block.extras.matchRate >= 75 && block.extras.matchRate < 99"
[class.badge-danger]="block.extras.matchRate < 75"
[routerLink]="block.extras.matchRate != null ? ['/block/' | relativeUrl, block.id] : null"
[state]="{ data: { block: block } }"
*ngIf="auditScores[block.id] != null; else nullHealth"
>{{ auditScores[block.id] }}%</a>
>{{ block.extras.matchRate }}%</a>
<ng-template #nullHealth>
<ng-container *ngIf="!loadingScores; else loadingHealth">
<span class="health-badge badge badge-secondary" i18n="unknown">Unknown</span>
</ng-container>
</ng-template>
<ng-template #loadingHealth>
<span class="skeleton-loader" style="max-width: 60px"></span>
<span class="health-badge badge badge-secondary" i18n="unknown">Unknown</span>
</ng-template>
</td>
<td *ngIf="indexingAvailable" class="reward text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
@@ -70,6 +62,11 @@
<td *ngIf="indexingAvailable && !widget" class="fees text-right" [class]="indexingAvailable ? '' : 'legacy'">
<app-amount [satoshis]="block.extras.totalFees" [noFiat]="true" digitsInfo="1.2-2"></app-amount>
</td>
<td *ngIf="auditAvailable" class="fee-delta" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<span *ngIf="block.extras.feeDelta" class="difference" [class.positive]="block.extras.feeDelta >= 0" [class.negative]="block.extras.feeDelta < 0">
{{ block.extras.feeDelta > 0 ? '+' : '' }}{{ (block.extras.feeDelta * 100) | amountShortener: 2 }}%
</span>
</td>
<td class="txs text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
{{ block.tx_count | number }}
</td>
@@ -106,6 +103,9 @@
<td *ngIf="indexingAvailable && !widget" class="fees text-right" [class]="indexingAvailable ? '' : 'legacy'">
<span class="skeleton-loader" style="max-width: 75px"></span>
</td>
<td *ngIf="auditAvailable && !widget" class="fee-delta" [class]="indexingAvailable ? '' : 'legacy'">
<span class="skeleton-loader" style="max-width: 75px"></span>
</td>
<td class="txs text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<span class="skeleton-loader" style="max-width: 75px"></span>
</td>

View File

@@ -23,6 +23,17 @@ tr, td, th {
border: 0px;
padding-top: 0.65rem !important;
padding-bottom: 0.7rem !important;
.difference {
margin-left: 0.5em;
&.positive {
color: rgb(66, 183, 71);
}
&.negative {
color: rgb(183, 66, 66);
}
}
}
.clear-link {
@@ -90,7 +101,7 @@ tr, td, th {
}
.timestamp {
width: 18%;
width: 10%;
@media (max-width: 1100px) {
display: none;
}
@@ -123,8 +134,8 @@ tr, td, th {
}
.txs {
padding-right: 40px;
width: 8%;
padding-right: 20px;
width: 6%;
@media (max-width: 1100px) {
padding-right: 10px;
}
@@ -160,6 +171,16 @@ tr, td, th {
.fees.widget {
width: 20%;
}
.fee-delta {
width: 6%;
padding-left: 0;
@media (max-width: 991px) {
display: none;
}
}
.fee-delta.widget {
display: none;
}
.reward {
width: 8%;
@@ -214,7 +235,7 @@ tr, td, th {
.health {
width: 10%;
@media (max-width: 1105px) {
@media (max-width: 1100px) {
width: 13%;
}
@media (max-width: 560px) {

View File

@@ -1,6 +1,6 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input } from '@angular/core';
import { BehaviorSubject, combineLatest, concat, Observable, timer, EMPTY, Subscription, of } from 'rxjs';
import { catchError, delayWhen, map, retryWhen, scan, skip, switchMap, tap } from 'rxjs/operators';
import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core';
import { BehaviorSubject, combineLatest, Observable, timer, of } from 'rxjs';
import { delayWhen, map, retryWhen, scan, switchMap, tap } from 'rxjs/operators';
import { BlockExtended } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service';
import { StateService } from '../../services/state.service';
@@ -12,19 +12,14 @@ import { WebsocketService } from '../../services/websocket.service';
styleUrls: ['./blocks-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BlocksList implements OnInit, OnDestroy {
export class BlocksList implements OnInit {
@Input() widget: boolean = false;
blocks$: Observable<BlockExtended[]> = undefined;
auditScores: { [hash: string]: number | void } = {};
auditScoreSubscription: Subscription;
latestScoreSubscription: Subscription;
indexingAvailable = false;
auditAvailable = false;
isLoading = true;
loadingScores = true;
fromBlockHeight = undefined;
paginationMaxSize: number;
page = 1;
@@ -39,6 +34,7 @@ export class BlocksList implements OnInit, OnDestroy {
private apiService: ApiService,
private websocketService: WebsocketService,
public stateService: StateService,
private cd: ChangeDetectorRef,
) {
}
@@ -65,7 +61,7 @@ export class BlocksList implements OnInit, OnDestroy {
this.blocksCount = blocks[0].height + 1;
}
this.isLoading = false;
this.lastBlockHeight = Math.max(...blocks.map(o => o.height))
this.lastBlockHeight = Math.max(...blocks.map(o => o.height));
}),
map(blocks => {
if (this.indexingAvailable) {
@@ -81,7 +77,7 @@ export class BlocksList implements OnInit, OnDestroy {
return blocks;
}),
retryWhen(errors => errors.pipe(delayWhen(() => timer(10000))))
)
);
})
),
this.stateService.blocks$
@@ -112,68 +108,25 @@ export class BlocksList implements OnInit, OnDestroy {
acc = acc.slice(0, this.widget ? 6 : 15);
}
return acc;
}, [])
);
if (this.indexingAvailable && this.auditAvailable) {
this.auditScoreSubscription = this.fromHeightSubject.pipe(
switchMap((fromBlockHeight) => {
this.loadingScores = true;
return this.apiService.getBlockAuditScores$(this.page === 1 ? undefined : fromBlockHeight)
.pipe(
catchError(() => {
return EMPTY;
})
);
}, []),
switchMap((blocks) => {
blocks.forEach(block => {
block.extras.feeDelta = block.extras.expectedFees ? (block.extras.totalFees - block.extras.expectedFees) / block.extras.expectedFees : 0;
});
return of(blocks);
})
).subscribe((scores) => {
Object.values(scores).forEach(score => {
this.auditScores[score.hash] = score?.matchRate != null ? score.matchRate : null;
});
this.loadingScores = false;
});
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): void {
this.fromHeightSubject.next((this.blocksCount - 1) - (page - 1) * 15);
}
trackByBlock(index: number, block: BlockExtended) {
trackByBlock(index: number, block: BlockExtended): number {
return block.height;
}
isEllipsisActive(e) {
isEllipsisActive(e): boolean {
return (e.offsetWidth < e.scrollWidth);
}
}

View File

@@ -1 +0,0 @@
<app-clock mode="mempool"></app-clock>

View File

@@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-clock-mempool',
templateUrl: './clock-mempool.component.html',
})
export class ClockMempoolComponent {}

View File

@@ -1 +0,0 @@
<app-clock mode="block"></app-clock>

View File

@@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-clock-mined',
templateUrl: './clock-mined.component.html',
})
export class ClockMinedComponent {}

View File

@@ -1,14 +1,19 @@
<div class="clock-wrapper" [style]="wrapperStyle">
<div class="clockchain-bar" [style.height]="chainHeight + 'px'">
<div class="clockchain">
<app-clockchain [width]="chainWidth" [height]="chainHeight" [mode]="mode"></app-clockchain>
<app-clockchain
[width]="chainWidth"
[height]="chainHeight"
[mode]="mode"
[index]="blockIndex"
></app-clockchain>
</div>
</div>
<div class="clock-face">
<app-clock-face [size]="clockSize">
<div class="block-wrapper">
<ng-container *ngIf="block && block.height >= 0">
<ng-container *ngIf="mode === 'block'; else mempoolMode;">
<ng-container *ngIf="blocks && blocks.length">
<ng-container *ngIf="mode === 'mined'; else mempoolMode;">
<div class="block-cube">
<div class="side top"></div>
<div class="side bottom"></div>
@@ -20,12 +25,12 @@
</ng-container>
<ng-template #mempoolMode>
<div class="block-sizer" [style]="blockSizerStyle">
<app-mempool-block-overview [index]="0" [pixelAlign]="true"></app-mempool-block-overview>
<app-mempool-block-overview [index]="blockIndex" [pixelAlign]="true"></app-mempool-block-overview>
</div>
</ng-template>
<div class="fader"></div>
<div class="title-wrapper">
<h1 class="block-height">{{ block.height }}</h1>
<h1 class="block-height">{{ blocks[mode === 'mempool' ? 0 : blockIndex].height }}</h1>
</div>
</ng-container>
</div>
@@ -42,13 +47,13 @@
<p class="label" i18n="clock.priority-rate|priority fee rate">priority rate</p>
<p *ngIf="recommendedFees$ | async as recommendedFees;" i18n="shared.sat-vbyte|sat/vB">{{ recommendedFees.fastestFee }} sat/vB</p>
</div>
<div *ngIf="mode !== 'mempool' && block" class="stats bottom left">
<p [innerHTML]="block.size | bytes: 2"></p>
<div *ngIf="mode !== 'mempool' && blocks?.length" class="stats bottom left">
<p [innerHTML]="blocks[blockIndex].size | bytes: 2"></p>
<p class="label" i18n="clock.block-size">block size</p>
</div>
<div *ngIf="mode !== 'mempool' && block" class="stats bottom right">
<div *ngIf="mode !== 'mempool' && blocks?.length" class="stats bottom right">
<p class="force-wrap">
<ng-container *ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container>
<ng-container *ngTemplateOutlet="blocks[blockIndex].tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: blocks[blockIndex].tx_count | number}"></ng-container>
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} <span class="label">transaction</span></ng-template>
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} <span class="label">transactions</span></ng-template>
</p>

View File

@@ -1,10 +1,11 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, Input, OnInit } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { Observable, Subscription, of, switchMap, tap } from 'rxjs';
import { StateService } from '../../services/state.service';
import { BlockExtended } from '../../interfaces/node-api.interface';
import { WebsocketService } from '../../services/websocket.service';
import { MempoolInfo, Recommendedfees } from '../../interfaces/websocket.interface';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
@Component({
selector: 'app-clock',
@@ -13,12 +14,14 @@ import { ActivatedRoute } from '@angular/router';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ClockComponent implements OnInit {
@Input() mode: 'block' | 'mempool' = 'block';
hideStats: boolean = false;
mode: 'mempool' | 'mined' = 'mined';
blockIndex: number;
pageSubscription: Subscription;
blocksSubscription: Subscription;
recommendedFees$: Observable<Recommendedfees>;
mempoolInfo$: Observable<MempoolInfo>;
block: BlockExtended;
blocks: BlockExtended[] = [];
clockSize: number = 300;
chainWidth: number = 384;
chainHeight: number = 60;
@@ -41,6 +44,8 @@ export class ClockComponent implements OnInit {
public stateService: StateService,
private websocketService: WebsocketService,
private route: ActivatedRoute,
private router: Router,
private relativeUrlPipe: RelativeUrlPipe,
private cd: ChangeDetectorRef,
) {
this.route.queryParams.subscribe((params) => {
@@ -57,14 +62,40 @@ export class ClockComponent implements OnInit {
this.blocksSubscription = this.stateService.blocks$
.subscribe(([block]) => {
if (block) {
this.block = block;
this.blockStyle = this.getStyleForBlock(this.block);
this.cd.markForCheck();
this.blocks.unshift(block);
this.blocks = this.blocks.slice(0, 16);
if (this.blocks[this.blockIndex]) {
this.blockStyle = this.getStyleForBlock(this.blocks[this.blockIndex]);
this.cd.markForCheck();
}
}
});
this.recommendedFees$ = this.stateService.recommendedFees$;
this.mempoolInfo$ = this.stateService.mempoolInfo$;
this.pageSubscription = this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
const rawMode: string = params.get('mode');
const mode = rawMode === 'mempool' ? 'mempool' : 'mined';
const index: number = Number.parseInt(params.get('index'));
if (mode !== rawMode || index < 0 || isNaN(index)) {
this.router.navigate([this.relativeUrlPipe.transform('/clock'), mode, index || 0]);
}
return of({
mode,
index,
});
}),
tap((page: { mode: 'mempool' | 'mined', index: number }) => {
this.mode = page.mode;
this.blockIndex = page.index || 0;
if (this.blocks[this.blockIndex]) {
this.blockStyle = this.getStyleForBlock(this.blocks[this.blockIndex]);
this.cd.markForCheck();
}
})
).subscribe();
}
getStyleForBlock(block: BlockExtended) {

View File

@@ -5,8 +5,20 @@
<div class="position-container" [ngClass]="network ? network : ''" [style.top]="(height / 3) + 'px'">
<span>
<div class="blocks-wrapper">
<app-mempool-blocks [minimal]="true" [count]="mempoolBlocks" [blockWidth]="blockWidth" [spotlight]="mode === 'mempool' ? 1 : 0"></app-mempool-blocks>
<app-blockchain-blocks [minimal]="true" [count]="blockchainBlocks" [blockWidth]="blockWidth" [spotlight]="mode === 'block' ? -1 : 0"></app-blockchain-blocks>
<app-mempool-blocks
[minimal]="true"
[count]="mempoolBlocks"
[blockWidth]="blockWidth"
[spotlight]="mode === 'mempool' ? index + 1 : 0"
[getHref]="getMempoolUrl"
></app-mempool-blocks>
<app-blockchain-blocks
[minimal]="true"
[count]="blockchainBlocks"
[blockWidth]="blockWidth"
[spotlight]="mode === 'mined' ? -index - 1 : 0"
[getHref]="getMinedUrl"
></app-blockchain-blocks>
</div>
<div class="divider" [style.top]="-(height / 6) + 'px'">
<svg

View File

@@ -11,7 +11,8 @@ import { StateService } from '../../services/state.service';
export class ClockchainComponent implements OnInit, OnChanges, OnDestroy {
@Input() width: number = 300;
@Input() height: number = 60;
@Input() mode: 'mempool' | 'block';
@Input() mode: 'mempool' | 'mined';
@Input() index: number = 0;
mempoolBlocks: number = 3;
blockchainBlocks: number = 6;
@@ -70,4 +71,12 @@ export class ClockchainComponent implements OnInit, OnChanges, OnDestroy {
this.ltrTransitionEnabled = true;
this.stateService.timeLtr.next(!this.timeLtr);
}
getMempoolUrl(index): string {
return `/clock/mempool/${index}`;
}
getMinedUrl(index): string {
return `/clock/block/${index}`;
}
}

View File

@@ -1,7 +1,7 @@
<ng-container *ngIf="{ val: network$ | async } as network">
<header>
<nav class="navbar navbar-expand-md navbar-dark bg-dark">
<a class="navbar-brand" [ngClass]="{'dual-logos': subdomain}" [routerLink]="['/' | relativeUrl]">
<a class="navbar-brand" [ngClass]="{'dual-logos': subdomain}" [routerLink]="['/' | relativeUrl]" (click)="brandClick($event)">
<ng-template [ngIf]="subdomain">
<div class="subdomain_container">
<img [src]="'/api/v1/enterprise/images/' + subdomain + '/logo'" class="subdomain_logo">

View File

@@ -53,4 +53,8 @@ export class MasterPageComponent implements OnInit {
onResize(): void {
this.isMobile = window.innerWidth <= 767.98;
}
brandClick(e): void {
this.stateService.resetScroll$.next(true);
}
}

View File

@@ -8,7 +8,7 @@
[style.right]="mempoolBlockStyles[i].right"
></div>
<div @blockEntryTrigger [@.disabled]="i > 0 || !animateEntry" [attr.data-cy]="'mempool-block-' + i" class="bitcoin-block text-center mempool-block" [class.hide-block]="count && i >= count" id="mempool-block-{{ i }}" [ngStyle]="mempoolBlockStyles[i]" [class.blink-bg]="projectedBlock.blink">
<a draggable="false" [routerLink]="['/mempool-block/' | relativeUrl, i]"
<a draggable="false" [routerLink]="[getHref(i) | relativeUrl]"
class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}">&nbsp;</a>
<div class="block-body">
<ng-container *ngIf="!minimal">

View File

@@ -28,6 +28,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
@Input() blockWidth: number = 125;
@Input() count: number = null;
@Input() spotlight: number = 0;
@Input() getHref?: (index) => string = (index) => `/mempool-block/${index}`;
specialBlocks = specialBlocks;
mempoolBlocks: MempoolBlock[] = [];

View File

@@ -1,3 +1,5 @@
<app-indexing-progress *ngIf="showLoadingIndicator"></app-indexing-progress>
<ng-container *ngIf="specialEvent">
<div class="pyro">
<div class="before"></div>

View File

@@ -1,4 +1,4 @@
import { Component, ElementRef, HostListener, OnInit, OnDestroy, ViewChild } from '@angular/core';
import { Component, ElementRef, HostListener, OnInit, OnDestroy, ViewChild, Input } from '@angular/core';
import { Subscription } from 'rxjs';
import { StateService } from '../../services/state.service';
import { specialBlocks } from '../../app.constants';
@@ -9,6 +9,8 @@ import { specialBlocks } from '../../app.constants';
styleUrls: ['./start.component.scss'],
})
export class StartComponent implements OnInit, OnDestroy {
@Input() showLoadingIndicator = false;
interval = 60;
colors = ['#5E35B1', '#ffffff'];
@@ -25,6 +27,7 @@ export class StartComponent implements OnInit, OnDestroy {
markBlockSubscription: Subscription;
blockCounterSubscription: Subscription;
@ViewChild('blockchainContainer') blockchainContainer: ElementRef;
resetScrollSubscription: Subscription;
isMobile: boolean = false;
isiOS: boolean = false;
@@ -106,6 +109,12 @@ export class StartComponent implements OnInit, OnDestroy {
}, 60 * 60 * 1000);
}
});
this.resetScrollSubscription = this.stateService.resetScroll$.subscribe(reset => {
if (reset) {
this.resetScroll();
this.stateService.resetScroll$.next(false);
}
});
}
@HostListener('window:resize', ['$event'])
@@ -385,5 +394,6 @@ export class StartComponent implements OnInit, OnDestroy {
this.chainTipSubscription.unsubscribe();
this.markBlockSubscription.unsubscribe();
this.blockCounterSubscription.unsubscribe();
this.resetScrollSubscription.unsubscribe();
}
}

View File

@@ -12,12 +12,13 @@
<form [formGroup]="radioGroupForm" class="formRadioGroup"
[class]="(stateService.env.MINING_DASHBOARD || stateService.env.LIGHTNING) ? 'mining' : 'no-menu'" (click)="saveGraphPreference()">
<div *ngIf="!isMobile()" class="btn-group btn-group-toggle">
<label class="btn btn-primary btn-sm mr-2">
<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>
</a>
</label>
<div class="small-buttons">
<a class="btn btn-primary btn-sm mb-0" [routerLink]="['/clock/mempool/0' | relativeUrl]" style="color: white" id="btn-clock">
<fa-icon [icon]="['fas', 'clock']" [fixedWidth]="true" i18n-title="master-page.clockview" title="Clock view"></fa-icon>
</a>
<a *ngIf="!isMobile()" class="btn btn-primary btn-sm mb-0" [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>
</a>
</div>
<div class="btn-group btn-group-toggle" name="radioBasic">
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '2h'">

View File

@@ -150,17 +150,30 @@
margin: 0px 0px;
}
.btn {
width: 49.25%;
width: 50%;
flex-grow: 1;
flex-shrink: 1;
margin: 1px 0;
margin-right: 0.5rem;
@media (min-width: 830px) {
width: auto;
}
&:first-child {
margin-right: 0;
@media (min-width: 830px) {
margin-right: 0.5rem;
}
}
}
.dropdown {
width: 49.25%;
display: flex;
width: 50%;
flex-grow: 1;
flex-shrink: 1;
@media (min-width: 830px) {
width: auto;
margin: 0px 5px;
margin-left: 0.5rem;
}
}
#dropdownFees {

View File

@@ -1,2 +1,2 @@
<app-start></app-start>
<app-start [showLoadingIndicator]="true"></app-start>
<app-footer></app-footer>

View File

@@ -18,7 +18,12 @@
</span>
<div class="container-buttons">
<app-confirmations [chainTip]="latestBlock?.height" [height]="tx?.status?.block_height" [replaced]="replaced"></app-confirmations>
<app-confirmations
[chainTip]="latestBlock?.height"
[height]="tx?.status?.block_height"
[replaced]="replaced"
[removed]="this.rbfInfo?.mined && !this.tx?.status?.confirmed"
></app-confirmations>
</div>
</ng-container>
</div>

View File

@@ -129,10 +129,15 @@ export interface PoolStat {
export interface BlockExtension {
totalFees?: number;
medianFee?: number;
minFee?: number;
maxFee?: number;
feeRange?: number[];
reward?: number;
coinbaseRaw?: string;
matchRate?: number;
expectedFees?: number;
expectedWeight?: number;
feeDelta?: number;
similarity?: number;
pool?: {
id: number;
@@ -149,6 +154,11 @@ export interface BlockAudit extends BlockExtended {
missingTxs: string[],
addedTxs: string[],
matchRate: number,
expectedFees: number,
expectedWeight: number,
feeDelta?: number,
weightDelta?: number,
txDelta?: number,
template: TransactionStripped[],
transactions: TransactionStripped[],
}

View File

@@ -126,6 +126,7 @@ export class StateService {
keyNavigation$ = new Subject<KeyboardEvent>();
blockScrolling$: Subject<boolean> = new Subject<boolean>();
resetScroll$: Subject<boolean> = new Subject<boolean>();
timeLtr: BehaviorSubject<boolean>;
hideFlow: BehaviorSubject<boolean>;
hideAudit: BehaviorSubject<boolean>;

View File

@@ -6,8 +6,11 @@
</button>
</ng-template>
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && replaced">
<button type="button" class="btn btn-sm btn-danger {{buttonClass}}" i18n="transaction.unconfirmed|Transaction unconfirmed state">Replaced</button>
<button type="button" class="btn btn-sm btn-warning {{buttonClass}}" i18n="transaction.replaced|Transaction replaced state">Replaced</button>
</ng-template>
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && !replaced">
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && !replaced && removed">
<button type="button" class="btn btn-sm btn-warning {{buttonClass}}" i18n="transaction.audit.removed|Transaction removed state">Removed</button>
</ng-template>
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && !replaced && !removed">
<button type="button" class="btn btn-sm btn-danger {{buttonClass}}" i18n="transaction.unconfirmed|Transaction unconfirmed state">Unconfirmed</button>
</ng-template>

View File

@@ -11,6 +11,7 @@ export class ConfirmationsComponent implements OnChanges {
@Input() chainTip: number;
@Input() height: number;
@Input() replaced: boolean = false;
@Input() removed: boolean = false;
@Input() hideUnconfirmed: boolean = false;
@Input() buttonClass: string = '';

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { NgbCollapseModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome';
import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle,
faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown,
faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faClock, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown,
faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft } from '@fortawesome/free-solid-svg-icons';
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
import { MasterPageComponent } from '../components/master-page/master-page.component';
@@ -95,8 +95,6 @@ import { MempoolBlockOverviewComponent } from '../components/mempool-block-overv
import { ClockchainComponent } from '../components/clockchain/clockchain.component';
import { ClockFaceComponent } from '../components/clock-face/clock-face.component';
import { ClockComponent } from '../components/clock/clock.component';
import { ClockMinedComponent } from '../components/clock/clock-mined.component';
import { ClockMempoolComponent } from '../components/clock/clock-mempool.component';
@NgModule({
declarations: [
@@ -185,8 +183,6 @@ import { ClockMempoolComponent } from '../components/clock/clock-mempool.compone
MempoolBlockOverviewComponent,
ClockchainComponent,
ClockComponent,
ClockMinedComponent,
ClockMempoolComponent,
ClockFaceComponent,
],
imports: [
@@ -300,8 +296,6 @@ import { ClockMempoolComponent } from '../components/clock/clock-mempool.compone
MempoolBlockOverviewComponent,
ClockchainComponent,
ClockComponent,
ClockMinedComponent,
ClockMempoolComponent,
ClockFaceComponent,
]
})
@@ -310,6 +304,7 @@ export class SharedModule {
library.addIcons(faInfoCircle);
library.addIcons(faChartArea);
library.addIcons(faTv);
library.addIcons(faClock);
library.addIcons(faTachometerAlt);
library.addIcons(faCubes);
library.addIcons(faHammer);

View File

@@ -1,5 +1,6 @@
var https = require('https');
var fs = require('fs');
var crypto = require('crypto');
const CONFIG_FILE_NAME = 'mempool-frontend-config.json';
let configContent = {};
@@ -37,39 +38,180 @@ function download(filename, url) {
});
}
function downloadMiningPoolLogos() {
const options = {
host: 'api.github.com',
path: '/repos/mempool/mining-pool-logos/contents/',
method: 'GET',
headers: {'user-agent': 'node.js'}
};
https.get(options, (response) => {
let chunks_of_data = [];
response.on('data', (fragments) => {
chunks_of_data.push(fragments);
});
response.on('end', () => {
let response_body = Buffer.concat(chunks_of_data);
try {
const poolLogos = JSON.parse(response_body.toString());
for (const poolLogo of poolLogos) {
download(`${PATH}/mining-pools/${poolLogo.name}`, poolLogo.download_url);
}
} catch (e) {
console.error(`Unable to download mining pool logos. Trying again at next restart. Reason: ${e instanceof Error ? e.message : e}`);
}
});
response.on('error', (error) => {
throw new Error(error);
});
})
function getLocalHash(filePath) {
const size = fs.statSync(filePath);
const buffer = fs.readFileSync(filePath);
const bufferWithHeader = Buffer.concat([Buffer.from('blob '), Buffer.from(`${size.size}`), Buffer.from('\0'), buffer]);
return crypto.createHash('sha1').update(bufferWithHeader).digest('hex');
}
function downloadMiningPoolLogos$() {
return new Promise((resolve, reject) => {
console.log('Checking if mining pool logos needs downloading or updating...');
const options = {
host: 'api.github.com',
path: '/repos/mempool/mining-pool-logos/contents/',
method: 'GET',
headers: {'user-agent': 'node.js'}
};
https.get(options, (response) => {
const chunks_of_data = [];
response.on('data', (fragments) => {
chunks_of_data.push(fragments);
});
response.on('end', () => {
const response_body = Buffer.concat(chunks_of_data);
try {
const poolLogos = JSON.parse(response_body.toString());
if (poolLogos.message) {
reject(poolLogos.message);
}
let downloadedCount = 0;
for (const poolLogo of poolLogos) {
const filePath = `${PATH}/mining-pools/${poolLogo.name}`;
if (fs.existsSync(filePath)) {
const localHash = getLocalHash(filePath);
if (localHash !== poolLogo.sha) {
console.log(`${poolLogo.name} is different on the remote, downloading...`);
download(filePath, poolLogo.download_url);
downloadedCount++;
}
} else {
console.log(`${poolLogo.name} is missing, downloading...`);
download(filePath, poolLogo.download_url);
downloadedCount++;
}
}
console.log(`Downloaded ${downloadedCount} and skipped ${poolLogos.length - downloadedCount} existing mining pool logos`);
resolve();
} catch (e) {
reject(`Unable to download mining pool logos. Trying again at next restart. Reason: ${e instanceof Error ? e.message : e}`);
}
});
response.on('error', (error) => {
reject(error);
});
});
});
}
function downloadPromoVideoSubtiles$() {
return new Promise((resolve, reject) => {
console.log('Checking if promo video subtitles needs downloading or updating...');
const options = {
host: 'api.github.com',
path: '/repos/mempool/mempool-promo/contents/subtitles',
method: 'GET',
headers: {'user-agent': 'node.js'}
};
https.get(options, (response) => {
const chunks_of_data = [];
response.on('data', (fragments) => {
chunks_of_data.push(fragments);
});
response.on('end', () => {
const response_body = Buffer.concat(chunks_of_data);
try {
const videoLanguages = JSON.parse(response_body.toString());
if (videoLanguages.message) {
reject(videoLanguages.message);
}
let downloadedCount = 0;
for (const language of videoLanguages) {
const filePath = `${PATH}/promo-video/${language.name}`;
if (fs.existsSync(filePath)) {
const localHash = getLocalHash(filePath);
if (localHash !== language.sha) {
console.log(`${language.name} is different on the remote, updating`);
download(filePath, language.download_url);
downloadedCount++;
}
} else {
console.log(`${language.name} is missing, downloading`);
download(filePath, language.download_url);
downloadedCount++;
}
}
console.log(`Downloaded ${downloadedCount} and skipped ${videoLanguages.length - downloadedCount} existing video subtitles`);
resolve();
} catch (e) {
reject(`Unable to download video subtitles. Trying again at next restart. Reason: ${e instanceof Error ? e.message : e}`);
}
});
response.on('error', (error) => {
reject(error);
});
});
});
}
function downloadPromoVideo$() {
return new Promise((resolve, reject) => {
console.log('Checking if promo video needs downloading or updating...');
const options = {
host: 'api.github.com',
path: '/repos/mempool/mempool-promo/contents',
method: 'GET',
headers: {'user-agent': 'node.js'}
};
https.get(options, (response) => {
const chunks_of_data = [];
response.on('data', (fragments) => {
chunks_of_data.push(fragments);
});
response.on('end', () => {
const response_body = Buffer.concat(chunks_of_data);
try {
const contents = JSON.parse(response_body.toString());
if (contents.message) {
reject(contents.message);
}
for (const item of contents) {
if (item.name !== 'promo.mp4') {
continue;
}
const filePath = `${PATH}/promo-video/mempool-promo.mp4`;
if (fs.existsSync(filePath)) {
const localHash = getLocalHash(filePath);
if (localHash !== item.sha) {
console.log(`mempool-promo.mp4 is different on the remote, updating`);
download(filePath, item.download_url);
console.log('mempool-promo.mp4 downloaded.');
} else {
console.log(`mempool-promo.mp4 is already up to date. Skipping.`);
}
} else {
console.log(`mempool-promo.mp4 is missing, downloading`);
download(filePath, item.download_url);
}
}
resolve();
} catch (e) {
reject(`Unable to download video. Trying again at next restart. Reason: ${e instanceof Error ? e.message : e}`);
}
});
response.on('error', (error) => {
reject(error);
});
});
});
}
let assetsJsonUrl = 'https://raw.githubusercontent.com/mempool/asset_registry_db/master/index.json';
let assetsMinimalJsonUrl = 'https://raw.githubusercontent.com/mempool/asset_registry_db/master/index.minimal.json';
@@ -81,11 +223,6 @@ if (configContent.BASE_MODULE && configContent.BASE_MODULE === 'liquid') {
const testnetAssetsJsonUrl = 'https://raw.githubusercontent.com/Blockstream/asset_registry_testnet_db/master/index.json';
const testnetAssetsMinimalJsonUrl = 'https://raw.githubusercontent.com/Blockstream/asset_registry_testnet_db/master/index.minimal.json';
const promoPrefix = PATH + 'promo-video/';
const promoVideoFile = promoPrefix + 'mempool-promo.mp4';
const promoVideoUrl = 'https://raw.githubusercontent.com/mempool/mempool-promo/master/promo.mp4';
const promoVideoLanguages = ['en','sv','ja','zh','cs','fi','fr','de','it','lt','nb','fa','pl','ro','pt'];
console.log('Downloading assets');
download(PATH + 'assets.json', assetsJsonUrl);
console.log('Downloading assets minimal');
@@ -94,13 +231,10 @@ console.log('Downloading testnet assets');
download(PATH + 'assets-testnet.json', testnetAssetsJsonUrl);
console.log('Downloading testnet assets minimal');
download(PATH + 'assets-testnet.minimal.json', testnetAssetsMinimalJsonUrl);
if (!fs.existsSync(promoVideoFile)) {
console.log('Downloading promo video');
download(promoVideoFile, promoVideoUrl);
}
console.log('Downloading promo video subtitles');
for( const l of promoVideoLanguages ) {
download(promoPrefix + l + ".vtt", "https://raw.githubusercontent.com/mempool/mempool-promo/master/subtitles/" + l + ".vtt");
}
console.log('Downloading mining pool logos');
downloadMiningPoolLogos();
downloadMiningPoolLogos$()
.then(() => downloadPromoVideoSubtiles$())
.then(() => downloadPromoVideo$())
.catch((error) => {
throw new Error(error);
});

View File

@@ -1239,7 +1239,7 @@ if [ "${BITCOIN_ELECTRS_INSTALL}" = ON ];then
case $OS in
FreeBSD)
echo "[*] Patching Bitcoin Electrs code for FreeBSD"
osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_HOME}/.cargo/registry/src/github.com-1ecc6299db9ec823/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\""
osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_HOME}/.cargo/registry/src/index.crates.io-6f17d22bba15001f/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\""
osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_ELECTRS_HOME}/src/new_index/\" && sed -i.bak -e s/Snappy/None/ db.rs && rm db.rs.bak"
osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_ELECTRS_HOME}/src/bin/\" && sed -i.bak -e 's/from_secs(5)/from_secs(1)/' electrs.rs && rm electrs.rs.bak"
;;
@@ -1289,7 +1289,7 @@ if [ "${ELEMENTS_ELECTRS_INSTALL}" = ON ];then
case $OS in
FreeBSD)
echo "[*] Patching Liquid Electrs code for FreeBSD"
osSudo "${ELEMENTS_USER}" sh -c "cd \"${ELEMENTS_HOME}/.cargo/registry/src/github.com-1ecc6299db9ec823/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\""
osSudo "${ELEMENTS_USER}" sh -c "cd \"${ELEMENTS_HOME}/.cargo/registry/src/index.crates.io-6f17d22bba15001f/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\""
;;
Debian)
;;

View File

@@ -97,10 +97,10 @@ location @mempool-api-v1-cache-normal {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache api;
proxy_cache_valid 200 10s;
proxy_cache_valid 200 2s;
proxy_redirect off;
expires 10s;
expires 2s;
}
location @mempool-api-v1-cache-disabled {