Merge branch 'master' into wiz/installer2

This commit is contained in:
wiz 2022-03-11 16:17:51 +00:00 committed by GitHub
commit 6fc0311b8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 992 additions and 414 deletions

View File

@ -41,7 +41,9 @@ class BitcoinApi implements AbstractBitcoinApi {
$getBlockHeightTip(): Promise<number> {
return this.bitcoindClient.getChainTips()
.then((result: IBitcoinApi.ChainTips[]) => result[0].height);
.then((result: IBitcoinApi.ChainTips[]) => {
return result.find(tip => tip.status === 'active')!.height;
});
}
$getTxIdsForBlock(hash: string): Promise<string[]> {
@ -216,7 +218,7 @@ class BitcoinApi implements AbstractBitcoinApi {
if (map[outputType]) {
return map[outputType];
} else {
return '';
return 'unknown';
}
}

View File

@ -108,17 +108,14 @@ class Blocks {
blockExtended.extras.reward = transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0);
blockExtended.extras.coinbaseTx = transactionUtils.stripCoinbaseTransaction(transactions[0]);
const transactionsTmp = [...transactions];
transactionsTmp.shift();
transactionsTmp.sort((a, b) => b.effectiveFeePerVsize - a.effectiveFeePerVsize);
blockExtended.extras.medianFee = transactionsTmp.length > 0 ?
Common.median(transactionsTmp.map((tx) => tx.effectiveFeePerVsize)) : 0;
blockExtended.extras.feeRange = transactionsTmp.length > 0 ?
Common.getFeesInRange(transactionsTmp, 8) : [0, 0];
blockExtended.extras.totalFees = transactionsTmp.reduce((acc, tx) => {
return acc + tx.fee;
}, 0)
const stats = await bitcoinClient.getBlockStats(block.id);
const coinbaseRaw: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(transactions[0].txid, true);
blockExtended.extras.coinbaseRaw = coinbaseRaw.hex;
blockExtended.extras.medianFee = stats.feerate_percentiles[2]; // 50th percentiles
blockExtended.extras.feeRange = [stats.minfeerate, stats.feerate_percentiles, stats.maxfeerate].flat();
blockExtended.extras.totalFees = stats.totalfee;
blockExtended.extras.avgFee = stats.avgfee;
blockExtended.extras.avgFeeRate = stats.avgfeerate;
if (Common.indexingEnabled()) {
let pool: PoolTag;
@ -184,7 +181,6 @@ class Blocks {
}
this.blockIndexingStarted = true;
const startedAt = new Date().getTime() / 1000;
try {
let currentBlockHeight = blockchainInfo.blocks;
@ -201,6 +197,9 @@ class Blocks {
const chunkSize = 10000;
let totaIndexed = await blocksRepository.$blockCount(null, null);
let indexedThisRun = 0;
const startedAt = new Date().getTime() / 1000;
let timer = new Date().getTime() / 1000;
while (currentBlockHeight >= lastBlockToIndex) {
const endBlock = Math.max(0, lastBlockToIndex, currentBlockHeight - chunkSize + 1);
@ -219,12 +218,16 @@ class Blocks {
break;
}
++indexedThisRun;
if (++totaIndexed % 100 === 0 || blockHeight === lastBlockToIndex) {
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
++totaIndexed;
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
if (elapsedSeconds > 5 || blockHeight === lastBlockToIndex) {
const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
const blockPerSeconds = Math.max(1, Math.round(indexedThisRun / elapsedSeconds));
const progress = Math.round(totaIndexed / indexingBlockAmount * 100);
const timeLeft = Math.round((indexingBlockAmount - totaIndexed) / blockPerSeconds);
logger.debug(`Indexing block #${blockHeight} | ~${blockPerSeconds} blocks/sec | total: ${totaIndexed}/${indexingBlockAmount} (${progress}%) | elapsed: ${elapsedSeconds} seconds | left: ~${timeLeft} seconds`);
logger.debug(`Indexing block #${blockHeight} | ~${blockPerSeconds} blocks/sec | total: ${totaIndexed}/${indexingBlockAmount} (${progress}%) | elapsed: ${runningFor} seconds | left: ~${timeLeft} seconds`);
timer = new Date().getTime() / 1000;
indexedThisRun = 0;
}
const blockHash = await bitcoinApi.$getBlockHash(blockHeight);
const block = await bitcoinApi.$getBlock(blockHash);
@ -249,7 +252,7 @@ class Blocks {
const blockHeightTip = await bitcoinApi.$getBlockHeightTip();
if (this.blocks.length === 0) {
this.currentBlockHeight = blockHeightTip - config.MEMPOOL.INITIAL_BLOCKS_AMOUNT;
this.currentBlockHeight = Math.max(blockHeightTip - config.MEMPOOL.INITIAL_BLOCKS_AMOUNT, -1);
} else {
this.currentBlockHeight = this.blocks[this.blocks.length - 1].height;
}
@ -268,17 +271,19 @@ class Blocks {
this.lastDifficultyAdjustmentTime = block.timestamp;
this.currentDifficulty = block.difficulty;
const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016);
const previousPeriodBlock = await bitcoinApi.$getBlock(previousPeriodBlockHash);
this.previousDifficultyRetarget = (block.difficulty - previousPeriodBlock.difficulty) / previousPeriodBlock.difficulty * 100;
logger.debug(`Initial difficulty adjustment data set.`);
if (blockHeightTip >= 2016) {
const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016);
const previousPeriodBlock = await bitcoinApi.$getBlock(previousPeriodBlockHash);
this.previousDifficultyRetarget = (block.difficulty - previousPeriodBlock.difficulty) / previousPeriodBlock.difficulty * 100;
logger.debug(`Initial difficulty adjustment data set.`);
}
} else {
logger.debug(`Blockchain headers (${blockchainInfo.headers}) and blocks (${blockchainInfo.blocks}) not in sync. Waiting...`);
}
}
while (this.currentBlockHeight < blockHeightTip) {
if (this.currentBlockHeight === 0) {
if (this.currentBlockHeight < blockHeightTip - config.MEMPOOL.INITIAL_BLOCKS_AMOUNT) {
this.currentBlockHeight = blockHeightTip;
} else {
this.currentBlockHeight++;

View File

@ -6,7 +6,7 @@ import logger from '../logger';
const sleep = (ms: number) => new Promise(res => setTimeout(res, ms));
class DatabaseMigration {
private static currentVersion = 9;
private static currentVersion = 11;
private queryTimeout = 120000;
private statisticsAddedIndexed = false;
@ -92,13 +92,13 @@ class DatabaseMigration {
await this.$executeQuery(connection, this.getCreateBlocksTableQuery(), await this.$checkIfTableExists('blocks'));
}
if (databaseSchemaVersion < 5 && isBitcoin === true) {
logger.warn(`'blocks' table has been truncated. Re-indexing from scratch.'`);
logger.warn(`'blocks' table has been truncated. Re-indexing from scratch.`);
await this.$executeQuery(connection, 'TRUNCATE blocks;'); // Need to re-index
await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `reward` double unsigned NOT NULL DEFAULT "0"');
}
if (databaseSchemaVersion < 6 && isBitcoin === true) {
logger.warn(`'blocks' table has been truncated. Re-indexing from scratch.'`);
logger.warn(`'blocks' table has been truncated. Re-indexing from scratch.`);
await this.$executeQuery(connection, 'TRUNCATE blocks;'); // Need to re-index
// Cleanup original blocks fields type
await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `height` integer unsigned NOT NULL DEFAULT "0"');
@ -125,7 +125,7 @@ class DatabaseMigration {
}
if (databaseSchemaVersion < 8 && isBitcoin === true) {
logger.warn(`'hashrates' table has been truncated. Re-indexing from scratch.'`);
logger.warn(`'hashrates' table has been truncated. Re-indexing from scratch.`);
await this.$executeQuery(connection, 'TRUNCATE hashrates;'); // Need to re-index
await this.$executeQuery(connection, 'ALTER TABLE `hashrates` DROP INDEX `PRIMARY`');
await this.$executeQuery(connection, 'ALTER TABLE `hashrates` ADD `id` int NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST');
@ -134,12 +134,28 @@ class DatabaseMigration {
}
if (databaseSchemaVersion < 9 && isBitcoin === true) {
logger.warn(`'hashrates' table has been truncated. Re-indexing from scratch.'`);
logger.warn(`'hashrates' table has been truncated. Re-indexing from scratch.`);
await this.$executeQuery(connection, 'TRUNCATE hashrates;'); // Need to re-index
await this.$executeQuery(connection, 'ALTER TABLE `state` CHANGE `name` `name` varchar(100)');
await this.$executeQuery(connection, 'ALTER TABLE `hashrates` ADD UNIQUE `hashrate_timestamp_pool_id` (`hashrate_timestamp`, `pool_id`)');
}
if (databaseSchemaVersion < 10 && isBitcoin === true) {
await this.$executeQuery(connection, 'ALTER TABLE `blocks` ADD INDEX `blockTimestamp` (`blockTimestamp`)');
}
if (databaseSchemaVersion < 11 && isBitcoin === true) {
logger.warn(`'blocks' table has been truncated. Re-indexing from scratch.`);
await this.$executeQuery(connection, 'TRUNCATE blocks;'); // Need to re-index
await this.$executeQuery(connection, `ALTER TABLE blocks
ADD avg_fee INT UNSIGNED NULL,
ADD avg_fee_rate INT UNSIGNED NULL
`);
await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `reward` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `median_fee` INT UNSIGNED NOT NULL DEFAULT "0"');
await this.$executeQuery(connection, 'ALTER TABLE blocks MODIFY `fees` INT UNSIGNED NOT NULL DEFAULT "0"');
}
connection.release();
} catch (e) {
connection.release();

View File

@ -8,6 +8,7 @@ import { IBitcoinApi } from './bitcoin/bitcoin-api.interface';
import loadingIndicators from './loading-indicators';
import bitcoinClient from './bitcoin/bitcoin-client';
import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
import rbfCache from './rbf-cache';
class Mempool {
private static WEBSOCKET_REFRESH_RATE_MS = 10000;
@ -200,6 +201,17 @@ class Mempool {
logger.debug('Mempool updated in ' + time / 1000 + ' seconds');
}
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) {
for (const rbfTransaction in rbfTransactions) {
if (this.mempoolCache[rbfTransaction]) {
// Store replaced transactions
rbfCache.add(rbfTransaction, rbfTransactions[rbfTransaction].txid);
// Erase the replaced transactions from the local mempool
delete this.mempoolCache[rbfTransaction];
}
}
}
private updateTxPerSecond() {
const nowMinusTimeSpan = new Date().getTime() - (1000 * config.STATISTICS.TX_PER_SECOND_SAMPLE_PERIOD);
this.txPerSecondArray = this.txPerSecondArray.filter((unixTime) => unixTime > nowMinusTimeSpan);

View File

@ -1,5 +1,5 @@
import { PoolInfo, PoolStats } from '../mempool.interfaces';
import BlocksRepository, { EmptyBlocks } from '../repositories/BlocksRepository';
import BlocksRepository from '../repositories/BlocksRepository';
import PoolsRepository from '../repositories/PoolsRepository';
import HashratesRepository from '../repositories/HashratesRepository';
import bitcoinClient from './bitcoin/bitcoin-client';
@ -20,25 +20,21 @@ class Mining {
const poolsStatistics = {};
const poolsInfo: PoolInfo[] = await PoolsRepository.$getPoolsInfo(interval);
const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$getEmptyBlocks(null, interval);
const emptyBlocks: any[] = await BlocksRepository.$countEmptyBlocks(null, interval);
const poolsStats: PoolStats[] = [];
let rank = 1;
poolsInfo.forEach((poolInfo: PoolInfo) => {
const emptyBlocksCount = emptyBlocks.filter((emptyCount) => emptyCount.poolId === poolInfo.poolId);
const poolStat: PoolStats = {
poolId: poolInfo.poolId, // mysql row id
name: poolInfo.name,
link: poolInfo.link,
blockCount: poolInfo.blockCount,
rank: rank++,
emptyBlocks: 0
emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0
};
for (let i = 0; i < emptyBlocks.length; ++i) {
if (emptyBlocks[i].poolId === poolInfo.poolId) {
poolStat.emptyBlocks++;
}
}
poolsStats.push(poolStat);
});
@ -58,19 +54,19 @@ class Mining {
/**
* Get all mining pool stats for a pool
*/
public async $getPoolStat(interval: string | null, poolId: number): Promise<object> {
public async $getPoolStat(poolId: number): Promise<object> {
const pool = await PoolsRepository.$getPool(poolId);
if (!pool) {
throw new Error(`This mining pool does not exist`);
}
const blockCount: number = await BlocksRepository.$blockCount(poolId, interval);
const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$getEmptyBlocks(poolId, interval);
const blockCount: number = await BlocksRepository.$blockCount(poolId);
const emptyBlocksCount = await BlocksRepository.$countEmptyBlocks(poolId);
return {
pool: pool,
blockCount: blockCount,
emptyBlocks: emptyBlocks,
emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0,
};
}
@ -97,8 +93,11 @@ class Mining {
const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps();
const hashrates: any[] = [];
const genesisTimestamp = 1231006505; // bitcoin-cli getblock 000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f
const lastMidnight = this.getDateMidnight(new Date());
let toTimestamp = Math.round((lastMidnight.getTime() - 604800) / 1000);
const now = new Date();
const lastMonday = new Date(now.setDate(now.getDate() - (now.getDay() + 6) % 7));
const lastMondayMidnight = this.getDateMidnight(lastMonday);
let toTimestamp = Math.round((lastMondayMidnight.getTime() - 604800) / 1000);
const totalWeekIndexed = (await BlocksRepository.$blockCount(null, null)) / 1008;
let indexedThisRun = 0;
@ -146,7 +145,7 @@ class Mining {
hashrates.length = 0;
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
if (elapsedSeconds > 5) {
if (elapsedSeconds > 1) {
const weeksPerSeconds = (indexedThisRun / elapsedSeconds).toFixed(2);
const formattedDate = new Date(fromTimestamp * 1000).toUTCString();
const weeksLeft = Math.round(totalWeekIndexed - totalIndexed);
@ -232,7 +231,7 @@ class Mining {
}
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
if (elapsedSeconds > 5) {
if (elapsedSeconds > 1) {
const daysPerSeconds = (indexedThisRun / elapsedSeconds).toFixed(2);
const formattedDate = new Date(fromTimestamp * 1000).toUTCString();
const daysLeft = Math.round(totalDayIndexed - totalIndexed);

View File

@ -0,0 +1,34 @@
export interface CachedRbf {
txid: string;
expires: Date;
}
class RbfCache {
private cache: { [txid: string]: CachedRbf; } = {};
constructor() {
setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
}
public add(replacedTxId: string, newTxId: string): void {
this.cache[replacedTxId] = {
expires: new Date(Date.now() + 1000 * 604800), // 1 week
txid: newTxId,
};
}
public get(txId: string): CachedRbf | undefined {
return this.cache[txId];
}
private cleanup(): void {
const currentDate = new Date();
for (const c in this.cache) {
if (this.cache[c].expires < currentDate) {
delete this.cache[c];
}
}
}
}
export default new RbfCache();

View File

@ -11,6 +11,7 @@ import { Common } from './common';
import loadingIndicators from './loading-indicators';
import config from '../config';
import transactionUtils from './transaction-utils';
import rbfCache from './rbf-cache';
class WebsocketHandler {
private wss: WebSocket.Server | undefined;
@ -48,29 +49,38 @@ class WebsocketHandler {
if (parsedMessage && parsedMessage['track-tx']) {
if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-tx'])) {
client['track-tx'] = parsedMessage['track-tx'];
// Client is telling the transaction wasn't found but it might have appeared before we had the time to start watching for it
// Client is telling the transaction wasn't found
if (parsedMessage['watch-mempool']) {
const tx = memPool.getMempool()[client['track-tx']];
if (tx) {
if (config.MEMPOOL.BACKEND === 'esplora') {
response['tx'] = tx;
const rbfCacheTx = rbfCache.get(client['track-tx']);
if (rbfCacheTx) {
response['txReplaced'] = {
txid: rbfCacheTx.txid,
};
client['track-tx'] = null;
} else {
// It might have appeared before we had the time to start watching for it
const tx = memPool.getMempool()[client['track-tx']];
if (tx) {
if (config.MEMPOOL.BACKEND === 'esplora') {
response['tx'] = tx;
} else {
// tx.prevout is missing from transactions when in bitcoind mode
try {
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true);
response['tx'] = fullTx;
} catch (e) {
logger.debug('Error finding transaction: ' + (e instanceof Error ? e.message : e));
}
}
} else {
// tx.prevouts is missing from transactions when in bitcoind mode
try {
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true);
const fullTx = await transactionUtils.$getTransactionExtended(client['track-tx'], true);
response['tx'] = fullTx;
} catch (e) {
logger.debug('Error finding transaction: ' + (e instanceof Error ? e.message : e));
logger.debug('Error finding transaction. ' + (e instanceof Error ? e.message : e));
client['track-mempool-tx'] = parsedMessage['track-tx'];
}
}
} else {
try {
const fullTx = await transactionUtils.$getTransactionExtended(client['track-tx'], true);
response['tx'] = fullTx;
} catch (e) {
logger.debug('Error finding transaction. ' + (e instanceof Error ? e.message : e));
client['track-mempool-tx'] = parsedMessage['track-tx'];
}
}
}
} else {
@ -221,14 +231,10 @@ class WebsocketHandler {
mempoolBlocks.updateMempoolBlocks(newMempool);
const mBlocks = mempoolBlocks.getMempoolBlocks();
const mempool = memPool.getMempool();
const mempoolInfo = memPool.getMempoolInfo();
const vBytesPerSecond = memPool.getVBytesPerSecond();
const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions);
for (const rbfTransaction in rbfTransactions) {
delete mempool[rbfTransaction];
}
memPool.handleRbfTransactions(rbfTransactions);
this.wss.clients.forEach(async (client: WebSocket) => {
if (client.readyState !== WebSocket.OPEN) {
@ -332,27 +338,26 @@ class WebsocketHandler {
}
if (client['track-tx']) {
const utxoSpent = newTransactions.some((tx) => {
return tx.vin.some((vin) => vin.txid === client['track-tx']);
});
if (utxoSpent) {
response['utxoSpent'] = true;
const outspends: object = {};
newTransactions.forEach((tx) => tx.vin.forEach((vin, i) => {
if (vin.txid === client['track-tx']) {
outspends[vin.vout] = {
vin: i,
txid: tx.txid,
};
}
}));
if (Object.keys(outspends).length) {
response['utxoSpent'] = outspends;
}
if (rbfTransactions[client['track-tx']]) {
for (const rbfTransaction in rbfTransactions) {
if (client['track-tx'] === rbfTransaction) {
const rbfTx = rbfTransactions[rbfTransaction];
if (config.MEMPOOL.BACKEND !== 'esplora') {
try {
const fullTx = await transactionUtils.$getTransactionExtended(rbfTransaction, true);
response['rbfTransaction'] = fullTx;
} catch (e) {
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e));
}
} else {
response['rbfTransaction'] = rbfTx;
}
response['rbfTransaction'] = {
txid: rbfTransactions[rbfTransaction].txid,
};
break;
}
}
@ -414,7 +419,6 @@ class WebsocketHandler {
}
if (client['track-tx'] && txIds.indexOf(client['track-tx']) > -1) {
client['track-tx'] = null;
response['txConfirmed'] = true;
}

View File

@ -299,6 +299,7 @@ class Server {
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/2y', routes.$getPools.bind(routes, '2y'))
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/3y', routes.$getPools.bind(routes, '3y'))
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/all', routes.$getPools.bind(routes, 'all'))
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/hashrate', routes.$getPoolHistoricalHashrate)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/blocks', routes.$getPoolBlocks)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/blocks/:height', routes.$getPoolBlocks)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId', routes.$getPool)

View File

@ -79,7 +79,7 @@ export interface TransactionStripped {
export interface BlockExtension {
totalFees?: number;
medianFee?: number;
medianFee?: number; // Actually the median fee rate that we compute ourself
feeRange?: number[];
reward?: number;
coinbaseTx?: TransactionMinerInfo;
@ -87,7 +87,10 @@ export interface BlockExtension {
pool?: {
id: number;
name: string;
}
};
avgFee?: number;
avgFeeRate?: number;
coinbaseRaw?: string;
}
export interface BlockExtended extends IEsploraApi.Block {

View File

@ -3,11 +3,6 @@ import { DB } from '../database';
import logger from '../logger';
import { Common } from '../api/common';
export interface EmptyBlocks {
emptyBlocks: number;
poolId: number;
}
class BlocksRepository {
/**
* Save indexed block data in the database
@ -17,17 +12,17 @@ class BlocksRepository {
try {
const query = `INSERT INTO blocks(
height, hash, blockTimestamp, size,
weight, tx_count, coinbase_raw, difficulty,
pool_id, fees, fee_span, median_fee,
reward, version, bits, nonce,
merkle_root, previous_block_hash
height, hash, blockTimestamp, size,
weight, tx_count, coinbase_raw, difficulty,
pool_id, fees, fee_span, median_fee,
reward, version, bits, nonce,
merkle_root, previous_block_hash, avg_fee, avg_fee_rate
) VALUE (
?, ?, FROM_UNIXTIME(?), ?,
?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?, ?,
?, ?
?, ?, ?, ?
)`;
const params: any[] = [
@ -37,21 +32,22 @@ class BlocksRepository {
block.size,
block.weight,
block.tx_count,
'',
block.extras.coinbaseRaw,
block.difficulty,
block.extras.pool?.id, // Should always be set to something
0,
'[]',
block.extras.medianFee ?? 0,
block.extras.reward ?? 0,
block.extras.totalFees,
JSON.stringify(block.extras.feeRange),
block.extras.medianFee,
block.extras.reward,
block.version,
block.bits,
block.nonce,
block.merkle_root,
block.previousblockhash
block.previousblockhash,
block.extras.avgFee,
block.extras.avgFeeRate,
];
// logger.debug(query);
await connection.query(query, params);
connection.release();
} catch (e: any) {
@ -100,12 +96,13 @@ class BlocksRepository {
/**
* Get empty blocks for one or all pools
*/
public async $getEmptyBlocks(poolId: number | null, interval: string | null = null): Promise<EmptyBlocks[]> {
public async $countEmptyBlocks(poolId: number | null, interval: string | null = null): Promise<any> {
interval = Common.getSqlInterval(interval);
const params: any[] = [];
let query = `SELECT height, hash, tx_count, size, pool_id, weight, UNIX_TIMESTAMP(blockTimestamp) as timestamp
let query = `SELECT count(height) as count, pools.id as poolId
FROM blocks
JOIN pools on pools.id = blocks.pool_id
WHERE tx_count = 1`;
if (poolId) {
@ -117,13 +114,14 @@ class BlocksRepository {
query += ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
}
// logger.debug(query);
query += ` GROUP by pools.id`;
const connection = await DB.pool.getConnection();
try {
const [rows] = await connection.query(query, params);
connection.release();
return <EmptyBlocks[]>rows;
return rows;
} catch (e) {
connection.release();
logger.err('$getEmptyBlocks() error' + (e instanceof Error ? e.message : e));
@ -134,7 +132,7 @@ class BlocksRepository {
/**
* Get blocks count for a period
*/
public async $blockCount(poolId: number | null, interval: string | null): Promise<number> {
public async $blockCount(poolId: number | null, interval: string | null = null): Promise<number> {
interval = Common.getSqlInterval(interval);
const params: any[] = [];
@ -275,7 +273,7 @@ class BlocksRepository {
/**
* Get one block by height
*/
public async $getBlockByHeight(height: number): Promise<object | null> {
public async $getBlockByHeight(height: number): Promise<object | null> {
const connection = await DB.pool.getConnection();
try {
const [rows]: any[] = await connection.query(`
@ -301,7 +299,7 @@ class BlocksRepository {
/**
* Return blocks difficulty
*/
public async $getBlocksDifficulty(interval: string | null): Promise<object[]> {
public async $getBlocksDifficulty(interval: string | null): Promise<object[]> {
interval = Common.getSqlInterval(interval);
const connection = await DB.pool.getConnection();

View File

@ -116,6 +116,52 @@ class HashratesRepository {
}
}
/**
* Returns a pool hashrate history
*/
public async $getPoolWeeklyHashrate(poolId: number): Promise<any[]> {
const connection = await DB.pool.getConnection();
// Find hashrate boundaries
let query = `SELECT MIN(hashrate_timestamp) as firstTimestamp, MAX(hashrate_timestamp) as lastTimestamp
FROM hashrates
JOIN pools on pools.id = pool_id
WHERE hashrates.type = 'weekly' AND pool_id = ? AND avg_hashrate != 0
ORDER by hashrate_timestamp LIMIT 1`;
let boundaries = {
firstTimestamp: '1970-01-01',
lastTimestamp: '9999-01-01'
};
try {
const [rows]: any[] = await connection.query(query, [poolId]);
boundaries = rows[0];
connection.release();
} catch (e) {
connection.release();
logger.err('$getPoolWeeklyHashrate() error' + (e instanceof Error ? e.message : e));
}
// Get hashrates entries between boundaries
query = `SELECT UNIX_TIMESTAMP(hashrate_timestamp) as timestamp, avg_hashrate as avgHashrate, share, pools.name as poolName
FROM hashrates
JOIN pools on pools.id = pool_id
WHERE hashrates.type = 'weekly' AND hashrate_timestamp BETWEEN ? AND ?
AND pool_id = ?
ORDER by hashrate_timestamp`;
try {
const [rows]: any[] = await connection.query(query, [boundaries.firstTimestamp, boundaries.lastTimestamp, poolId]);
connection.release();
return rows;
} catch (e) {
connection.release();
logger.err('$getPoolWeeklyHashrate() error' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $setLatestRunTimestamp(key: string, val: any = null) {
const connection = await DB.pool.getConnection();
const query = `UPDATE state SET number = ? WHERE name = ?`;
@ -136,6 +182,9 @@ class HashratesRepository {
const [rows] = await connection.query<any>(query, [key]);
connection.release();
if (rows.length === 0) {
return 0;
}
return rows[0]['number'];
} catch (e) {
connection.release();

View File

@ -538,7 +538,7 @@ class Routes {
public async $getPool(req: Request, res: Response) {
try {
const stats = await mining.$getPoolStat(req.params.interval ?? null, parseInt(req.params.poolId, 10));
const stats = await mining.$getPoolStat(parseInt(req.params.poolId, 10));
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
@ -603,6 +603,22 @@ class Routes {
}
}
public async $getPoolHistoricalHashrate(req: Request, res: Response) {
try {
const hashrates = await HashratesRepository.$getPoolWeeklyHashrate(parseInt(req.params.poolId, 10));
const oldestIndexedBlockTimestamp = await BlocksRepository.$oldestBlockTimestamp();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
res.json({
oldestIndexedBlockTimestamp: oldestIndexedBlockTimestamp,
hashrates: hashrates,
});
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
public async $getHistoricalHashrate(req: Request, res: Response) {
try {
const hashrates = await HashratesRepository.$getNetworkDailyHashrate(req.params.interval ?? null);
@ -665,7 +681,7 @@ class Routes {
}
let nextHash = startFromHash;
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 10 && nextHash; i++) {
const localBlock = blocks.getBlocks().find((b) => b.id === nextHash);
if (localBlock) {
returnBlocks.push(localBlock);

3
contributors/bosch-0.txt Normal file
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: Bosch-0

View File

@ -75,6 +75,7 @@ import { HashrateChartPoolsComponent } from './components/hashrates-chart-pools/
import { MiningStartComponent } from './components/mining-start/mining-start.component';
import { AmountShortenerPipe } from './shared/pipes/amount-shortener.pipe';
import { ShortenStringPipe } from './shared/pipes/shorten-string-pipe/shorten-string.pipe';
import { DifficultyAdjustmentsTable } from './components/difficulty-adjustments-table/difficulty-adjustments-table.components';
@NgModule({
declarations: [
@ -131,6 +132,7 @@ import { ShortenStringPipe } from './shared/pipes/shorten-string-pipe/shorten-st
HashrateChartPoolsComponent,
MiningStartComponent,
AmountShortenerPipe,
DifficultyAdjustmentsTable,
],
imports: [
BrowserModule.withServerTransition({ appId: 'serverApp' }),

View File

@ -217,12 +217,8 @@ export class BlockComponent implements OnInit, OnDestroy {
this.blockSubsidy = 0;
return;
}
this.blockSubsidy = 50;
let halvenings = Math.floor(this.block.height / 210000);
while (halvenings > 0) {
this.blockSubsidy = this.blockSubsidy / 2;
halvenings--;
}
const halvings = Math.floor(this.block.height / 210000);
this.blockSubsidy = 50 * 2 ** -halvings;
}
pageChange(page: number, target: HTMLElement) {

View File

@ -110,7 +110,7 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy {
this.markBlockSubscription = this.stateService.markBlock$
.subscribe((state) => {
this.markHeight = undefined;
if (state.blockHeight) {
if (state.blockHeight !== undefined) {
this.markHeight = state.blockHeight;
}
this.moveArrowToPosition(false);
@ -127,7 +127,7 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy {
}
moveArrowToPosition(animate: boolean, newBlockFromLeft = false) {
if (!this.markHeight) {
if (this.markHeight === undefined) {
this.arrowVisible = false;
return;
}

View File

@ -0,0 +1,33 @@
<div>
<table class="table latest-transactions" style="min-height: 295px">
<thead>
<tr>
<th class="d-none d-md-block" i18n="block.height">Height</th>
<th i18n="mining.adjusted" class="text-left">Adjusted</th>
<th i18n="mining.difficulty" class="text-right">Difficulty</th>
<th i18n="mining.change" class="text-right">Change</th>
</tr>
</thead>
<tbody *ngIf="(hashrateObservable$ | async) as data">
<tr *ngFor="let diffChange of data.difficulty">
<td class="d-none d-md-block"><a [routerLink]="['/block' | relativeUrl, diffChange.height]">{{ diffChange.height
}}</a></td>
<td class="text-left">
<app-time-since [time]="diffChange.timestamp" [fastRender]="true"></app-time-since>
</td>
<td class="text-right">{{ diffChange.difficultyShorten }}</td>
<td class="text-right" [style]="diffChange.change >= 0 ? 'color: #42B747' : 'color: #B74242'">
{{ diffChange.change >= 0 ? '+' : '' }}{{ formatNumber(diffChange.change, locale, '1.2-2') }}%
</td>
</tr>
</tbody>
<tbody *ngIf="isLoading">
<tr *ngFor="let item of [1,2,3,4,5]">
<td class="d-none d-md-block w-75"><span class="skeleton-loader"></span></td>
<td class="text-left"><span class="skeleton-loader w-75"></span></td>
<td class="text-right"><span class="skeleton-loader w-75"></span></td>
<td class="text-right"><span class="skeleton-loader w-75"></span></td>
</tr>
</tbody>
</table>
</div>

View File

@ -0,0 +1,40 @@
.latest-transactions {
width: 100%;
text-align: left;
table-layout:fixed;
tr, td, th {
border: 0px;
}
td {
width: 25%;
}
.table-cell-satoshis {
display: none;
text-align: right;
@media (min-width: 576px) {
display: table-cell;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 1100px) {
display: table-cell;
}
}
.table-cell-fiat {
display: none;
text-align: right;
@media (min-width: 485px) {
display: table-cell;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: table-cell;
}
}
.table-cell-fees {
text-align: right;
}
}

View File

@ -0,0 +1,65 @@
import { Component, Inject, LOCALE_ID, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ApiService } from 'src/app/services/api.service';
import { formatNumber } from '@angular/common';
import { selectPowerOfTen } from 'src/app/bitcoin.utils';
@Component({
selector: 'app-difficulty-adjustments-table',
templateUrl: './difficulty-adjustments-table.component.html',
styleUrls: ['./difficulty-adjustments-table.component.scss'],
styles: [`
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
}
`],
})
export class DifficultyAdjustmentsTable implements OnInit {
hashrateObservable$: Observable<any>;
isLoading = true;
formatNumber = formatNumber;
constructor(
@Inject(LOCALE_ID) public locale: string,
private apiService: ApiService,
) {
}
ngOnInit(): void {
this.hashrateObservable$ = this.apiService.getHistoricalHashrate$('1y')
.pipe(
map((data: any) => {
const availableTimespanDay = (
(new Date().getTime() / 1000) - (data.oldestIndexedBlockTimestamp)
) / 3600 / 24;
const tableData = [];
for (let i = data.difficulty.length - 1; i > 0; --i) {
const selectedPowerOfTen: any = selectPowerOfTen(data.difficulty[i].difficulty);
const change = (data.difficulty[i].difficulty / data.difficulty[i - 1].difficulty - 1) * 100;
tableData.push(Object.assign(data.difficulty[i], {
change: change,
difficultyShorten: formatNumber(
data.difficulty[i].difficulty / selectedPowerOfTen.divider,
this.locale, '1.2-2') + selectedPowerOfTen.unit
}));
}
this.isLoading = false;
return {
availableTimespanDay: availableTimespanDay,
difficulty: tableData.slice(0, 5),
};
}),
);
}
isMobile() {
return (window.innerWidth <= 767.98);
}
}

View File

@ -1,6 +1,6 @@
<div [class]="widget === false ? 'full-container' : ''">
<div *ngIf="!tableOnly" class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''">
<div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''">
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as hashrates">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 90">
@ -25,31 +25,10 @@
</form>
</div>
<div *ngIf="(hashrateObservable$ | async) && !tableOnly" [class]="!widget ? 'chart' : 'chart-widget'"
<div [class]="!widget ? 'chart' : 'chart-widget'"
echarts [initOpts]="chartInitOptions" [options]="chartOptions"></div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>
<div [class]="!widget ? 'mt-3 p-2' : 'ml-4 mr-4 mt-1'" *ngIf="tableOnly">
<table class="table table-borderless table-sm text-left" [class]="widget ? 'compact' : ''">
<thead>
<tr>
<th class="d-none d-md-block" i18n="block.timestamp">Timestamp</th>
<th i18n="mining.adjusted">Adjusted</th>
<th i18n="mining.difficulty" class="text-right">Difficulty</th>
<th i18n="mining.change" class="text-right">Change</th>
</tr>
</thead>
<tbody *ngIf="(hashrateObservable$ | async) as data">
<tr *ngFor="let diffChange of data.difficulty">
<td class="d-none d-md-block">&lrm;{{ diffChange.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}</td>
<td><app-time-since [time]="diffChange.timestamp" [fastRender]="true"></app-time-since></td>
<td class="text-right">{{ diffChange.difficultyShorten }}</td>
<td class="text-right" [style]="diffChange.change >= 0 ? 'color: #42B747' : 'color: #B74242'">{{ formatNumber(diffChange.change, locale, '1.2-2') }}%</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@ -48,8 +48,3 @@
}
}
}
.compact td {
padding: 0 !important;
margin: 0.15rem !important;
}

View File

@ -1,7 +1,7 @@
import { Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core';
import { EChartsOption, graphic } from 'echarts';
import { Observable } from 'rxjs';
import { map, share, startWith, switchMap, tap } from 'rxjs/operators';
import { delay, map, retryWhen, share, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from 'src/app/services/api.service';
import { SeoService } from 'src/app/services/seo.service';
import { formatNumber } from '@angular/common';
@ -15,11 +15,12 @@ import { selectPowerOfTen } from 'src/app/bitcoin.utils';
styles: [`
.loadingGraphs {
position: absolute;
top: 38%;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HashrateChartComponent implements OnInit {
@Input() tableOnly = false;
@ -45,6 +46,7 @@ export class HashrateChartComponent implements OnInit {
private seoService: SeoService,
private apiService: ApiService,
private formBuilder: FormBuilder,
private cd: ChangeDetectorRef,
) {
this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' });
this.radioGroupForm.controls.dateSpan.setValue('1y');
@ -92,9 +94,15 @@ export class HashrateChartComponent implements OnInit {
this.prepareChartOptions({
hashrates: data.hashrates.map(val => [val.timestamp * 1000, val.avgHashrate]),
difficulty: diffFixed.map(val => [val.timestamp * 1000, val.difficulty])
difficulty: diffFixed.map(val => [val.timestamp * 1000, val.difficulty]),
timestamp: data.oldestIndexedBlockTimestamp,
});
this.isLoading = false;
if (data.hashrates.length === 0) {
this.cd.markForCheck();
throw new Error();
}
}),
map((data: any) => {
const availableTimespanDay = (
@ -115,9 +123,12 @@ export class HashrateChartComponent implements OnInit {
}
return {
availableTimespanDay: availableTimespanDay,
difficulty: this.tableOnly ? (this.isMobile() ? tableData.slice(0, 12) : tableData.slice(0, 9)) : tableData
difficulty: this.tableOnly ? tableData.slice(0, 5) : tableData,
};
}),
retryWhen((errors) => errors.pipe(
delay(60000)
))
);
}),
share()
@ -125,7 +136,25 @@ export class HashrateChartComponent implements OnInit {
}
prepareChartOptions(data) {
let title: object;
if (data.hashrates.length === 0) {
const lastBlock = new Date(data.timestamp * 1000);
const dd = String(lastBlock.getDate()).padStart(2, '0');
const mm = String(lastBlock.getMonth() + 1).padStart(2, '0'); // January is 0!
const yyyy = lastBlock.getFullYear();
title = {
textStyle: {
color: 'grey',
fontSize: 15
},
text: `Indexing in progess - ${yyyy}-${mm}-${dd}`,
left: 'center',
top: 'center'
};
}
this.chartOptions = {
title: title,
color: [
new graphic.LinearGradient(0, 0, 0, 0.65, [
{ offset: 0, color: '#F4511E' },
@ -168,18 +197,19 @@ export class HashrateChartComponent implements OnInit {
difficulty = Math.round(data[1].data[1] / difficultyPowerOfTen.divider);
}
const date = new Date(data[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
return `
<b style="color: white; margin-left: 18px">${data[0].axisValueLabel}</b><br>
<b style="color: white; margin-left: 18px">${date}</b><br>
<span>${data[0].marker} ${data[0].seriesName}: ${formatNumber(hashrate, this.locale, '1.0-0')} ${hashratePowerOfTen.unit}H/s</span><br>
<span>${data[1].marker} ${data[1].seriesName}: ${formatNumber(difficulty, this.locale, '1.2-2')} ${difficultyPowerOfTen.unit}</span>
`;
}.bind(this)
},
xAxis: {
xAxis: data.hashrates.length === 0 ? undefined : {
type: 'time',
splitNumber: (this.isMobile() || this.widget) ? 5 : 10,
},
legend: {
legend: data.hashrates.length === 0 ? undefined : {
data: [
{
name: 'Hashrate',
@ -205,7 +235,7 @@ export class HashrateChartComponent implements OnInit {
},
],
},
yAxis: [
yAxis: data.hashrates.length === 0 ? undefined : [
{
min: function (value) {
return value.min * 0.9;
@ -244,7 +274,7 @@ export class HashrateChartComponent implements OnInit {
}
}
],
series: [
series: data.hashrates.length === 0 ? [] : [
{
name: 'Hashrate',
showSymbol: false,

View File

@ -25,7 +25,7 @@
</form>
</div>
<div *ngIf="hashrateObservable$ | async" [class]="!widget ? 'chart' : 'chart-widget'"
<div [class]="!widget ? 'chart' : 'chart-widget'"
echarts [initOpts]="chartInitOptions" [options]="chartOptions"></div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>

View File

@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core';
import { EChartsOption } from 'echarts';
import { Observable } from 'rxjs';
import { map, share, startWith, switchMap, tap } from 'rxjs/operators';
import { delay, map, retryWhen, share, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from 'src/app/services/api.service';
import { SeoService } from 'src/app/services/seo.service';
import { FormBuilder, FormGroup } from '@angular/forms';
@ -22,7 +22,7 @@ import { poolsColor } from 'src/app/app.constants';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HashrateChartPoolsComponent implements OnInit {
@Input() widget: boolean = false;
@Input() widget = false;
@Input() right: number | string = 40;
@Input() left: number | string = 25;
@ -43,6 +43,7 @@ export class HashrateChartPoolsComponent implements OnInit {
private seoService: SeoService,
private apiService: ApiService,
private formBuilder: FormBuilder,
private cd: ChangeDetectorRef,
) {
this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' });
this.radioGroupForm.controls.dateSpan.setValue('1y');
@ -105,9 +106,15 @@ export class HashrateChartPoolsComponent implements OnInit {
this.prepareChartOptions({
legends: legends,
series: series
series: series,
timestamp: data.oldestIndexedBlockTimestamp,
});
this.isLoading = false;
if (series.length === 0) {
this.cd.markForCheck();
throw new Error();
}
}),
map((data: any) => {
const availableTimespanDay = (
@ -117,6 +124,9 @@ export class HashrateChartPoolsComponent implements OnInit {
availableTimespanDay: availableTimespanDay,
};
}),
retryWhen((errors) => errors.pipe(
delay(60000)
))
);
}),
share()
@ -124,7 +134,25 @@ export class HashrateChartPoolsComponent implements OnInit {
}
prepareChartOptions(data) {
let title: object;
if (data.series.length === 0) {
const lastBlock = new Date(data.timestamp * 1000);
const dd = String(lastBlock.getDate()).padStart(2, '0');
const mm = String(lastBlock.getMonth() + 1).padStart(2, '0'); // January is 0!
const yyyy = lastBlock.getFullYear();
title = {
textStyle: {
color: 'grey',
fontSize: 15
},
text: `Indexing in progess - ${yyyy}-${mm}-${dd}`,
left: 'center',
top: 'center',
};
}
this.chartOptions = {
title: title,
grid: {
right: this.right,
left: this.left,
@ -146,7 +174,8 @@ export class HashrateChartPoolsComponent implements OnInit {
},
borderColor: '#000',
formatter: function (data) {
let tooltip = `<b style="color: white; margin-left: 18px">${data[0].axisValueLabel}</b><br>`;
const date = new Date(data[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
let tooltip = `<b style="color: white; margin-left: 18px">${date}</b><br>`;
data.sort((a, b) => b.data[1] - a.data[1]);
for (const pool of data) {
if (pool.data[1] > 0) {
@ -156,14 +185,14 @@ export class HashrateChartPoolsComponent implements OnInit {
return tooltip;
}.bind(this)
},
xAxis: {
xAxis: data.series.length === 0 ? undefined : {
type: 'time',
splitNumber: (this.isMobile() || this.widget) ? 5 : 10,
},
legend: (this.isMobile() || this.widget) ? undefined : {
legend: (this.isMobile() || this.widget || data.series.length === 0) ? undefined : {
data: data.legends
},
yAxis: {
yAxis: data.series.length === 0 ? undefined : {
position: 'right',
axisLabel: {
color: 'rgb(110, 112, 121)',

View File

@ -109,8 +109,12 @@ export class LatestBlocksComponent implements OnInit, OnDestroy {
if (this.isLoading) {
return;
}
const height = this.blocks[this.blocks.length - 1].height - 1;
if (height < 0) {
return;
}
this.isLoading = true;
this.electrsApiService.listBlocks$(this.blocks[this.blocks.length - 1].height - 1)
this.electrsApiService.listBlocks$(height)
.subscribe((blocks) => {
this.blocks = this.blocks.concat(blocks);
this.isLoading = false;

View File

@ -26,7 +26,7 @@
<app-time-until [time]="(1 * i) + now + 61000" [fastRender]="false" [fixedRender]="true"></app-time-until>
</ng-template>
<ng-template #timeDiffMainnet>
<app-time-until [time]="(timeAvg * i) + now + timeAvg" [fastRender]="false" [fixedRender]="true" [forceFloorOnTimeIntervals]="['hour']"></app-time-until>
<app-time-until [time]="(timeAvg * i) + now + timeAvg + timeOffset" [fastRender]="false" [fixedRender]="true" [forceFloorOnTimeIntervals]="['hour']"></app-time-until>
</ng-template>
</div>
<ng-template #mergedBlock>

View File

@ -33,6 +33,7 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
networkSubscription: Subscription;
network = '';
now = new Date().getTime();
timeOffset = 0;
showMiningInfo = false;
blockWidth = 125;
@ -146,6 +147,15 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
timeAvgMins += Math.abs(timeAvgDiff);
}
// testnet difficulty is set to 1 after 20 minutes of no blockSize
// therefore the time between blocks will always be below 20 minutes (1200s)
if (this.stateService.network === 'testnet' && now - block.timestamp + timeAvgMins * 60 > 1200) {
this.timeOffset = -Math.min(now - block.timestamp, 1200) * 1000;
timeAvgMins = 20;
} else {
this.timeOffset = 0;
}
return timeAvgMins * 60 * 1000;
})
);

View File

@ -2,11 +2,12 @@
<div class="row row-cols-1 row-cols-md-2">
<!-- Temporary stuff here - Will be moved to a component once we have more useful data to show -->
<div class="col">
<div class="main-title">Reward stats</div>
<div class="card" style="height: 123px">
<div class="card-body more-padding">
<div class="difficulty-adjustment-container" *ngIf="$rewardStats | async as rewardStats">
<div class="fee-estimation-container" *ngIf="$rewardStats | async as rewardStats; else loadingReward">
<div class="item">
<h5 class="card-title" i18n="">Miners Reward</h5>
<div class="card-text">
@ -17,7 +18,7 @@
<div class="item">
<h5 class="card-title" i18n="">Reward Per Tx</h5>
<div class="card-text">
{{ rewardStats.rewardPerTx }}
{{ rewardStats.rewardPerTx | amountShortener }}
<span class="symbol">sats/tx</span>
<div class="symbol">in the last 8 blocks</div>
</div>
@ -25,7 +26,7 @@
<div class="item">
<h5 class="card-title" i18n="">Average Fee</h5>
<div class="card-text">
{{ rewardStats.feePerTx }}
{{ rewardStats.feePerTx | amountShortener}}
<span class="symbol">sats/tx</span>
<div class="symbol">in the last 8 blocks</div>
</div>
@ -34,6 +35,31 @@
</div>
</div>
</div>
<ng-template #loadingReward>
<div class="fee-estimation-container">
<div class="item">
<h5 class="card-title" i18n="">Miners Reward</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="">Reward Per Tx</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="">Average Fee</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
</div>
</ng-template>
<!-- difficulty adjustment -->
<div class="col">
@ -86,9 +112,9 @@
<div class="card" style="height: 385px">
<div class="card-body">
<h5 class="card-title">
Adjusments
Adjustments
</h5>
<app-hashrate-chart [tableOnly]=true [widget]=true></app-hashrate-chart>
<app-difficulty-adjustments-table></app-difficulty-adjustments-table>
<div class="mt-1"><a [routerLink]="['/mining/hashrate' | relativeUrl]" i18n="dashboard.view-more">View more
&raquo;</a></div>
</div>
@ -96,4 +122,4 @@
</div>
</div>
</div>
</div>

View File

@ -44,7 +44,7 @@
.fade-border {
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 10%, black 80%, transparent 100%)
}
.main-title {
position: relative;
color: #ffffff91;
@ -56,39 +56,22 @@
padding-bottom: 3px;
}
.general-stats {
min-height: 56px;
display: block;
@media (min-width: 485px) {
display: flex;
.fee-estimation-container {
display: flex;
justify-content: space-between;
@media (min-width: 376px) {
flex-direction: row;
}
h5 {
margin-bottom: 10px;
}
.item {
width: 50%;
margin: 0px auto 10px;
display: inline-block;
@media (min-width: 485px) {
margin: 0px auto 10px;
max-width: 150px;
margin: 0;
width: -webkit-fill-available;
@media (min-width: 376px) {
margin: 0 auto 0px;
}
@media (min-width: 785px) {
margin: 0px auto 0px;
}
&:last-child {
margin: 0px auto 0px;
}
&:nth-child(2) {
order: 2;
&:first-child{
display: none;
@media (min-width: 485px) {
order: 3;
}
}
&:nth-child(3) {
order: 3;
@media (min-width: 485px) {
order: 2;
display: block;
}
@media (min-width: 768px) {
@ -98,48 +81,37 @@
display: block;
}
}
.card-title {
font-size: 1rem;
color: #4a68b9;
&:last-child {
margin-bottom: 0;
}
.card-text {
font-size: 18px;
span {
color: #ffffff66;
font-size: 12px;
}
.card-text span {
color: #ffffff66;
font-size: 12px;
top: 0px;
}
.fee-text{
border-bottom: 1px solid #ffffff1c;
width: fit-content;
margin: auto;
line-height: 1.45;
padding: 0px 2px;
}
.fiat {
display: block;
font-size: 14px !important;
}
}
}
.difficulty-adjustment-container {
display: flex;
flex-direction: row;
justify-content: space-around;
height: 76px;
.shared-block {
color: #ffffff66;
font-size: 12px;
.skeleton-loader {
width: 100%;
display: block;
&:first-child {
max-width: 90px;
margin: 15px auto 3px;
}
.item {
padding: 0 5px;
width: 100%;
&:nth-child(1) {
display: none;
@media (min-width: 485px) {
display: table-cell;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: table-cell;
}
}
}
.card-text {
font-size: 22px;
margin-top: -9px;
position: relative;
&:last-child {
margin: 10px auto 3px;
max-width: 55px;
}
}

View File

@ -2,8 +2,6 @@ import { ChangeDetectionStrategy, Component, Inject, LOCALE_ID, OnDestroy, OnIni
import { map } from 'rxjs/operators';
import { SeoService } from 'src/app/services/seo.service';
import { StateService } from 'src/app/services/state.service';
import { formatNumber } from '@angular/common';
import { WebsocketService } from 'src/app/services/websocket.service';
import { Observable } from 'rxjs';
@Component({
@ -22,7 +20,6 @@ export class MiningDashboardComponent implements OnInit {
constructor(private seoService: SeoService,
public stateService: StateService,
private websocketService: WebsocketService,
@Inject(LOCALE_ID) private locale: string,
) {
this.seoService.setTitle($localize`:@@mining.mining-dashboard:Mining Dashboard`);
@ -39,8 +36,8 @@ export class MiningDashboardComponent implements OnInit {
return {
'totalReward': totalReward,
'rewardPerTx': formatNumber(totalReward / totalTx, this.locale, '1.0-0'),
'feePerTx': formatNumber(totalFee / totalTx, this.locale, '1.0-0'),
'rewardPerTx': totalReward / totalTx,
'feePerTx': totalFee / totalTx,
}
})
);

View File

@ -1,23 +1,25 @@
<div [class]="widget === false ? 'container-xl' : ''">
<div class="pool-distribution" *ngIf="widget && (miningStatsObservable$ | async) as miningStats">
<div class="item">
<h5 class="card-title" i18n="mining.miners-luck">Pools luck (1w)</h5>
<p class="card-text">
{{ miningStats['minersLuck'] }}%
</p>
</div>
<div class="item">
<h5 class="card-title" i18n="master-page.blocks">Blocks (1w)</h5>
<p class="card-text">
{{ miningStats.blockCount }}
</p>
</div>
<div class="item">
<h5 class="card-title" i18n="mining.miners-count">Pools count (1w)</h5>
<p class="card-text">
{{ miningStats.pools.length }}
</p>
<div *ngIf="widget">
<div class="pool-distribution" *ngIf="(miningStatsObservable$ | async) as miningStats; else loadingReward">
<div class="item">
<h5 class="card-title" i18n="mining.miners-luck">Pools luck (1w)</h5>
<p class="card-text">
{{ miningStats['minersLuck'] }}%
</p>
</div>
<div class="item">
<h5 class="card-title" i18n="master-page.blocks">Blocks (1w)</h5>
<p class="card-text">
{{ miningStats.blockCount }}
</p>
</div>
<div class="item">
<h5 class="card-title" i18n="mining.miners-count">Pools count (1w)</h5>
<p class="card-text">
{{ miningStats.pools.length }}
</p>
</div>
</div>
</div>
@ -96,3 +98,27 @@
</table>
</div>
<ng-template #loadingReward>
<div class="pool-distribution">
<div class="item">
<h5 class="card-title" i18n="mining.miners-luck">Pools luck (1w)</h5>
<p class="card-text">
<span class="skeleton-loader skeleton-loader-big"></span>
</p>
</div>
<div class="item">
<h5 class="card-title" i18n="master-page.blocks">Blocks (1w)</h5>
<p class="card-text">
<span class="skeleton-loader skeleton-loader-big"></span>
</p>
</div>
<div class="item">
<h5 class="card-title" i18n="mining.miners-count">Pools count (1w)</h5>
<p class="card-text">
<span class="skeleton-loader skeleton-loader-big"></span>
</p>
</div>
</div>
</ng-template>

View File

@ -102,4 +102,11 @@
}
}
}
}
}
.skeleton-loader {
width: 100%;
display: block;
max-width: 80px;
margin: 15px auto 3px;
}

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, Input, NgZone, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Router } from '@angular/router';
import { EChartsOption, PieSeriesOption } from 'echarts';
@ -41,6 +41,7 @@ export class PoolRankingComponent implements OnInit {
private miningService: MiningService,
private seoService: SeoService,
private router: Router,
private zone: NgZone,
) {
}
@ -263,8 +264,8 @@ export class PoolRankingComponent implements OnInit {
fontSize: 14,
},
itemStyle: {
borderRadius: 2,
borderWidth: 2,
borderRadius: 1,
borderWidth: 1,
borderColor: '#000',
},
emphasis: {
@ -293,7 +294,9 @@ export class PoolRankingComponent implements OnInit {
if (e.data.data === 9999) { // "Other"
return;
}
this.router.navigate(['/mining/pool/', e.data.data]);
this.zone.run(() => {
this.router.navigate(['/mining/pool/', e.data.data]);
});
});
}

View File

@ -1,49 +1,107 @@
<div class="container">
<div *ngIf="poolStats$ | async as poolStats">
<h1 class="m-0">
<img width="50" src="{{ poolStats['logo'] }}" onError="this.src = './resources/mining-pools/default.svg'" class="mr-3">
<div *ngIf="poolStats$ | async as poolStats; else loadingMain">
<h1 class="m-0 mb-2">
<img width="50" height="50" src="{{ poolStats['logo'] }}"
onError="this.src = './resources/mining-pools/default.svg'" class="mr-3">
{{ poolStats.pool.name }}
</h1>
<div class="box pl-0 bg-transparent">
<div class="card-header mb-0 mb-lg-4 pr-0 pl-0">
<form [formGroup]="radioGroupForm" class="formRadioGroup ml-0">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'24h'"> 24h
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'3d'"> 3D
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'1w'"> 1W
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'1m'"> 1M
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'3m'"> 3M
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'6m'"> 6M
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'1y'"> 1Y
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'2y'"> 2Y
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'3y'"> 3Y
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'all'"> ALL
</label>
</div>
</form>
<div class="box">
<div class="row">
<div class="col-lg-7">
<table class="table table-borderless table-striped" style="table-layout: fixed;">
<tbody>
<tr>
<td class="label">Tags</td>
<td class="text-truncate">
<div class="scrollable">
{{ poolStats.pool.regexes }}
</div>
</td>
</tr>
<tr>
<td class="label">Addresses</td>
<td class="text-truncate" *ngIf="poolStats.pool.addresses.length else noaddress">
<div class="scrollable">
<a *ngFor="let address of poolStats.pool.addresses"
[routerLink]="['/address' | relativeUrl, address]">{{
address }}<br></a>
</div>
</td>
<ng-template #noaddress>
<td>~</td>
</ng-template>
</tr>
</tbody>
</table>
</div>
<div class="col-lg-5">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="label">Mined Blocks</td>
<td class="data">{{ formatNumber(poolStats.blockCount, this.locale, '1.0-0') }}</td>
</tr>
<tr>
<td class="label">Empty Blocks</td>
<td class="data">{{ formatNumber(poolStats.emptyBlocks, this.locale, '1.0-0') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions"></div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>
<table *ngIf="blocks$ | async as blocks" class="table table-borderless" [alwaysCallback]="true" infiniteScroll
[infiniteScrollDistance]="1.5" [infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50"
(scrolled)="loadMore()">
<thead>
<th style="width: 15%;" i18n="latest-blocks.height">Height</th>
<th class="d-none d-md-block" style="width: 20%;" i18n="latest-blocks.timestamp">Timestamp</th>
<th style="width: 20%;" i18n="latest-blocks.mined">Mined</th>
<th class="text-right" style="width: 10%; padding-right: 30px" i18n="latest-blocks.reward">Reward</th>
<th class="d-none d-lg-block text-right" style="width: 15%; padding-right: 40px"
i18n="latest-blocks.transactions">
Transactions</th>
<th style="width: 20%;" i18n="latest-blocks.size">Size</th>
</thead>
<tbody>
<tr *ngFor="let block of blocks">
<td><a [routerLink]="['/block' | relativeUrl, block.id]">{{ block.height }}</a></td>
<td class="d-none d-md-block">&lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}</td>
<td>
<app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since>
</td>
<td class="text-right" style="padding-right: 30px">
<app-amount [satoshis]="block['reward']" digitsInfo="1.2-2" [noFiat]="true"></app-amount>
</td>
<td class="d-none d-lg-block text-right" style="padding-right: 40px">{{ block.tx_count | number }}</td>
<td>
<div class="progress">
<div class="progress-bar progress-mempool" role="progressbar"
[ngStyle]="{'width': (block.weight / stateService.env.BLOCK_WEIGHT_UNITS)*100 + '%' }"></div>
<div class="progress-text" [innerHTML]="block.size | bytes: 2"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<ng-template #loadingMain>
<div>
<h1 class="m-0 mb-2">
<img width="50" height="50" src="./resources/mining-pools/default.svg" class="mr-3">
<div class="skeleton-loader"></div>
</h1>
<div class="box">
<div class="row">
@ -52,16 +110,20 @@
<tbody>
<tr>
<td class="col-4 col-lg-3">Addresses</td>
<td class="text-truncate" *ngIf="poolStats.pool.addresses.length else noaddress">
<td class="text-truncate">
<div class="scrollable">
<a *ngFor="let address of poolStats.pool.addresses" [routerLink]="['/address' | relativeUrl, address]">{{ address }}<br></a>
<div class="skeleton-loader"></div>
</div>
</td>
<ng-template #noaddress><td>~</td></ng-template>
<ng-template #noaddress>
<td>~</td>
</ng-template>
</tr>
<tr>
<td class="col-4 col-lg-3">Coinbase Tags</td>
<td class="text-truncate">{{ poolStats.pool.regexes }}</td>
<td class="text-truncate">
<div class="skeleton-loader"></div>
</td>
</tr>
</tbody>
</table>
@ -71,11 +133,15 @@
<tbody>
<tr>
<td class="col-4 col-lg-8">Mined Blocks</td>
<td class="text-left">{{ poolStats.blockCount }}</td>
<td class="text-left">
<div class="skeleton-loader"></div>
</td>
</tr>
<tr>
<td class="col-4 col-lg-8">Empty Blocks</td>
<td class="text-left">{{ poolStats.emptyBlocks.length }}</td>
<td class="text-left">
<div class="skeleton-loader"></div>
</td>
</tr>
</tbody>
</table>
@ -83,31 +149,4 @@
</div>
</div>
</div>
<table class="table table-borderless" [alwaysCallback]="true" infiniteScroll [infiniteScrollDistance]="1.5" [infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50" (scrolled)="loadMore()">
<thead>
<th style="width: 15%;" i18n="latest-blocks.height">Height</th>
<th class="d-none d-md-block" style="width: 20%;" i18n="latest-blocks.timestamp">Timestamp</th>
<th style="width: 20%;" i18n="latest-blocks.mined">Mined</th>
<th style="width: 10%;" i18n="latest-blocks.reward">Reward</th>
<th class="d-none d-lg-block" style="width: 15%;" i18n="latest-blocks.transactions">Transactions</th>
<th style="width: 20%;" i18n="latest-blocks.size">Size</th>
</thead>
<tbody *ngIf="blocks$ | async as blocks">
<tr *ngFor="let block of blocks">
<td><a [routerLink]="['/block' | relativeUrl, block.id]">{{ block.height }}</a></td>
<td class="d-none d-md-block">&lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}</td>
<td><app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since></td>
<td class=""><app-amount [satoshis]="block['reward']" digitsInfo="1.2-2" [noFiat]="true"></app-amount></td>
<td class="d-none d-lg-block">{{ block.tx_count | number }}</td>
<td>
<div class="progress">
<div class="progress-bar progress-mempool" role="progressbar" [ngStyle]="{'width': (block.weight / stateService.env.BLOCK_WEIGHT_UNITS)*100 + '%' }"></div>
<div class="progress-text" [innerHTML]="block.size | bytes: 2"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ng-template>

View File

@ -18,9 +18,8 @@
display: flex;
flex-direction: column;
@media (min-width: 830px) {
margin-left: 2%;
flex-direction: row;
float: left;
float: right;
margin-top: 0px;
}
.btn-sm {
@ -37,5 +36,31 @@ div.scrollable {
margin: 0;
padding: 0;
overflow: auto;
max-height: 100px;
max-height: 75px;
}
.skeleton-loader {
width: 100%;
max-width: 90px;
}
.table {
margin: 0px auto;
max-width: 900px;
}
.box {
padding-bottom: 0px;
}
.label {
max-width: 50px;
width: 30%;
}
.data {
text-align: center;
@media (max-width: 767.98px) {
text-align: right;
}
}

View File

@ -1,51 +1,78 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { distinctUntilChanged, map, startWith, switchMap, tap } from 'rxjs/operators';
import { EChartsOption, graphic } from 'echarts';
import { BehaviorSubject, Observable } from 'rxjs';
import { distinctUntilChanged, map, switchMap, tap, toArray } from 'rxjs/operators';
import { BlockExtended, PoolStat } from 'src/app/interfaces/node-api.interface';
import { ApiService } from 'src/app/services/api.service';
import { StateService } from 'src/app/services/state.service';
import { selectPowerOfTen } from 'src/app/bitcoin.utils';
import { formatNumber } from '@angular/common';
@Component({
selector: 'app-pool',
templateUrl: './pool.component.html',
styleUrls: ['./pool.component.scss'],
styles: [`
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PoolComponent implements OnInit {
@Input() right: number | string = 45;
@Input() left: number | string = 75;
formatNumber = formatNumber;
poolStats$: Observable<PoolStat>;
blocks$: Observable<BlockExtended[]>;
isLoading = true;
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'svg',
width: 'auto',
height: 'auto',
};
fromHeight: number = -1;
fromHeightSubject: BehaviorSubject<number> = new BehaviorSubject(this.fromHeight);
blocks: BlockExtended[] = [];
poolId: number = undefined;
radioGroupForm: FormGroup;
constructor(
@Inject(LOCALE_ID) public locale: string,
private apiService: ApiService,
private route: ActivatedRoute,
public stateService: StateService,
private formBuilder: FormBuilder,
) {
this.radioGroupForm = this.formBuilder.group({ dateSpan: '1w' });
this.radioGroupForm.controls.dateSpan.setValue('1w');
}
ngOnInit(): void {
this.poolStats$ = combineLatest([
this.route.params.pipe(map((params) => params.poolId)),
this.radioGroupForm.get('dateSpan').valueChanges.pipe(startWith('1w')),
])
this.poolStats$ = this.route.params.pipe(map((params) => params.poolId))
.pipe(
switchMap((params: any) => {
this.poolId = params[0];
switchMap((poolId: any) => {
this.isLoading = true;
this.poolId = poolId;
return this.apiService.getPoolHashrate$(this.poolId)
.pipe(
switchMap((data) => {
this.isLoading = false;
this.prepareChartOptions(data.hashrates.map(val => [val.timestamp * 1000, val.avgHashrate]));
return poolId;
}),
)
}),
switchMap(() => {
if (this.blocks.length === 0) {
this.fromHeightSubject.next(undefined);
}
return this.apiService.getPoolStats$(this.poolId, params[1] ?? '1w');
return this.apiService.getPoolStats$(this.poolId);
}),
map((poolStats) => {
let regexes = '"';
@ -74,6 +101,96 @@ export class PoolComponent implements OnInit {
)
}
prepareChartOptions(data) {
this.chartOptions = {
animation: false,
color: [
new graphic.LinearGradient(0, 0, 0, 0.65, [
{ offset: 0, color: '#F4511E' },
{ offset: 0.25, color: '#FB8C00' },
{ offset: 0.5, color: '#FFB300' },
{ offset: 0.75, color: '#FDD835' },
{ offset: 1, color: '#7CB342' }
]),
'#D81B60',
],
grid: {
right: this.right,
left: this.left,
bottom: 60,
},
tooltip: {
show: !this.isMobile(),
trigger: 'axis',
axisPointer: {
type: 'line'
},
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
textStyle: {
color: '#b1b1b1',
align: 'left',
},
borderColor: '#000',
formatter: function (data) {
let hashratePowerOfTen: any = selectPowerOfTen(1);
let hashrate = data[0].data[1];
if (this.isMobile()) {
hashratePowerOfTen = selectPowerOfTen(data[0].data[1]);
hashrate = Math.round(data[0].data[1] / hashratePowerOfTen.divider);
}
return `
<b style="color: white; margin-left: 18px">${data[0].axisValueLabel}</b><br>
<span>${data[0].marker} ${data[0].seriesName}: ${formatNumber(hashrate, this.locale, '1.0-0')} ${hashratePowerOfTen.unit}H/s</span><br>
`;
}.bind(this)
},
xAxis: {
type: 'time',
splitNumber: (this.isMobile()) ? 5 : 10,
},
yAxis: [
{
min: function (value) {
return value.min * 0.9;
},
type: 'value',
name: 'Hashrate',
axisLabel: {
color: 'rgb(110, 112, 121)',
formatter: (val) => {
const selectedPowerOfTen: any = selectPowerOfTen(val);
const newVal = Math.round(val / selectedPowerOfTen.divider);
return `${newVal} ${selectedPowerOfTen.unit}H/s`
}
},
splitLine: {
show: false,
}
},
],
series: [
{
name: 'Hashrate',
showSymbol: false,
symbol: 'none',
data: data,
type: 'line',
lineStyle: {
width: 2,
},
},
],
};
}
isMobile() {
return (window.innerWidth <= 767.98);
}
loadMore() {
this.fromHeightSubject.next(this.blocks[this.blocks.length - 1]?.height);
}

View File

@ -3,13 +3,13 @@
<div class="title-block">
<div *ngIf="rbfTransaction" class="alert alert-mempool" role="alert">
<span i18n="transaction.rbf.replacement|RBF replacement">This transaction has been replaced by:</span>
<a class="alert-link" [routerLink]="['/tx/' | relativeUrl, rbfTransaction.txid]" [state]="{ data: rbfTransaction }">
<a class="alert-link" [routerLink]="['/tx/' | relativeUrl, rbfTransaction.txid]" [state]="{ data: rbfTransaction.size ? rbfTransaction : null }">
<span class="d-inline d-lg-none">{{ rbfTransaction.txid | shortenString : 24 }}</span>
<span class="d-none d-lg-inline">{{ rbfTransaction.txid }}</span>
</a>
</div>
<ng-container>
<ng-container *ngIf="!rbfTransaction || rbfTransaction?.size">
<h1 i18n="shared.transaction">Transaction</h1>
<span class="tx-link float-left">

View File

@ -37,6 +37,8 @@ export class TransactionComponent implements OnInit, OnDestroy {
transactionTime = -1;
subscription: Subscription;
fetchCpfpSubscription: Subscription;
txReplacedSubscription: Subscription;
blocksSubscription: Subscription;
rbfTransaction: undefined | Transaction;
cpfpInfo: CpfpInfo | null;
showCpfpDetails = false;
@ -217,7 +219,7 @@ export class TransactionComponent implements OnInit, OnDestroy {
}
);
this.stateService.blocks$.subscribe(([block, txConfirmed]) => {
this.blocksSubscription = this.stateService.blocks$.subscribe(([block, txConfirmed]) => {
this.latestBlock = block;
if (txConfirmed && this.tx) {
@ -232,9 +234,13 @@ export class TransactionComponent implements OnInit, OnDestroy {
}
});
this.stateService.txReplaced$.subscribe(
(rbfTransaction) => (this.rbfTransaction = rbfTransaction)
);
this.txReplacedSubscription = this.stateService.txReplaced$.subscribe((rbfTransaction) => {
if (!this.tx) {
this.error = new Error();
this.waitingForTransaction = false;
}
this.rbfTransaction = rbfTransaction;
});
}
handleLoadElectrsTransactionError(error: any): Observable<any> {
@ -302,6 +308,8 @@ export class TransactionComponent implements OnInit, OnDestroy {
ngOnDestroy() {
this.subscription.unsubscribe();
this.fetchCpfpSubscription.unsubscribe();
this.txReplacedSubscription.unsubscribe();
this.blocksSubscription.unsubscribe();
this.leaveTransaction();
}
}

View File

@ -186,16 +186,16 @@
<app-amount [satoshis]="vout.value"></app-amount>
</ng-template>
</td>
<td class="arrow-td" *ngIf="{ value: (outspends$ | async) } as outspends">
<span *ngIf="!outspends.value || !outspends.value[i] || vout.scriptpubkey_type === 'op_return' || vout.scriptpubkey_type === 'fee' ; else outspend" class="grey">
<td class="arrow-td">
<span *ngIf="!outspends[i] || vout.scriptpubkey_type === 'op_return' || vout.scriptpubkey_type === 'fee' ; else outspend" class="grey">
<fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon>
</span>
<ng-template #outspend>
<span *ngIf="!outspends.value[i][vindex] || !outspends.value[i][vindex].spent; else spent" class="green">
<span *ngIf="!outspends[i][vindex] || !outspends[i][vindex].spent; else spent" class="green">
<fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon>
</span>
<ng-template #spent>
<a *ngIf="outspends.value[i][vindex].txid else outputNoTxId" [routerLink]="['/tx/' | relativeUrl, outspends.value[i][vindex].txid]" class="red">
<a *ngIf="outspends[i][vindex].txid else outputNoTxId" [routerLink]="['/tx/' | relativeUrl, outspends[i][vindex].txid]" class="red">
<fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon>
</a>
<ng-template #outputNoTxId>

View File

@ -1,11 +1,11 @@
import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter } from '@angular/core';
import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
import { StateService } from '../../services/state.service';
import { Observable, forkJoin, ReplaySubject, BehaviorSubject, merge } from 'rxjs';
import { Observable, forkJoin, ReplaySubject, BehaviorSubject, merge, of, Subject, Subscription } from 'rxjs';
import { Outspend, Transaction } from '../../interfaces/electrs.interface';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { environment } from 'src/environments/environment';
import { AssetsService } from 'src/app/services/assets.service';
import { map, share, switchMap } from 'rxjs/operators';
import { map, share, switchMap, tap } from 'rxjs/operators';
import { BlockExtended } from 'src/app/interfaces/node-api.interface';
@Component({
@ -27,41 +27,18 @@ export class TransactionsListComponent implements OnInit, OnChanges {
@Output() loadMore = new EventEmitter();
latestBlock$: Observable<BlockExtended>;
outspends$: Observable<Outspend[]>;
outspendsSubscription: Subscription;
refreshOutspends$: ReplaySubject<object> = new ReplaySubject();
showDetails$ = new BehaviorSubject<boolean>(false);
_outspends: Outspend[] = [];
outspends: Outspend[][] = [];
assetsMinimal: any;
constructor(
public stateService: StateService,
private electrsApiService: ElectrsApiService,
private assetsService: AssetsService,
) {
this.outspends$ = merge(
this.refreshOutspends$,
this.stateService.utxoSpent$
.pipe(
map(() => {
this._outspends = [];
return { 0: this.electrsApiService.getOutspends$(this.transactions[0].txid) };
}),
)
).pipe(
switchMap((observableObject) => forkJoin(observableObject)),
map((outspends: any) => {
const newOutspends = [];
for (const i in outspends) {
if (outspends.hasOwnProperty(i)) {
newOutspends.push(outspends[i]);
}
}
this._outspends = this._outspends.concat(newOutspends);
return this._outspends;
}),
share(),
);
}
private ref: ChangeDetectorRef,
) { }
ngOnInit() {
this.latestBlock$ = this.stateService.blocks$.pipe(map(([block]) => block));
@ -72,6 +49,34 @@ export class TransactionsListComponent implements OnInit, OnChanges {
this.assetsMinimal = assets;
});
}
this.outspendsSubscription = merge(
this.refreshOutspends$
.pipe(
switchMap((observableObject) => forkJoin(observableObject)),
map((outspends: any) => {
const newOutspends: Outspend[] = [];
for (const i in outspends) {
if (outspends.hasOwnProperty(i)) {
newOutspends.push(outspends[i]);
}
}
this.outspends = this.outspends.concat(newOutspends);
}),
),
this.stateService.utxoSpent$
.pipe(
map((utxoSpent) => {
for (const i in utxoSpent) {
this.outspends[0][i] = {
spent: true,
txid: utxoSpent[i].txid,
vin: utxoSpent[i].vin,
};
}
}),
)
).subscribe(() => this.ref.markForCheck());
}
ngOnChanges() {
@ -90,7 +95,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
this.transactions.forEach((tx, i) => {
tx['@voutLimit'] = true;
tx['@vinLimit'] = true;
if (this._outspends[i]) {
if (this.outspends[i]) {
return;
}
observableObject[i] = this.electrsApiService.getOutspends$(tx.txid);
@ -149,4 +154,8 @@ export class TransactionsListComponent implements OnInit, OnChanges {
this.showDetails$.next(true);
}
}
ngOnDestroy() {
this.outspendsSubscription.unsubscribe();
}
}

View File

@ -144,6 +144,9 @@ export class DashboardComponent implements OnInit {
this.latestBlockHeight = block.height;
}),
scan((acc, [block]) => {
if (acc.find((b) => b.height == block.height)) {
return acc;
}
acc.unshift(block);
acc = acc.slice(0, 6);
return acc;
@ -153,6 +156,9 @@ export class DashboardComponent implements OnInit {
this.transactions$ = this.stateService.transactions$
.pipe(
scan((acc, tx) => {
if (acc.find((t) => t.txid == tx.txid)) {
return acc;
}
acc.unshift(tx);
acc = acc.slice(0, 6);
return acc;

View File

@ -90,7 +90,7 @@ export interface PoolInfo {
export interface PoolStat {
pool: PoolInfo;
blockCount: number;
emptyBlocks: BlockExtended[];
emptyBlocks: number;
}
export interface BlockExtension {

View File

@ -15,8 +15,9 @@ export interface WebsocketResponse {
action?: string;
data?: string[];
tx?: Transaction;
rbfTransaction?: Transaction;
utxoSpent?: boolean;
rbfTransaction?: ReplacedTransaction;
txReplaced?: ReplacedTransaction;
utxoSpent?: object;
transactions?: TransactionStripped[];
loadingIndicators?: ILoadingIndicators;
backendInfo?: IBackendInfo;
@ -27,6 +28,9 @@ export interface WebsocketResponse {
'track-bisq-market'?: string;
}
export interface ReplacedTransaction extends Transaction {
txid: string;
}
export interface MempoolBlock {
blink?: boolean;
height?: number;

View File

@ -136,11 +136,12 @@ export class ApiService {
);
}
getPoolStats$(poolId: number, interval: string | undefined): Observable<PoolStat> {
return this.httpClient.get<PoolStat>(
this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}` +
(interval !== undefined ? `/${interval}` : '')
);
getPoolStats$(poolId: number): Observable<PoolStat> {
return this.httpClient.get<PoolStat>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}`);
}
getPoolHashrate$(poolId: number): Observable<any> {
return this.httpClient.get<any>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}/hashrate`);
}
getPoolBlocks$(poolId: number, fromHeight: number): Observable<BlockExtended[]> {

View File

@ -1,7 +1,7 @@
import { Inject, Injectable, PLATFORM_ID } from '@angular/core';
import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs';
import { Transaction } from '../interfaces/electrs.interface';
import { IBackendInfo, MempoolBlock, MempoolInfo, TransactionStripped } from '../interfaces/websocket.interface';
import { IBackendInfo, MempoolBlock, MempoolInfo, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface';
import { BlockExtended, OptimizedMempoolStats } from '../interfaces/node-api.interface';
import { Router, NavigationStart } from '@angular/router';
import { isPlatformBrowser } from '@angular/common';
@ -71,7 +71,7 @@ export class StateService {
network = '';
blockVSize: number;
env: Env;
latestBlockHeight = 0;
latestBlockHeight = -1;
networkChanged$ = new ReplaySubject<string>(1);
blocks$: ReplaySubject<[BlockExtended, boolean]>;
@ -80,8 +80,8 @@ export class StateService {
bsqPrice$ = new ReplaySubject<number>(1);
mempoolInfo$ = new ReplaySubject<MempoolInfo>(1);
mempoolBlocks$ = new ReplaySubject<MempoolBlock[]>(1);
txReplaced$ = new Subject<Transaction>();
utxoSpent$ = new Subject<null>();
txReplaced$ = new Subject<ReplacedTransaction>();
utxoSpent$ = new Subject<object>();
mempoolTransactions$ = new Subject<Transaction>();
blockTransactions$ = new Subject<Transaction>();
isLoadingWebSocket$ = new ReplaySubject<boolean>(1);

View File

@ -68,7 +68,7 @@ export class WebsocketService {
clearTimeout(this.onlineCheckTimeout);
clearTimeout(this.onlineCheckTimeoutTwo);
this.stateService.latestBlockHeight = 0;
this.stateService.latestBlockHeight = -1;
this.websocketSubject.complete();
this.subscription.unsubscribe();
@ -239,6 +239,10 @@ export class WebsocketService {
this.stateService.txReplaced$.next(response.rbfTransaction);
}
if (response.txReplaced) {
this.stateService.txReplaced$.next(response.txReplaced);
}
if (response['mempool-blocks']) {
this.stateService.mempoolBlocks$.next(response['mempool-blocks']);
}
@ -252,7 +256,7 @@ export class WebsocketService {
}
if (response.utxoSpent) {
this.stateService.utxoSpent$.next();
this.stateService.utxoSpent$.next(response.utxoSpent);
}
if (response.backendInfo) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -50,6 +50,7 @@ $dropdown-link-active-bg: #11131f;
html, body {
height: 100%;
overflow-y: scroll;
}
body {

View File

@ -38,5 +38,13 @@ do for url in / \
curl -s "https://${hostname}${url}" >/dev/null
done
counter=1
while [ $counter -le 134 ]
do
curl -s "https://${hostname}/api/v1/mining/pool/${counter}/hashrate" >/dev/null
curl -s "https://${hostname}/api/v1/mining/pool/${counter}" >/dev/null
((counter++))
done
sleep 10
done