mirror of
https://github.com/mempool/mempool.git
synced 2024-12-28 01:04:28 +01:00
Merge branch 'master' into fee-visibility
for cla bot
This commit is contained in:
commit
9fc4297e86
49
CONTRIBUTING.md
Normal file
49
CONTRIBUTING.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Contributing to The Mempool Open Source Project
|
||||
|
||||
Thank you for contributing to The Mempool Open Source Project managed by Mempool Space K.K. (“Mempool”).
|
||||
|
||||
In order to clarify the intellectual property license granted with Contributions from any person or entity, Mempool must have a statement on file from each Contributor indicating their agreement to the Contributor License Agreement (“Agreement”). This license is for your protection as a Contributor as well as the protection of Mempool and its other contributors and users; it does not change your rights to use your own Contributions for any other purpose.
|
||||
|
||||
When submitting a pull request for the first time, please create a file with a name like `/contributors/{github_username}.txt`, and in the content of that file indicate your agreement to the Contributor License Agreement terms below. An example of what that file should contain can be seen in wiz's agreement file. (This method of CLA "signing" is borrowed from Medium's open source project.)
|
||||
|
||||
# Contributor License Agreement
|
||||
|
||||
Last Updated: January 25, 2022
|
||||
|
||||
By accepting this Agreement, You agree to the following terms and conditions for Your present and future Contributions submitted to Mempool. Except for the license granted herein to Mempool and recipients of software distributed by Mempool, You reserve all right, title, and interest in and to Your Contributions.
|
||||
|
||||
### 1. Definitions
|
||||
|
||||
“You” (or “Your”) shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Mempool. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, “control” means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
“Contribution” shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to Mempool for inclusion in, or documentation of, any of the products owned or managed by Mempool (“Work”). For the purposes of this definition, “submitted” means any form of electronic, verbal, or written communication sent to Mempool or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Mempool for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as “Not a Contribution.”
|
||||
|
||||
### 2. Grant of Copyright License
|
||||
|
||||
Subject to the terms and conditions of this Agreement, You hereby grant to Mempool and to recipients of software distributed by Mempool a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.
|
||||
|
||||
### 3. Grant of Patent License
|
||||
|
||||
Subject to the terms and conditions of this Agreement, You hereby grant to Mempool and to recipients of software distributed by Mempool a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
### 4. Authority
|
||||
|
||||
You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to Mempool, or that your employer has executed a separate Corporate Contributor License Agreement with Mempool.
|
||||
|
||||
### 5. Originality
|
||||
|
||||
You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware, and which are associated with any part of Your Contributions.
|
||||
|
||||
### 6. Support
|
||||
|
||||
You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON- INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
### 7. Third Party Contributions
|
||||
|
||||
Should You wish to submit work that is not Your original creation, You may submit it to Mempool separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as “Submitted on behalf of a third-party: [named here]”.
|
||||
|
||||
### 8. Notifications
|
||||
|
||||
You agree to notify Mempool of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
|
||||
|
||||
EOF
|
2
LICENSE
2
LICENSE
@ -1,5 +1,5 @@
|
||||
The Mempool Open Source Project
|
||||
Copyright (c) 2019-2021 The Mempool Open Source Project Developers
|
||||
Copyright (c) 2019-2022 The Mempool Open Source Project Developers
|
||||
|
||||
This program is free software; you can redistribute it and/or modify it under
|
||||
the terms of (at your option) either:
|
||||
|
22
backend/README.md
Normal file
22
backend/README.md
Normal file
@ -0,0 +1,22 @@
|
||||
# Setup backend watchers
|
||||
|
||||
The backend is static. Typescript scripts are compiled into the `dist` folder and served through a node web server.
|
||||
|
||||
You can avoid the manual shutdown/recompile/restart command line cycle by using a watcher.
|
||||
|
||||
Make sure you are in the `backend` directory `cd backend`.
|
||||
|
||||
1. Install nodemon and ts-node
|
||||
|
||||
```
|
||||
sudo npm install -g ts-node nodemon
|
||||
```
|
||||
|
||||
2. Run the watcher
|
||||
|
||||
> Note: You can find your npm global binary folder using `npm -g bin`, where nodemon will be installed.
|
||||
|
||||
```
|
||||
nodemon src/index.ts --ignore cache/ --ignore pools.json
|
||||
```
|
||||
|
@ -12,9 +12,12 @@
|
||||
"BLOCK_WEIGHT_UNITS": 4000000,
|
||||
"INITIAL_BLOCKS_AMOUNT": 8,
|
||||
"MEMPOOL_BLOCKS_AMOUNT": 8,
|
||||
"INDEXING_BLOCKS_AMOUNT": 1100,
|
||||
"PRICE_FEED_UPDATE_INTERVAL": 3600,
|
||||
"USE_SECOND_NODE_FOR_MINFEE": false,
|
||||
"EXTERNAL_ASSETS": []
|
||||
"EXTERNAL_ASSETS": [
|
||||
"https://mempool.space/resources/pools.json"
|
||||
]
|
||||
},
|
||||
"CORE_RPC": {
|
||||
"HOST": "127.0.0.1",
|
||||
|
@ -107,14 +107,25 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
const outSpends: IEsploraApi.Outspend[] = [];
|
||||
const tx = await this.$getRawTransaction(txId, true, false);
|
||||
for (let i = 0; i < tx.vout.length; i++) {
|
||||
const txOut = await this.bitcoindClient.getTxOut(txId, i);
|
||||
outSpends.push({
|
||||
spent: txOut === null,
|
||||
});
|
||||
if (tx.status && tx.status.block_height == 0) {
|
||||
outSpends.push({
|
||||
spent: false
|
||||
});
|
||||
} else {
|
||||
const txOut = await this.bitcoindClient.getTxOut(txId, i);
|
||||
outSpends.push({
|
||||
spent: txOut === null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return outSpends;
|
||||
}
|
||||
|
||||
$getEstimatedHashrate(blockHeight: number): Promise<number> {
|
||||
// 120 is the default block span in Core
|
||||
return this.bitcoindClient.getNetworkHashPs(120, blockHeight);
|
||||
}
|
||||
|
||||
protected async $convertTransaction(transaction: IBitcoinApi.Transaction, addPrevout: boolean): Promise<IEsploraApi.Transaction> {
|
||||
let esploraTransaction: IEsploraApi.Transaction = {
|
||||
txid: transaction.txid,
|
||||
|
@ -2,11 +2,14 @@ import config from '../config';
|
||||
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||
import logger from '../logger';
|
||||
import memPool from './mempool';
|
||||
import { BlockExtended, TransactionExtended } from '../mempool.interfaces';
|
||||
import { BlockExtended, PoolTag, TransactionExtended, TransactionMinerInfo } from '../mempool.interfaces';
|
||||
import { Common } from './common';
|
||||
import diskCache from './disk-cache';
|
||||
import transactionUtils from './transaction-utils';
|
||||
import bitcoinClient from './bitcoin/bitcoin-client';
|
||||
import { IEsploraApi } from './bitcoin/esplora-api.interface';
|
||||
import poolsRepository from '../repositories/PoolsRepository';
|
||||
import blocksRepository from '../repositories/BlocksRepository';
|
||||
|
||||
class Blocks {
|
||||
private blocks: BlockExtended[] = [];
|
||||
@ -15,6 +18,7 @@ class Blocks {
|
||||
private lastDifficultyAdjustmentTime = 0;
|
||||
private previousDifficultyRetarget = 0;
|
||||
private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = [];
|
||||
private blockIndexingStarted = false;
|
||||
|
||||
constructor() { }
|
||||
|
||||
@ -30,6 +34,186 @@ class Blocks {
|
||||
this.newBlockCallbacks.push(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of transaction for a block
|
||||
* @param blockHash
|
||||
* @param blockHeight
|
||||
* @param onlyCoinbase - Set to true if you only need the coinbase transaction
|
||||
* @returns Promise<TransactionExtended[]>
|
||||
*/
|
||||
private async $getTransactionsExtended(blockHash: string, blockHeight: number, onlyCoinbase: boolean): Promise<TransactionExtended[]> {
|
||||
const transactions: TransactionExtended[] = [];
|
||||
const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash);
|
||||
|
||||
const mempool = memPool.getMempool();
|
||||
let transactionsFound = 0;
|
||||
let transactionsFetched = 0;
|
||||
|
||||
for (let i = 0; i < txIds.length; i++) {
|
||||
if (mempool[txIds[i]]) {
|
||||
// We update blocks before the mempool (index.ts), therefore we can
|
||||
// optimize here by directly fetching txs in the "outdated" mempool
|
||||
transactions.push(mempool[txIds[i]]);
|
||||
transactionsFound++;
|
||||
} else if (config.MEMPOOL.BACKEND === 'esplora' || memPool.isInSync() || i === 0) {
|
||||
// Otherwise we fetch the tx data through backend services (esplora, electrum, core rpc...)
|
||||
if (i % (Math.round((txIds.length) / 10)) === 0 || i + 1 === txIds.length) { // Avoid log spam
|
||||
logger.debug(`Indexing tx ${i + 1} of ${txIds.length} in block #${blockHeight}`);
|
||||
}
|
||||
try {
|
||||
const tx = await transactionUtils.$getTransactionExtended(txIds[i]);
|
||||
transactions.push(tx);
|
||||
transactionsFetched++;
|
||||
} catch (e) {
|
||||
logger.debug('Error fetching block tx: ' + (e instanceof Error ? e.message : e));
|
||||
if (i === 0) {
|
||||
throw new Error('Failed to fetch Coinbase transaction: ' + txIds[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onlyCoinbase === true) {
|
||||
break; // Fetch the first transaction and exit
|
||||
}
|
||||
}
|
||||
|
||||
transactions.forEach((tx) => {
|
||||
if (!tx.cpfpChecked) {
|
||||
Common.setRelativesAndGetCpfpInfo(tx, mempool); // Child Pay For Parent
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`${transactionsFound} of ${txIds.length} found in mempool. ${transactionsFetched} fetched through backend service.`);
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a block with additional data (reward, coinbase, fees...)
|
||||
* @param block
|
||||
* @param transactions
|
||||
* @returns BlockExtended
|
||||
*/
|
||||
private getBlockExtended(block: IEsploraApi.Block, transactions: TransactionExtended[]): BlockExtended {
|
||||
const blockExtended: BlockExtended = Object.assign({}, block);
|
||||
blockExtended.reward = transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0);
|
||||
blockExtended.coinbaseTx = transactionUtils.stripCoinbaseTransaction(transactions[0]);
|
||||
|
||||
const transactionsTmp = [...transactions];
|
||||
transactionsTmp.shift();
|
||||
transactionsTmp.sort((a, b) => b.effectiveFeePerVsize - a.effectiveFeePerVsize);
|
||||
blockExtended.medianFee = transactionsTmp.length > 0 ? Common.median(transactionsTmp.map((tx) => tx.effectiveFeePerVsize)) : 0;
|
||||
blockExtended.feeRange = transactionsTmp.length > 0 ? Common.getFeesInRange(transactionsTmp, 8) : [0, 0];
|
||||
|
||||
return blockExtended;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find which miner found the block
|
||||
* @param txMinerInfo
|
||||
* @returns
|
||||
*/
|
||||
private async $findBlockMiner(txMinerInfo: TransactionMinerInfo | undefined): Promise<PoolTag> {
|
||||
if (txMinerInfo === undefined || txMinerInfo.vout.length < 1) {
|
||||
return await poolsRepository.$getUnknownPool();
|
||||
}
|
||||
|
||||
const asciiScriptSig = transactionUtils.hex2ascii(txMinerInfo.vin[0].scriptsig);
|
||||
const address = txMinerInfo.vout[0].scriptpubkey_address;
|
||||
|
||||
const pools: PoolTag[] = await poolsRepository.$getPools();
|
||||
for (let i = 0; i < pools.length; ++i) {
|
||||
if (address !== undefined) {
|
||||
const addresses: string[] = JSON.parse(pools[i].addresses);
|
||||
if (addresses.indexOf(address) !== -1) {
|
||||
return pools[i];
|
||||
}
|
||||
}
|
||||
|
||||
const regexes: string[] = JSON.parse(pools[i].regexes);
|
||||
for (let y = 0; y < regexes.length; ++y) {
|
||||
const match = asciiScriptSig.match(regexes[y]);
|
||||
if (match !== null) {
|
||||
return pools[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await poolsRepository.$getUnknownPool();
|
||||
}
|
||||
|
||||
/**
|
||||
* Index all blocks metadata for the mining dashboard
|
||||
*/
|
||||
public async $generateBlockDatabase() {
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false || // Bitcoin only
|
||||
config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === 0 || // Indexing must be enabled
|
||||
!memPool.isInSync() || // We sync the mempool first
|
||||
this.blockIndexingStarted === true // Indexing must not already be in progress
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
|
||||
if (blockchainInfo.blocks !== blockchainInfo.headers) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.blockIndexingStarted = true;
|
||||
|
||||
try {
|
||||
let currentBlockHeight = blockchainInfo.blocks;
|
||||
|
||||
let indexingBlockAmount = config.MEMPOOL.INDEXING_BLOCKS_AMOUNT;
|
||||
if (indexingBlockAmount <= -1) {
|
||||
indexingBlockAmount = currentBlockHeight + 1;
|
||||
}
|
||||
|
||||
const lastBlockToIndex = Math.max(0, currentBlockHeight - indexingBlockAmount + 1);
|
||||
|
||||
logger.info(`Indexing blocks from #${currentBlockHeight} to #${lastBlockToIndex}`);
|
||||
|
||||
const chunkSize = 10000;
|
||||
while (currentBlockHeight >= lastBlockToIndex) {
|
||||
const endBlock = Math.max(0, lastBlockToIndex, currentBlockHeight - chunkSize + 1);
|
||||
|
||||
const missingBlockHeights: number[] = await blocksRepository.$getMissingBlocksBetweenHeights(
|
||||
currentBlockHeight, endBlock);
|
||||
if (missingBlockHeights.length <= 0) {
|
||||
logger.debug(`No missing blocks between #${currentBlockHeight} to #${endBlock}`);
|
||||
currentBlockHeight -= chunkSize;
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug(`Indexing ${missingBlockHeights.length} blocks from #${currentBlockHeight} to #${endBlock}`);
|
||||
|
||||
for (const blockHeight of missingBlockHeights) {
|
||||
if (blockHeight < lastBlockToIndex) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
logger.debug(`Indexing block #${blockHeight}`);
|
||||
const blockHash = await bitcoinApi.$getBlockHash(blockHeight);
|
||||
const block = await bitcoinApi.$getBlock(blockHash);
|
||||
const transactions = await this.$getTransactionsExtended(blockHash, block.height, true);
|
||||
const blockExtended = this.getBlockExtended(block, transactions);
|
||||
const miner = await this.$findBlockMiner(blockExtended.coinbaseTx);
|
||||
const coinbase: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(transactions[0].txid, true);
|
||||
await blocksRepository.$saveBlockInDatabase(blockExtended, blockHash, coinbase.hex, miner);
|
||||
} catch (e) {
|
||||
logger.err(`Something went wrong while indexing blocks.` + e);
|
||||
}
|
||||
}
|
||||
|
||||
currentBlockHeight -= chunkSize;
|
||||
}
|
||||
logger.info('Block indexing completed');
|
||||
} catch (e) {
|
||||
logger.err('An error occured in $generateBlockDatabase(). Skipping block indexing. ' + e);
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
public async $updateBlocks() {
|
||||
const blockHeightTip = await bitcoinApi.$getBlockHeightTip();
|
||||
|
||||
@ -70,49 +254,18 @@ class Blocks {
|
||||
logger.debug(`New block found (#${this.currentBlockHeight})!`);
|
||||
}
|
||||
|
||||
const transactions: TransactionExtended[] = [];
|
||||
|
||||
const blockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight);
|
||||
const block = await bitcoinApi.$getBlock(blockHash);
|
||||
const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash);
|
||||
const transactions = await this.$getTransactionsExtended(blockHash, block.height, false);
|
||||
const blockExtended: BlockExtended = this.getBlockExtended(block, transactions);
|
||||
const coinbase: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(transactions[0].txid, true);
|
||||
|
||||
const mempool = memPool.getMempool();
|
||||
let transactionsFound = 0;
|
||||
|
||||
for (let i = 0; i < txIds.length; i++) {
|
||||
if (mempool[txIds[i]]) {
|
||||
transactions.push(mempool[txIds[i]]);
|
||||
transactionsFound++;
|
||||
} else if (config.MEMPOOL.BACKEND === 'esplora' || memPool.isInSync() || i === 0) {
|
||||
logger.debug(`Fetching block tx ${i} of ${txIds.length}`);
|
||||
try {
|
||||
const tx = await transactionUtils.$getTransactionExtended(txIds[i]);
|
||||
transactions.push(tx);
|
||||
} catch (e) {
|
||||
logger.debug('Error fetching block tx: ' + (e instanceof Error ? e.message : e));
|
||||
if (i === 0) {
|
||||
throw new Error('Failed to fetch Coinbase transaction: ' + txIds[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === true) {
|
||||
const miner = await this.$findBlockMiner(blockExtended.coinbaseTx);
|
||||
await blocksRepository.$saveBlockInDatabase(blockExtended, blockHash, coinbase.hex, miner);
|
||||
}
|
||||
|
||||
transactions.forEach((tx) => {
|
||||
if (!tx.cpfpChecked) {
|
||||
Common.setRelativesAndGetCpfpInfo(tx, mempool);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`${transactionsFound} of ${txIds.length} found in mempool. ${txIds.length - transactionsFound} not found.`);
|
||||
|
||||
const blockExtended: BlockExtended = Object.assign({}, block);
|
||||
blockExtended.reward = transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0);
|
||||
blockExtended.coinbaseTx = transactionUtils.stripCoinbaseTransaction(transactions[0]);
|
||||
transactions.shift();
|
||||
transactions.sort((a, b) => b.effectiveFeePerVsize - a.effectiveFeePerVsize);
|
||||
blockExtended.medianFee = transactions.length > 0 ? Common.median(transactions.map((tx) => tx.effectiveFeePerVsize)) : 0;
|
||||
blockExtended.feeRange = transactions.length > 0 ? Common.getFeesInRange(transactions, 8) : [0, 0];
|
||||
|
||||
if (block.height % 2016 === 0) {
|
||||
this.previousDifficultyRetarget = (block.difficulty - this.currentDifficulty) / this.currentDifficulty * 100;
|
||||
this.lastDifficultyAdjustmentTime = block.timestamp;
|
||||
@ -130,6 +283,8 @@ class Blocks {
|
||||
if (memPool.isInSync()) {
|
||||
diskCache.$saveCacheToDisk();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,10 +3,10 @@ import config from '../config';
|
||||
import { DB } from '../database';
|
||||
import logger from '../logger';
|
||||
|
||||
const sleep = (ms: number) => new Promise( res => setTimeout(res, ms));
|
||||
const sleep = (ms: number) => new Promise(res => setTimeout(res, ms));
|
||||
|
||||
class DatabaseMigration {
|
||||
private static currentVersion = 2;
|
||||
private static currentVersion = 4;
|
||||
private queryTimeout = 120000;
|
||||
private statisticsAddedIndexed = false;
|
||||
|
||||
@ -83,6 +83,13 @@ class DatabaseMigration {
|
||||
if (databaseSchemaVersion < 2 && this.statisticsAddedIndexed === false) {
|
||||
await this.$executeQuery(connection, `CREATE INDEX added ON statistics (added);`);
|
||||
}
|
||||
if (databaseSchemaVersion < 3) {
|
||||
await this.$executeQuery(connection, this.getCreatePoolsTableQuery(), await this.$checkIfTableExists('pools'));
|
||||
}
|
||||
if (databaseSchemaVersion < 4) {
|
||||
await this.$executeQuery(connection, 'DROP table IF EXISTS blocks;');
|
||||
await this.$executeQuery(connection, this.getCreateBlocksTableQuery(), await this.$checkIfTableExists('blocks'));
|
||||
}
|
||||
connection.release();
|
||||
} catch (e) {
|
||||
connection.release();
|
||||
@ -197,7 +204,6 @@ class DatabaseMigration {
|
||||
const connection = await DB.pool.getConnection();
|
||||
try {
|
||||
await this.$executeQuery(connection, 'START TRANSACTION;');
|
||||
await this.$executeQuery(connection, 'SET autocommit = 0;');
|
||||
for (const query of transactionQueries) {
|
||||
await this.$executeQuery(connection, query);
|
||||
}
|
||||
@ -335,6 +341,37 @@ class DatabaseMigration {
|
||||
final_tx int(11) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreatePoolsTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS pools (
|
||||
id int(11) NOT NULL AUTO_INCREMENT,
|
||||
name varchar(50) NOT NULL,
|
||||
link varchar(255) NOT NULL,
|
||||
addresses text NOT NULL,
|
||||
regexes text NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`;
|
||||
}
|
||||
|
||||
private getCreateBlocksTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS blocks (
|
||||
height int(11) unsigned NOT NULL,
|
||||
hash varchar(65) NOT NULL,
|
||||
blockTimestamp timestamp NOT NULL,
|
||||
size int(11) unsigned NOT NULL,
|
||||
weight int(11) unsigned NOT NULL,
|
||||
tx_count int(11) unsigned NOT NULL,
|
||||
coinbase_raw text,
|
||||
difficulty bigint(20) unsigned NOT NULL,
|
||||
pool_id int(11) DEFAULT -1,
|
||||
fees double unsigned NOT NULL,
|
||||
fee_span json NOT NULL,
|
||||
median_fee double unsigned NOT NULL,
|
||||
PRIMARY KEY (height),
|
||||
INDEX (pool_id),
|
||||
FOREIGN KEY (pool_id) REFERENCES pools (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
}
|
||||
|
||||
export default new DatabaseMigration();
|
||||
|
69
backend/src/api/mining.ts
Normal file
69
backend/src/api/mining.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { PoolInfo, PoolStats } from '../mempool.interfaces';
|
||||
import BlocksRepository, { EmptyBlocks } from '../repositories/BlocksRepository';
|
||||
import PoolsRepository from '../repositories/PoolsRepository';
|
||||
import bitcoinClient from './bitcoin/bitcoin-client';
|
||||
|
||||
class Mining {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate high level overview of the pool ranks and general stats
|
||||
*/
|
||||
public async $getPoolsStats(interval: string | null) : Promise<object> {
|
||||
let sqlInterval: string | null = null;
|
||||
switch (interval) {
|
||||
case '24h': sqlInterval = '1 DAY'; break;
|
||||
case '3d': sqlInterval = '3 DAY'; break;
|
||||
case '1w': sqlInterval = '1 WEEK'; break;
|
||||
case '1m': sqlInterval = '1 MONTH'; break;
|
||||
case '3m': sqlInterval = '3 MONTH'; break;
|
||||
case '6m': sqlInterval = '6 MONTH'; break;
|
||||
case '1y': sqlInterval = '1 YEAR'; break;
|
||||
case '2y': sqlInterval = '2 YEAR'; break;
|
||||
case '3y': sqlInterval = '3 YEAR'; break;
|
||||
default: sqlInterval = null; break;
|
||||
}
|
||||
|
||||
const poolsStatistics = {};
|
||||
|
||||
const poolsInfo: PoolInfo[] = await PoolsRepository.$getPoolsInfo(sqlInterval);
|
||||
const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$countEmptyBlocks(sqlInterval);
|
||||
|
||||
const poolsStats: PoolStats[] = [];
|
||||
let rank = 1;
|
||||
|
||||
poolsInfo.forEach((poolInfo: PoolInfo) => {
|
||||
const poolStat: PoolStats = {
|
||||
poolId: poolInfo.poolId, // mysql row id
|
||||
name: poolInfo.name,
|
||||
link: poolInfo.link,
|
||||
blockCount: poolInfo.blockCount,
|
||||
rank: rank++,
|
||||
emptyBlocks: 0,
|
||||
}
|
||||
for (let i = 0; i < emptyBlocks.length; ++i) {
|
||||
if (emptyBlocks[i].poolId === poolInfo.poolId) {
|
||||
poolStat.emptyBlocks++;
|
||||
}
|
||||
}
|
||||
poolsStats.push(poolStat);
|
||||
});
|
||||
|
||||
poolsStatistics['pools'] = poolsStats;
|
||||
|
||||
const oldestBlock = new Date(await BlocksRepository.$oldestBlockTimestamp());
|
||||
poolsStatistics['oldestIndexedBlockTimestamp'] = oldestBlock.getTime();
|
||||
|
||||
const blockCount: number = await BlocksRepository.$blockCount(sqlInterval);
|
||||
poolsStatistics['blockCount'] = blockCount;
|
||||
|
||||
const blockHeightTip = await bitcoinClient.getBlockCount();
|
||||
const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(120, blockHeightTip);
|
||||
poolsStatistics['lastEstimatedHashrate'] = lastBlockHashrate;
|
||||
|
||||
return poolsStatistics;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Mining();
|
173
backend/src/api/pools-parser.ts
Normal file
173
backend/src/api/pools-parser.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { DB } from '../database';
|
||||
import logger from '../logger';
|
||||
import config from '../config';
|
||||
|
||||
interface Pool {
|
||||
name: string;
|
||||
link: string;
|
||||
regexes: string[];
|
||||
addresses: string[];
|
||||
}
|
||||
|
||||
class PoolsParser {
|
||||
/**
|
||||
* Parse the pools.json file, consolidate the data and dump it into the database
|
||||
*/
|
||||
public async migratePoolsJson() {
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('Importing pools.json to the database, open ./pools.json');
|
||||
|
||||
let poolsJson: object = {};
|
||||
try {
|
||||
const fileContent: string = readFileSync('./pools.json', 'utf8');
|
||||
poolsJson = JSON.parse(fileContent);
|
||||
} catch (e) {
|
||||
logger.err('Unable to open ./pools.json, does the file exist?');
|
||||
await this.insertUnknownPool();
|
||||
return;
|
||||
}
|
||||
|
||||
// First we save every entries without paying attention to pool duplication
|
||||
const poolsDuplicated: Pool[] = [];
|
||||
|
||||
logger.debug('Parse coinbase_tags');
|
||||
const coinbaseTags = Object.entries(poolsJson['coinbase_tags']);
|
||||
for (let i = 0; i < coinbaseTags.length; ++i) {
|
||||
poolsDuplicated.push({
|
||||
'name': (<Pool>coinbaseTags[i][1]).name,
|
||||
'link': (<Pool>coinbaseTags[i][1]).link,
|
||||
'regexes': [coinbaseTags[i][0]],
|
||||
'addresses': [],
|
||||
});
|
||||
}
|
||||
logger.debug('Parse payout_addresses');
|
||||
const addressesTags = Object.entries(poolsJson['payout_addresses']);
|
||||
for (let i = 0; i < addressesTags.length; ++i) {
|
||||
poolsDuplicated.push({
|
||||
'name': (<Pool>addressesTags[i][1]).name,
|
||||
'link': (<Pool>addressesTags[i][1]).link,
|
||||
'regexes': [],
|
||||
'addresses': [addressesTags[i][0]],
|
||||
});
|
||||
}
|
||||
|
||||
// Then, we find unique mining pool names
|
||||
logger.debug('Identify unique mining pools');
|
||||
const poolNames: string[] = [];
|
||||
for (let i = 0; i < poolsDuplicated.length; ++i) {
|
||||
if (poolNames.indexOf(poolsDuplicated[i].name) === -1) {
|
||||
poolNames.push(poolsDuplicated[i].name);
|
||||
}
|
||||
}
|
||||
logger.debug(`Found ${poolNames.length} unique mining pools`);
|
||||
|
||||
// Get existing pools from the db
|
||||
const connection = await DB.pool.getConnection();
|
||||
let existingPools;
|
||||
try {
|
||||
[existingPools] = await connection.query<any>({ sql: 'SELECT * FROM pools;', timeout: 120000 });
|
||||
} catch (e) {
|
||||
logger.err('Unable to get existing pools from the database, skipping pools.json import');
|
||||
connection.release();
|
||||
return;
|
||||
}
|
||||
|
||||
// Finally, we generate the final consolidated pools data
|
||||
const finalPoolDataAdd: Pool[] = [];
|
||||
const finalPoolDataUpdate: Pool[] = [];
|
||||
for (let i = 0; i < poolNames.length; ++i) {
|
||||
let allAddresses: string[] = [];
|
||||
let allRegexes: string[] = [];
|
||||
const match = poolsDuplicated.filter((pool: Pool) => pool.name === poolNames[i]);
|
||||
|
||||
for (let y = 0; y < match.length; ++y) {
|
||||
allAddresses = allAddresses.concat(match[y].addresses);
|
||||
allRegexes = allRegexes.concat(match[y].regexes);
|
||||
}
|
||||
|
||||
const finalPoolName = poolNames[i].replace(`'`, `''`); // To support single quote in names when doing db queries
|
||||
|
||||
if (existingPools.find((pool) => pool.name === poolNames[i]) !== undefined) {
|
||||
logger.debug(`Update '${finalPoolName}' mining pool`);
|
||||
finalPoolDataUpdate.push({
|
||||
'name': finalPoolName,
|
||||
'link': match[0].link,
|
||||
'regexes': allRegexes,
|
||||
'addresses': allAddresses,
|
||||
});
|
||||
} else {
|
||||
logger.debug(`Add '${finalPoolName}' mining pool`);
|
||||
finalPoolDataAdd.push({
|
||||
'name': finalPoolName,
|
||||
'link': match[0].link,
|
||||
'regexes': allRegexes,
|
||||
'addresses': allAddresses,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Update pools table now`);
|
||||
|
||||
// Add new mining pools into the database
|
||||
let queryAdd: string = 'INSERT INTO pools(name, link, regexes, addresses) VALUES ';
|
||||
for (let i = 0; i < finalPoolDataAdd.length; ++i) {
|
||||
queryAdd += `('${finalPoolDataAdd[i].name}', '${finalPoolDataAdd[i].link}',
|
||||
'${JSON.stringify(finalPoolDataAdd[i].regexes)}', '${JSON.stringify(finalPoolDataAdd[i].addresses)}'),`;
|
||||
}
|
||||
queryAdd = queryAdd.slice(0, -1) + ';';
|
||||
|
||||
// Add new mining pools into the database
|
||||
const updateQueries: string[] = [];
|
||||
for (let i = 0; i < finalPoolDataUpdate.length; ++i) {
|
||||
updateQueries.push(`
|
||||
UPDATE pools
|
||||
SET name='${finalPoolDataUpdate[i].name}', link='${finalPoolDataUpdate[i].link}',
|
||||
regexes='${JSON.stringify(finalPoolDataUpdate[i].regexes)}', addresses='${JSON.stringify(finalPoolDataUpdate[i].addresses)}'
|
||||
WHERE name='${finalPoolDataUpdate[i].name}'
|
||||
;`);
|
||||
}
|
||||
|
||||
try {
|
||||
if (finalPoolDataAdd.length > 0) {
|
||||
await connection.query<any>({ sql: queryAdd, timeout: 120000 });
|
||||
}
|
||||
for (const query of updateQueries) {
|
||||
await connection.query<any>({ sql: query, timeout: 120000 });
|
||||
}
|
||||
await this.insertUnknownPool();
|
||||
connection.release();
|
||||
logger.info('Mining pools.json import completed');
|
||||
} catch (e) {
|
||||
connection.release();
|
||||
logger.err(`Unable to import pools in the database!`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually add the 'unknown pool'
|
||||
*/
|
||||
private async insertUnknownPool() {
|
||||
const connection = await DB.pool.getConnection();
|
||||
try {
|
||||
const [rows]: any[] = await connection.query({ sql: 'SELECT name from pools where name="Unknown"', timeout: 120000 });
|
||||
if (rows.length === 0) {
|
||||
logger.debug('Manually inserting "Unknown" mining pool into the databse');
|
||||
await connection.query({
|
||||
sql: `INSERT INTO pools(name, link, regexes, addresses)
|
||||
VALUES("Unknown", "https://learnmeabitcoin.com/technical/coinbase-transaction", "[]", "[]");
|
||||
`});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('Unable to insert "Unknown" mining pool');
|
||||
}
|
||||
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
export default new PoolsParser();
|
@ -44,6 +44,14 @@ class TransactionUtils {
|
||||
}
|
||||
return transactionExtended;
|
||||
}
|
||||
|
||||
public hex2ascii(hex: string) {
|
||||
let str = '';
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
str += String.fromCharCode(parseInt(hex.substr(i, 2), 16));
|
||||
}
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
export default new TransactionUtils();
|
||||
|
@ -14,6 +14,7 @@ interface IConfig {
|
||||
BLOCK_WEIGHT_UNITS: number;
|
||||
INITIAL_BLOCKS_AMOUNT: number;
|
||||
MEMPOOL_BLOCKS_AMOUNT: number;
|
||||
INDEXING_BLOCKS_AMOUNT: number;
|
||||
PRICE_FEED_UPDATE_INTERVAL: number;
|
||||
USE_SECOND_NODE_FOR_MINFEE: boolean;
|
||||
EXTERNAL_ASSETS: string[];
|
||||
@ -77,9 +78,12 @@ const defaults: IConfig = {
|
||||
'BLOCK_WEIGHT_UNITS': 4000000,
|
||||
'INITIAL_BLOCKS_AMOUNT': 8,
|
||||
'MEMPOOL_BLOCKS_AMOUNT': 8,
|
||||
'INDEXING_BLOCKS_AMOUNT': 1100, // 0 = disable indexing, -1 = index all blocks
|
||||
'PRICE_FEED_UPDATE_INTERVAL': 3600,
|
||||
'USE_SECOND_NODE_FOR_MINFEE': false,
|
||||
'EXTERNAL_ASSETS': [],
|
||||
'EXTERNAL_ASSETS': [
|
||||
'https://mempool.space/resources/pools.json'
|
||||
]
|
||||
},
|
||||
'ESPLORA': {
|
||||
'REST_API_URL': 'http://127.0.0.1:3000',
|
||||
|
@ -22,6 +22,7 @@ import loadingIndicators from './api/loading-indicators';
|
||||
import mempool from './api/mempool';
|
||||
import elementsParser from './api/liquid/elements-parser';
|
||||
import databaseMigration from './api/database-migration';
|
||||
import poolsParser from './api/pools-parser';
|
||||
import syncAssets from './sync-assets';
|
||||
import icons from './api/liquid/icons';
|
||||
import { Common } from './api/common';
|
||||
@ -88,6 +89,7 @@ class Server {
|
||||
await checkDbConnection();
|
||||
try {
|
||||
await databaseMigration.$initializeOrMigrateDatabase();
|
||||
await poolsParser.migratePoolsJson();
|
||||
} catch (e) {
|
||||
throw new Error(e instanceof Error ? e.message : 'Error');
|
||||
}
|
||||
@ -136,6 +138,8 @@ class Server {
|
||||
}
|
||||
await blocks.$updateBlocks();
|
||||
await memPool.$updateMempool();
|
||||
blocks.$generateBlockDatabase();
|
||||
|
||||
setTimeout(this.runMainUpdateLoop.bind(this), config.MEMPOOL.POLL_RATE_MS);
|
||||
this.currentBackendRetryInterval = 5;
|
||||
} catch (e) {
|
||||
@ -252,6 +256,16 @@ class Server {
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1y', routes.$getStatisticsByTime.bind(routes, '1y'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2y', routes.$getStatisticsByTime.bind(routes, '2y'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3y', routes.$getStatisticsByTime.bind(routes, '3y'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/24h', routes.$getPools.bind(routes, '24h'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/3d', routes.$getPools.bind(routes, '3d'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/1w', routes.$getPools.bind(routes, '1w'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/1m', routes.$getPools.bind(routes, '1m'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/3m', routes.$getPools.bind(routes, '3m'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/6m', routes.$getPools.bind(routes, '6m'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/1y', routes.$getPools.bind(routes, '1y'))
|
||||
.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'))
|
||||
;
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,25 @@
|
||||
import { IEsploraApi } from './api/bitcoin/esplora-api.interface';
|
||||
|
||||
export interface PoolTag {
|
||||
id: number | null, // mysql row id
|
||||
name: string,
|
||||
link: string,
|
||||
regexes: string, // JSON array
|
||||
addresses: string, // JSON array
|
||||
}
|
||||
|
||||
export interface PoolInfo {
|
||||
poolId: number, // mysql row id
|
||||
name: string,
|
||||
link: string,
|
||||
blockCount: number,
|
||||
}
|
||||
|
||||
export interface PoolStats extends PoolInfo {
|
||||
rank: number,
|
||||
emptyBlocks: number,
|
||||
}
|
||||
|
||||
export interface MempoolBlock {
|
||||
blockSize: number;
|
||||
blockVSize: number;
|
||||
|
128
backend/src/repositories/BlocksRepository.ts
Normal file
128
backend/src/repositories/BlocksRepository.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { BlockExtended, PoolTag } from '../mempool.interfaces';
|
||||
import { DB } from '../database';
|
||||
import logger from '../logger';
|
||||
|
||||
export interface EmptyBlocks {
|
||||
emptyBlocks: number;
|
||||
poolId: number;
|
||||
}
|
||||
|
||||
class BlocksRepository {
|
||||
/**
|
||||
* Save indexed block data in the database
|
||||
*/
|
||||
public async $saveBlockInDatabase(
|
||||
block: BlockExtended,
|
||||
blockHash: string,
|
||||
coinbaseHex: string | undefined,
|
||||
poolTag: PoolTag
|
||||
) {
|
||||
const connection = await DB.pool.getConnection();
|
||||
|
||||
try {
|
||||
const query = `INSERT INTO blocks(
|
||||
height, hash, blockTimestamp, size,
|
||||
weight, tx_count, coinbase_raw, difficulty,
|
||||
pool_id, fees, fee_span, median_fee
|
||||
) VALUE (
|
||||
?, ?, FROM_UNIXTIME(?), ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?
|
||||
)`;
|
||||
|
||||
const params: any[] = [
|
||||
block.height, blockHash, block.timestamp, block.size,
|
||||
block.weight, block.tx_count, coinbaseHex ? coinbaseHex : '', block.difficulty,
|
||||
poolTag.id, 0, '[]', block.medianFee,
|
||||
];
|
||||
|
||||
await connection.query(query, params);
|
||||
} catch (e) {
|
||||
logger.err('$saveBlockInDatabase() error' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
|
||||
connection.release();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all block height that have not been indexed between [startHeight, endHeight]
|
||||
*/
|
||||
public async $getMissingBlocksBetweenHeights(startHeight: number, endHeight: number): Promise<number[]> {
|
||||
if (startHeight < endHeight) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const connection = await DB.pool.getConnection();
|
||||
const [rows] : any[] = await connection.query(`
|
||||
SELECT height
|
||||
FROM blocks
|
||||
WHERE height <= ${startHeight} AND height >= ${endHeight}
|
||||
ORDER BY height DESC;
|
||||
`);
|
||||
connection.release();
|
||||
|
||||
const indexedBlockHeights: number[] = [];
|
||||
rows.forEach((row: any) => { indexedBlockHeights.push(row.height); });
|
||||
const seekedBlocks: number[] = Array.from(Array(startHeight - endHeight + 1).keys(), n => n + endHeight).reverse();
|
||||
const missingBlocksHeights = seekedBlocks.filter(x => indexedBlockHeights.indexOf(x) === -1);
|
||||
|
||||
return missingBlocksHeights;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count empty blocks for all pools
|
||||
*/
|
||||
public async $countEmptyBlocks(interval: string | null): Promise<EmptyBlocks[]> {
|
||||
const query = `
|
||||
SELECT pool_id as poolId
|
||||
FROM blocks
|
||||
WHERE tx_count = 1` +
|
||||
(interval != null ? ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()` : ``)
|
||||
;
|
||||
|
||||
const connection = await DB.pool.getConnection();
|
||||
const [rows] = await connection.query(query);
|
||||
connection.release();
|
||||
|
||||
return <EmptyBlocks[]>rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blocks count for a period
|
||||
*/
|
||||
public async $blockCount(interval: string | null): Promise<number> {
|
||||
const query = `
|
||||
SELECT count(height) as blockCount
|
||||
FROM blocks` +
|
||||
(interval != null ? ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()` : ``)
|
||||
;
|
||||
|
||||
const connection = await DB.pool.getConnection();
|
||||
const [rows] = await connection.query(query);
|
||||
connection.release();
|
||||
|
||||
return <number>rows[0].blockCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the oldest indexed block
|
||||
*/
|
||||
public async $oldestBlockTimestamp(): Promise<number> {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const [rows]: any[] = await connection.query(`
|
||||
SELECT blockTimestamp
|
||||
FROM blocks
|
||||
ORDER BY height
|
||||
LIMIT 1;
|
||||
`);
|
||||
connection.release();
|
||||
|
||||
if (rows.length <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return <number>rows[0].blockTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
export default new BlocksRepository();
|
46
backend/src/repositories/PoolsRepository.ts
Normal file
46
backend/src/repositories/PoolsRepository.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { DB } from '../database';
|
||||
import { PoolInfo, PoolTag } from '../mempool.interfaces';
|
||||
|
||||
class PoolsRepository {
|
||||
/**
|
||||
* Get all pools tagging info
|
||||
*/
|
||||
public async $getPools(): Promise<PoolTag[]> {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const [rows] = await connection.query('SELECT * FROM pools;');
|
||||
connection.release();
|
||||
return <PoolTag[]>rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unknown pool tagging info
|
||||
*/
|
||||
public async $getUnknownPool(): Promise<PoolTag> {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const [rows] = await connection.query('SELECT * FROM pools where name = "Unknown"');
|
||||
connection.release();
|
||||
return <PoolTag>rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get basic pool info and block count
|
||||
*/
|
||||
public async $getPoolsInfo(interval: string | null): Promise<PoolInfo[]> {
|
||||
const query = `
|
||||
SELECT COUNT(height) as blockCount, pool_id as poolId, pools.name as name, pools.link as link
|
||||
FROM blocks
|
||||
JOIN pools on pools.id = pool_id` +
|
||||
(interval != null ? ` WHERE blocks.blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()` : ``) +
|
||||
` GROUP BY pool_id
|
||||
ORDER BY COUNT(height) DESC
|
||||
`;
|
||||
|
||||
const connection = await DB.pool.getConnection();
|
||||
const [rows] = await connection.query(query);
|
||||
connection.release();
|
||||
|
||||
return <PoolInfo[]>rows;
|
||||
}
|
||||
}
|
||||
|
||||
export default new PoolsRepository();
|
@ -20,6 +20,7 @@ import { Common } from './api/common';
|
||||
import bitcoinClient from './api/bitcoin/bitcoin-client';
|
||||
import elementsParser from './api/liquid/elements-parser';
|
||||
import icons from './api/liquid/icons';
|
||||
import miningStats from './api/mining';
|
||||
|
||||
class Routes {
|
||||
constructor() {}
|
||||
@ -531,6 +532,18 @@ class Routes {
|
||||
}
|
||||
}
|
||||
|
||||
public async $getPools(interval: string, req: Request, res: Response) {
|
||||
try {
|
||||
let stats = await miningStats.$getPoolsStats(interval);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(stats);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
public async getBlock(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await bitcoinApi.$getBlock(req.params.hash);
|
||||
|
3
contributors/antonilol.txt
Normal file
3
contributors/antonilol.txt
Normal 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: antonilol
|
1
contributors/emzy.txt
Normal file
1
contributors/emzy.txt
Normal file
@ -0,0 +1 @@
|
||||
Mempool Space K.K. has a signed CLA or other agreement on file with @emzy as of January 25, 2022
|
1
contributors/hunicus.txt
Normal file
1
contributors/hunicus.txt
Normal file
@ -0,0 +1 @@
|
||||
Mempool Space K.K. has a signed CLA or other agreement on file with @hunicus as of January 25, 2022
|
1
contributors/knorrium.txt
Normal file
1
contributors/knorrium.txt
Normal file
@ -0,0 +1 @@
|
||||
Mempool Space K.K. has a signed CLA or other agreement on file with @knorrium as of January 25, 2022
|
1
contributors/miguelmedeiros.txt
Normal file
1
contributors/miguelmedeiros.txt
Normal file
@ -0,0 +1 @@
|
||||
Mempool Space K.K. has a signed CLA or other agreement on file with @miguelmedeiros as of January 25, 2022
|
1
contributors/nymkappa.txt
Normal file
1
contributors/nymkappa.txt
Normal file
@ -0,0 +1 @@
|
||||
Mempool Space K.K. has a signed CLA or other agreement on file with @nymkappa as of January 25, 2022
|
3
contributors/softsimon.txt
Normal file
3
contributors/softsimon.txt
Normal 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: softsimon
|
3
contributors/wiz.txt
Normal file
3
contributors/wiz.txt
Normal 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: wiz
|
@ -13,6 +13,7 @@ __MEMPOOL_RECOMMENDED_FEE_PERCENTILE__=${MEMPOOL_RECOMMENDED_FEE_PERCENTILE:=50}
|
||||
__MEMPOOL_BLOCK_WEIGHT_UNITS__=${MEMPOOL_BLOCK_WEIGHT_UNITS:=4000000}
|
||||
__MEMPOOL_INITIAL_BLOCKS_AMOUNT__=${MEMPOOL_INITIAL_BLOCKS_AMOUNT:=8}
|
||||
__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_MEMPOOL_BLOCKS_AMOUNT:=8}
|
||||
__MEMPOOL_INDEXING_BLOCKS_AMOUNT__=${MEMPOOL_INDEXING_BLOCKS_AMOUNT:=1100}
|
||||
__MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__=${MEMPOOL_PRICE_FEED_UPDATE_INTERVAL:=3600}
|
||||
__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__=${MEMPOOL_USE_SECOND_NODE_FOR_MINFEE:=false}
|
||||
__MEMPOOL_EXTERNAL_ASSETS__=${MEMPOOL_EXTERNAL_ASSETS:=[]}
|
||||
@ -74,6 +75,7 @@ sed -i "s/__MEMPOOL_RECOMMENDED_FEE_PERCENTILE__/${__MEMPOOL_RECOMMENDED_FEE_PER
|
||||
sed -i "s/__MEMPOOL_BLOCK_WEIGHT_UNITS__/${__MEMPOOL_BLOCK_WEIGHT_UNITS__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_INITIAL_BLOCKS_AMOUNT__/${__MEMPOOL_INITIAL_BLOCKS_AMOUNT__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__/${__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_INDEXING_BLOCKS_AMOUNT__/${__MEMPOOL_INDEXING_BLOCKS_AMOUNT__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__/${__MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__/${__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_EXTERNAL_ASSETS__/${__MEMPOOL_EXTERNAL_ASSETS__}/g" mempool-config.json
|
||||
|
@ -421,7 +421,7 @@
|
||||
"link" : "http://www.dpool.top/"
|
||||
},
|
||||
"/Rawpool.com/": {
|
||||
"name" : "Rawpool.com",
|
||||
"name" : "Rawpool",
|
||||
"link" : "https://www.rawpool.com/"
|
||||
},
|
||||
"/haominer/": {
|
||||
@ -488,10 +488,14 @@
|
||||
"name" : "Binance Pool",
|
||||
"link" : "https://pool.binance.com/"
|
||||
},
|
||||
"/Minerium.com/" : {
|
||||
"/Mined in the USA by: /Minerium.com/" : {
|
||||
"name" : "Minerium",
|
||||
"link" : "https://www.minerium.com/"
|
||||
},
|
||||
"/Minerium.com/" : {
|
||||
"name" : "Minerium",
|
||||
"link" : "https://www.minerium.com/"
|
||||
},
|
||||
"/Buffett/": {
|
||||
"name" : "Lubian.com",
|
||||
"link" : ""
|
||||
@ -504,15 +508,15 @@
|
||||
"name" : "OKKONG",
|
||||
"link" : "https://hash.okkong.com"
|
||||
},
|
||||
"/TMSPOOL/" : {
|
||||
"name" : "TMSPool",
|
||||
"/AAOPOOL/" : {
|
||||
"name" : "AAO Pool",
|
||||
"link" : "https://btc.tmspool.top"
|
||||
},
|
||||
"/one_more_mcd/" : {
|
||||
"name" : "EMCDPool",
|
||||
"link" : "https://pool.emcd.io"
|
||||
},
|
||||
"/Foundry USA Pool #dropgold/" : {
|
||||
"Foundry USA Pool" : {
|
||||
"name" : "Foundry USA",
|
||||
"link" : "https://foundrydigital.com/"
|
||||
},
|
||||
@ -539,9 +543,29 @@
|
||||
"/PureBTC.COM/": {
|
||||
"name": "PureBTC.COM",
|
||||
"link": "https://purebtc.com"
|
||||
},
|
||||
"MARA Pool": {
|
||||
"name": "MARA Pool",
|
||||
"link": "https://marapool.com"
|
||||
},
|
||||
"KuCoinPool": {
|
||||
"name": "KuCoinPool",
|
||||
"link": "https://www.kucoin.com/mining-pool/"
|
||||
},
|
||||
"Entrustus" : {
|
||||
"name": "Entrust Charity Pool",
|
||||
"link": "pool.entustus.org"
|
||||
}
|
||||
},
|
||||
"payout_addresses" : {
|
||||
"1MkCDCzHpBsYQivp8MxjY5AkTGG1f2baoe": {
|
||||
"name": "Luxor",
|
||||
"link": "https://mining.luxor.tech"
|
||||
},
|
||||
"1ArTPjj6pV3aNRhLPjJVPYoxB98VLBzUmb": {
|
||||
"name" : "KuCoinPool",
|
||||
"link" : "https://www.kucoin.com/mining-pool/"
|
||||
},
|
||||
"3Bmb9Jig8A5kHdDSxvDZ6eryj3AXd3swuJ": {
|
||||
"name" : "NovaBlock",
|
||||
"link" : "https://novablock.com"
|
||||
@ -606,7 +630,7 @@
|
||||
"name" : "BitMinter",
|
||||
"link" : "http://bitminter.com/"
|
||||
},
|
||||
"15xiShqUqerfjFdyfgBH1K7Gwp6cbYmsTW " : {
|
||||
"15xiShqUqerfjFdyfgBH1K7Gwp6cbYmsTW" : {
|
||||
"name" : "EclipseMC",
|
||||
"link" : "https://eclipsemc.com/"
|
||||
},
|
||||
@ -634,6 +658,14 @@
|
||||
"name" : "Huobi.pool",
|
||||
"link" : "https://www.hpt.com/"
|
||||
},
|
||||
"1BDbsWi3Mrcjp1wdop3PWFNCNZtu4R7Hjy" : {
|
||||
"name" : "EMCDPool",
|
||||
"link" : "https://pool.emcd.io"
|
||||
},
|
||||
"12QVFmJH2b4455YUHkMpEnWLeRY3eJ4Jb5" : {
|
||||
"name" : "AAO Pool",
|
||||
"link" : "https://btc.tmspool.top "
|
||||
},
|
||||
"1ALA5v7h49QT7WYLcRsxcXqXUqEqaWmkvw" : {
|
||||
"name" : "CloudHashing",
|
||||
"link" : "https://cloudhashing.com/"
|
||||
@ -915,7 +947,7 @@
|
||||
"link" : "http://www.dpool.top/"
|
||||
},
|
||||
"1FbBbv5oYqFKwiPm4CAqvAy8345n8AQ74b" : {
|
||||
"name" : "Rawpool.com",
|
||||
"name" : "Rawpool",
|
||||
"link" : "https://www.rawpool.com/"
|
||||
},
|
||||
"1LsFmhnne74EmU4q4aobfxfrWY4wfMVd8w" : {
|
||||
@ -934,6 +966,22 @@
|
||||
"name" : "Poolin",
|
||||
"link" : "https://www.poolin.com/"
|
||||
},
|
||||
"1E8CZo2S3CqWg1VZSJNFCTbtT8hZPuQ2kB" : {
|
||||
"name" : "Poolin",
|
||||
"link" : "https://www.poolin.com/"
|
||||
},
|
||||
"14sA8jqYQgMRQV9zUtGFvpeMEw7YDn77SK" : {
|
||||
"name" : "Poolin",
|
||||
"link" : "https://www.poolin.com/"
|
||||
},
|
||||
"1GNgwA8JfG7Kc8akJ8opdNWJUihqUztfPe" : {
|
||||
"name" : "Poolin",
|
||||
"link" : "https://www.poolin.com/"
|
||||
},
|
||||
"17tUZLvy3X2557JGhceXRiij2TNYuhRr4r" : {
|
||||
"name" : "Poolin",
|
||||
"link" : "https://www.poolin.com/"
|
||||
},
|
||||
"12Taz8FFXQ3E2AGn3ZW1SZM5bLnYGX4xR6" : {
|
||||
"name" : "Tangpool",
|
||||
"link" : "http://www.tangpool.com/"
|
||||
@ -1126,6 +1174,10 @@
|
||||
"name" : "Binance Pool",
|
||||
"link" : "https://pool.binance.com/"
|
||||
},
|
||||
"1JvXhnHCi6XqcanvrZJ5s2Qiv4tsmm2UMy": {
|
||||
"name" : "Binance Pool",
|
||||
"link" : "https://pool.binance.com/"
|
||||
},
|
||||
"34Jpa4Eu3ApoPVUKNTN2WeuXVVq1jzxgPi": {
|
||||
"name" : "Lubian.com",
|
||||
"link" : "http://www.lubian.com/"
|
||||
@ -1173,6 +1225,14 @@
|
||||
"3CLigLYNkrtoNgNcUwTaKoUSHCwr9W851W": {
|
||||
"name": "Rawpool",
|
||||
"link": "https://www.rawpool.com"
|
||||
},
|
||||
"bc1qf274x7penhcd8hsv3jcmwa5xxzjl2a6pa9pxwm": {
|
||||
"name" : "F2Pool",
|
||||
"link" : "https://www.f2pool.com/"
|
||||
},
|
||||
"1A32KFEX7JNPmU1PVjrtiXRrTQcesT3Nf1": {
|
||||
"name": "MARA Pool",
|
||||
"link": "https://marapool.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -274,19 +274,6 @@ describe('Mainnet', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('loads genesis block and click on the arrow left', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/block/0');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.waitForPageIdle();
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('not.exist');
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').click().then(() => {
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -321,10 +308,10 @@ describe('Mainnet', () => {
|
||||
cy.get(':nth-child(3) > #bitcoin-block-0').should('not.exist');
|
||||
});
|
||||
|
||||
it('loads the blocks screen', () => {
|
||||
it('loads the pools screen', () => {
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-blocks').click().then(() => {
|
||||
cy.get('#btn-pools').click().then(() => {
|
||||
cy.waitForPageIdle();
|
||||
});
|
||||
});
|
||||
@ -384,6 +371,112 @@ describe('Mainnet', () => {
|
||||
cy.get('.blockchain-wrapper').should('not.visible');
|
||||
});
|
||||
|
||||
it('loads genesis block and click on the arrow left', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/block/0');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.waitForPageIdle();
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('not.exist');
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').click().then(() => {
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads skeleton when changes between networks', () => {
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
|
||||
cy.changeNetwork("testnet");
|
||||
cy.changeNetwork("signet");
|
||||
cy.changeNetwork("mainnet");
|
||||
});
|
||||
|
||||
it.skip('loads the dashboard with the skeleton blocks', () => {
|
||||
cy.mockMempoolSocket();
|
||||
cy.visit("/");
|
||||
cy.get(':nth-child(1) > #bitcoin-block-0').should('be.visible');
|
||||
cy.get(':nth-child(2) > #bitcoin-block-0').should('be.visible');
|
||||
cy.get(':nth-child(3) > #bitcoin-block-0').should('be.visible');
|
||||
cy.get('#mempool-block-0').should('be.visible');
|
||||
cy.get('#mempool-block-1').should('be.visible');
|
||||
cy.get('#mempool-block-2').should('be.visible');
|
||||
|
||||
emitMempoolInfo({
|
||||
'params': {
|
||||
command: 'init'
|
||||
}
|
||||
});
|
||||
|
||||
cy.get(':nth-child(1) > #bitcoin-block-0').should('not.exist');
|
||||
cy.get(':nth-child(2) > #bitcoin-block-0').should('not.exist');
|
||||
cy.get(':nth-child(3) > #bitcoin-block-0').should('not.exist');
|
||||
});
|
||||
|
||||
it('loads the pools screen', () => {
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-pools').click().then(() => {
|
||||
cy.wait(1000);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the graphs screen', () => {
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-graphs').click().then(() => {
|
||||
cy.wait(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('graphs page', () => {
|
||||
it('check buttons - mobile', () => {
|
||||
cy.viewport('iphone-6');
|
||||
cy.visit('/graphs');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.small-buttons > :nth-child(2)').should('be.visible');
|
||||
cy.get('#dropdownFees').should('be.visible');
|
||||
cy.get('.btn-group').should('be.visible');
|
||||
});
|
||||
it('check buttons - tablet', () => {
|
||||
cy.viewport('ipad-2');
|
||||
cy.visit('/graphs');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.small-buttons > :nth-child(2)').should('be.visible');
|
||||
cy.get('#dropdownFees').should('be.visible');
|
||||
cy.get('.btn-group').should('be.visible');
|
||||
});
|
||||
it('check buttons - desktop', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/graphs');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.small-buttons > :nth-child(2)').should('be.visible');
|
||||
cy.get('#dropdownFees').should('be.visible');
|
||||
cy.get('.btn-group').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the tv screen - desktop', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-tv').click().then(() => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.get('.chart-holder');
|
||||
cy.get('.blockchain-wrapper').should('be.visible');
|
||||
cy.get('#mempool-block-0').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the tv screen - mobile', () => {
|
||||
cy.viewport('iphone-6');
|
||||
cy.visit('/tv');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.chart-holder');
|
||||
cy.get('.blockchain-wrapper').should('not.visible');
|
||||
});
|
||||
|
||||
it('loads the api screen', () => {
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
|
@ -44,10 +44,10 @@ describe('Signet', () => {
|
||||
cy.get(':nth-child(3) > #bitcoin-block-0').should('not.exist');
|
||||
});
|
||||
|
||||
it('loads the blocks screen', () => {
|
||||
it('loads the pools screen', () => {
|
||||
cy.visit('/signet');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-blocks').click().then(() => {
|
||||
cy.get('#btn-pools').click().then(() => {
|
||||
cy.wait(1000);
|
||||
});
|
||||
});
|
||||
|
@ -44,10 +44,10 @@ describe('Testnet', () => {
|
||||
cy.get(':nth-child(3) > #bitcoin-block-0').should('not.exist');
|
||||
});
|
||||
|
||||
it('loads the blocks screen', () => {
|
||||
it('loads the pools screen', () => {
|
||||
cy.visit('/testnet');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-blocks').click().then(() => {
|
||||
cy.get('#btn-pools').click().then(() => {
|
||||
cy.wait(1000);
|
||||
});
|
||||
});
|
||||
|
@ -6,7 +6,6 @@ import * as express from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as domino from 'domino';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
|
||||
import { join } from 'path';
|
||||
import { AppServerModule } from './src/main.server';
|
||||
@ -66,6 +65,7 @@ export function app(locale: string): express.Express {
|
||||
server.get('/mempool-block/*', getLocalizedSSR(indexHtml));
|
||||
server.get('/address/*', getLocalizedSSR(indexHtml));
|
||||
server.get('/blocks', getLocalizedSSR(indexHtml));
|
||||
server.get('/mining/pools', getLocalizedSSR(indexHtml));
|
||||
server.get('/graphs', getLocalizedSSR(indexHtml));
|
||||
server.get('/liquid', getLocalizedSSR(indexHtml));
|
||||
server.get('/liquid/tx/*', getLocalizedSSR(indexHtml));
|
||||
@ -86,6 +86,7 @@ export function app(locale: string): express.Express {
|
||||
server.get('/testnet/mempool-block/*', getLocalizedSSR(indexHtml));
|
||||
server.get('/testnet/address/*', getLocalizedSSR(indexHtml));
|
||||
server.get('/testnet/blocks', getLocalizedSSR(indexHtml));
|
||||
server.get('/testnet/mining/pools', getLocalizedSSR(indexHtml));
|
||||
server.get('/testnet/graphs', getLocalizedSSR(indexHtml));
|
||||
server.get('/testnet/api', getLocalizedSSR(indexHtml));
|
||||
server.get('/testnet/tv', getLocalizedSSR(indexHtml));
|
||||
@ -97,6 +98,7 @@ export function app(locale: string): express.Express {
|
||||
server.get('/signet/mempool-block/*', getLocalizedSSR(indexHtml));
|
||||
server.get('/signet/address/*', getLocalizedSSR(indexHtml));
|
||||
server.get('/signet/blocks', getLocalizedSSR(indexHtml));
|
||||
server.get('/signet/mining/pools', getLocalizedSSR(indexHtml));
|
||||
server.get('/signet/graphs', getLocalizedSSR(indexHtml));
|
||||
server.get('/signet/api', getLocalizedSSR(indexHtml));
|
||||
server.get('/signet/tv', getLocalizedSSR(indexHtml));
|
||||
|
@ -22,6 +22,7 @@ import { BisqMasterPageComponent } from './components/bisq-master-page/bisq-mast
|
||||
import { SponsorComponent } from './components/sponsor/sponsor.component';
|
||||
import { LiquidMasterPageComponent } from './components/liquid-master-page/liquid-master-page.component';
|
||||
import { PushTransactionComponent } from './components/push-transaction/push-transaction.component';
|
||||
import { PoolRankingComponent } from './components/pool-ranking/pool-ranking.component';
|
||||
|
||||
let routes: Routes = [
|
||||
{
|
||||
@ -58,6 +59,10 @@ let routes: Routes = [
|
||||
path: 'blocks',
|
||||
component: LatestBlocksComponent,
|
||||
},
|
||||
{
|
||||
path: 'mining/pools',
|
||||
component: PoolRankingComponent,
|
||||
},
|
||||
{
|
||||
path: 'graphs',
|
||||
component: StatisticsComponent,
|
||||
@ -142,6 +147,10 @@ let routes: Routes = [
|
||||
path: 'blocks',
|
||||
component: LatestBlocksComponent,
|
||||
},
|
||||
{
|
||||
path: 'mining/pools',
|
||||
component: PoolRankingComponent,
|
||||
},
|
||||
{
|
||||
path: 'graphs',
|
||||
component: StatisticsComponent,
|
||||
@ -220,6 +229,10 @@ let routes: Routes = [
|
||||
path: 'blocks',
|
||||
component: LatestBlocksComponent,
|
||||
},
|
||||
{
|
||||
path: 'mining/pools',
|
||||
component: PoolRankingComponent,
|
||||
},
|
||||
{
|
||||
path: 'graphs',
|
||||
component: StatisticsComponent,
|
||||
|
@ -37,6 +37,7 @@ import { IncomingTransactionsGraphComponent } from './components/incoming-transa
|
||||
import { TimeSpanComponent } from './components/time-span/time-span.component';
|
||||
import { SeoService } from './services/seo.service';
|
||||
import { MempoolGraphComponent } from './components/mempool-graph/mempool-graph.component';
|
||||
import { PoolRankingComponent } from './components/pool-ranking/pool-ranking.component';
|
||||
import { LbtcPegsGraphComponent } from './components/lbtc-pegs-graph/lbtc-pegs-graph.component';
|
||||
import { AssetComponent } from './components/asset/asset.component';
|
||||
import { AssetsComponent } from './assets/assets.component';
|
||||
@ -48,7 +49,7 @@ import { FeesBoxComponent } from './components/fees-box/fees-box.component';
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { DifficultyComponent } from './components/difficulty/difficulty.component';
|
||||
import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome';
|
||||
import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faDatabase, faExchangeAlt, faInfoCircle,
|
||||
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, faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ApiDocsComponent } from './components/docs/api-docs.component';
|
||||
import { DocsComponent } from './components/docs/docs.component';
|
||||
@ -91,6 +92,7 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
FeeDistributionGraphComponent,
|
||||
IncomingTransactionsGraphComponent,
|
||||
MempoolGraphComponent,
|
||||
PoolRankingComponent,
|
||||
LbtcPegsGraphComponent,
|
||||
AssetComponent,
|
||||
AssetsComponent,
|
||||
@ -143,6 +145,7 @@ export class AppModule {
|
||||
library.addIcons(faTv);
|
||||
library.addIcons(faTachometerAlt);
|
||||
library.addIcons(faCubes);
|
||||
library.addIcons(faHammer);
|
||||
library.addIcons(faCogs);
|
||||
library.addIcons(faThList);
|
||||
library.addIcons(faList);
|
||||
|
@ -102,7 +102,7 @@
|
||||
<img class="image" src="/resources/profile/ronindojo.png" />
|
||||
<span>RoninDojo</span>
|
||||
</a>
|
||||
<a href="https://github.com/runcitadel/dashboard" target="_blank" title="Citadel">
|
||||
<a href="https://github.com/runcitadel/core" target="_blank" title="Citadel">
|
||||
<img class="image" src="/resources/profile/runcitadel.svg" />
|
||||
<span>Citadel</span>
|
||||
</a>
|
||||
@ -220,7 +220,7 @@
|
||||
|
||||
<div class="copyright">
|
||||
<div class="title">
|
||||
Copyright © 2019-2021<br>
|
||||
Copyright © 2019-2022<br>
|
||||
The Mempool Open Source Project
|
||||
</div>
|
||||
<p>
|
||||
|
@ -39,13 +39,22 @@
|
||||
{{ epochData.previousRetarget | absolute | number: '1.2-2' }} </span> %
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="item" *ngIf="showProgress">
|
||||
<h5 class="card-title" i18n="difficulty-box.current-period">Current Period</h5>
|
||||
<div class="card-text">{{ epochData.progress | number: '1.2-2' }} <span class="symbol">%</span></div>
|
||||
<div class="progress small-bar">
|
||||
<div class="progress-bar" role="progressbar" style="width: 15%; background-color: #105fb0" [ngStyle]="{'width': epochData.base}"> </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item" *ngIf="showHalving">
|
||||
<h5 class="card-title" i18n="difficulty-box.next-halving">Next halving</h5>
|
||||
<div class="card-text">
|
||||
<ng-container *ngTemplateOutlet="epochData.blocksUntilHalving === 1 ? blocksSingular : blocksPlural; context: {$implicit: epochData.blocksUntilHalving }"></ng-container>
|
||||
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
|
||||
<ng-template #blocksSingular let-i i18n="shared.block">{{ i }} <span class="shared-block">block</span></ng-template>
|
||||
</div>
|
||||
<div class="symbol"><app-time-until [time]="epochData.timeUntilHalving" [fastRender]="true"></app-time-until></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -14,6 +14,8 @@ interface EpochProgress {
|
||||
timeAvg: string;
|
||||
remainingTime: number;
|
||||
previousRetarget: number;
|
||||
blocksUntilHalving: number;
|
||||
timeUntilHalving: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@ -26,6 +28,9 @@ export class DifficultyComponent implements OnInit {
|
||||
isLoadingWebSocket$: Observable<boolean>;
|
||||
difficultyEpoch$: Observable<EpochProgress>;
|
||||
|
||||
@Input() showProgress: boolean = true;
|
||||
@Input() showHalving: boolean = false;
|
||||
|
||||
constructor(
|
||||
public stateService: StateService,
|
||||
) { }
|
||||
@ -92,6 +97,9 @@ export class DifficultyComponent implements OnInit {
|
||||
colorPreviousAdjustments = '#ffffff66';
|
||||
}
|
||||
|
||||
const blocksUntilHalving = block.height % 210000;
|
||||
const timeUntilHalving = (blocksUntilHalving * timeAvgMins * 60 * 1000) + (now * 1000);
|
||||
|
||||
return {
|
||||
base: `${progress}%`,
|
||||
change,
|
||||
@ -104,6 +112,8 @@ export class DifficultyComponent implements OnInit {
|
||||
newDifficultyHeight,
|
||||
remainingTime,
|
||||
previousRetarget,
|
||||
blocksUntilHalving,
|
||||
timeUntilHalving,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
@ -31,8 +31,8 @@
|
||||
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-home">
|
||||
<a class="nav-link" [routerLink]="['/' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'tachometer-alt']" [fixedWidth]="true" i18n-title="master-page.dashboard" title="Dashboard"></fa-icon></a>
|
||||
</li>
|
||||
<li class="nav-item" routerLinkActive="active" id="btn-blocks">
|
||||
<a class="nav-link" [routerLink]="['/blocks' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'cubes']" [fixedWidth]="true" i18n-title="master-page.blocks" title="Blocks"></fa-icon></a>
|
||||
<li class="nav-item" routerLinkActive="active" id="btn-pools">
|
||||
<a class="nav-link" [routerLink]="['/mining/pools' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'hammer']" [fixedWidth]="true" i18n-title="master-page.mining-pools" title="Mining Pools"></fa-icon></a>
|
||||
</li>
|
||||
<li class="nav-item" routerLinkActive="active" id="btn-graphs">
|
||||
<a class="nav-link" [routerLink]="['/graphs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'chart-area']" [fixedWidth]="true" i18n-title="master-page.graphs" title="Graphs"></fa-icon></a>
|
||||
|
@ -0,0 +1,77 @@
|
||||
<div class="container-xl">
|
||||
<!-- <app-difficulty [showProgress]=false [showHalving]=true></app-difficulty> -->
|
||||
|
||||
<div class="hashrate-pie" echarts [initOpts]="chartInitOptions" [options]="chartOptions"></div>
|
||||
<div class="text-center loadingGraphs" *ngIf="isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-header mb-0 mb-lg-4">
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(miningStatsObservable$ | async) as miningStats">
|
||||
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
|
||||
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 1">
|
||||
<input ngbButton type="radio" [value]="'24h'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="24h"> 24h
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 3">
|
||||
<input ngbButton type="radio" [value]="'3d'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="3d"> 3D
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 7">
|
||||
<input ngbButton type="radio" [value]="'1w'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="1w"> 1W
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 30">
|
||||
<input ngbButton type="radio" [value]="'1m'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="1m"> 1M
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 90">
|
||||
<input ngbButton type="radio" [value]="'3m'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="3m"> 3M
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 180">
|
||||
<input ngbButton type="radio" [value]="'6m'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="6m"> 6M
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 365">
|
||||
<input ngbButton type="radio" [value]="'1y'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="1y"> 1Y
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 730">
|
||||
<input ngbButton type="radio" [value]="'2y'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="2y"> 2Y
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 1095">
|
||||
<input ngbButton type="radio" [value]="'3y'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="3y"> 3Y
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||
<input ngbButton type="radio" [value]="'all'" [routerLink]="['/mining/pools' | relativeUrl]" fragment="all"> ALL
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<table class="table table-borderless text-center pools-table" [alwaysCallback]="true" infiniteScroll [infiniteScrollDistance]="1.5" [infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="d-none d-md-block" i18n="mining.rank">Rank</th>
|
||||
<th class=""></th>
|
||||
<th class="" i18n="mining.pool-name">Pool</th>
|
||||
<th class="" *ngIf="this.poolsWindowPreference === '24h'" i18n="mining.hashrate">Hashrate</th>
|
||||
<th class="" i18n="master-page.blocks">Blocks</th>
|
||||
<th class="d-none d-md-block" i18n="mining.empty-blocks">Empty Blocks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody *ngIf="(miningStatsObservable$ | async) as miningStats">
|
||||
<tr *ngFor="let pool of miningStats.pools">
|
||||
<td class="d-none d-md-block">{{ pool.rank }}</td>
|
||||
<td class="text-right"><img width="25" height="25" src="{{ pool.logo }}" onError="this.src = './resources/mining-pools/default.svg'"></td>
|
||||
<td class="">{{ pool.name }}</td>
|
||||
<td class="" *ngIf="this.poolsWindowPreference === '24h'">{{ pool.lastEstimatedHashrate }} {{ miningStats.miningUnits.hashrateUnit }}</td>
|
||||
<td class="">{{ pool['blockText'] }}</td>
|
||||
<td class="d-none d-md-block">{{ pool.emptyBlocks }} ({{ pool.emptyBlockRatio }}%)</td>
|
||||
</tr>
|
||||
<tr style="border-top: 1px solid #555">
|
||||
<td class="d-none d-md-block">-</td>
|
||||
<td class="text-right"><img width="25" height="25" src="./resources/mining-pools/default.svg"></td>
|
||||
<td class="" i18n="mining.all-miners"><b>All miners</b></td>
|
||||
<td class="" *ngIf="this.poolsWindowPreference === '24h'"><b>{{ miningStats.lastEstimatedHashrate}} {{ miningStats.miningUnits.hashrateUnit }}</b></td>
|
||||
<td class=""><b>{{ miningStats.blockCount }}</b></td>
|
||||
<td class="d-none d-md-block"><b>{{ miningStats.totalEmptyBlock }} ({{ miningStats.totalEmptyBlockRatio }}%)</b></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
@ -0,0 +1,32 @@
|
||||
.hashrate-pie {
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
@media (max-width: 767.98px) {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.formRadioGroup {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@media (min-width: 830px) {
|
||||
margin-left: 2%;
|
||||
flex-direction: row;
|
||||
float: left;
|
||||
margin-top: 0px;
|
||||
}
|
||||
.btn-sm {
|
||||
font-size: 9px;
|
||||
@media (min-width: 830px) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.pools-table th,
|
||||
.pools-table td {
|
||||
padding: .3em !important;
|
||||
}
|
||||
}
|
@ -0,0 +1,218 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { EChartsOption } from 'echarts';
|
||||
import { combineLatest, Observable, of } from 'rxjs';
|
||||
import { catchError, map, share, skip, startWith, switchMap, tap } from 'rxjs/operators';
|
||||
import { SinglePoolStats } from 'src/app/interfaces/node-api.interface';
|
||||
import { SeoService } from 'src/app/services/seo.service';
|
||||
import { StorageService } from '../..//services/storage.service';
|
||||
import { MiningService, MiningStats } from '../../services/mining.service';
|
||||
import { StateService } from '../../services/state.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-pool-ranking',
|
||||
templateUrl: './pool-ranking.component.html',
|
||||
styleUrls: ['./pool-ranking.component.scss'],
|
||||
styles: [`
|
||||
.loadingGraphs {
|
||||
position: absolute;
|
||||
top: 38%;
|
||||
left: calc(50% - 15px);
|
||||
z-index: 100;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PoolRankingComponent implements OnInit, OnDestroy {
|
||||
poolsWindowPreference: string;
|
||||
radioGroupForm: FormGroup;
|
||||
|
||||
isLoading = true;
|
||||
chartOptions: EChartsOption = {};
|
||||
chartInitOptions = {
|
||||
renderer: 'svg'
|
||||
};
|
||||
|
||||
miningStatsObservable$: Observable<MiningStats>;
|
||||
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
private storageService: StorageService,
|
||||
private formBuilder: FormBuilder,
|
||||
private miningService: MiningService,
|
||||
private seoService: SeoService,
|
||||
) {
|
||||
this.seoService.setTitle($localize`:@@mining.mining-pools:Mining Pools`);
|
||||
this.poolsWindowPreference = this.storageService.getValue('poolsWindowPreference') ? this.storageService.getValue('poolsWindowPreference') : '1w';
|
||||
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.poolsWindowPreference });
|
||||
this.radioGroupForm.controls.dateSpan.setValue(this.poolsWindowPreference);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// When...
|
||||
this.miningStatsObservable$ = combineLatest([
|
||||
// ...a new block is mined
|
||||
this.stateService.blocks$
|
||||
.pipe(
|
||||
// (we always receives some blocks at start so only trigger for the last one)
|
||||
skip(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT - 1),
|
||||
),
|
||||
// ...or we change the timespan
|
||||
this.radioGroupForm.get('dateSpan').valueChanges
|
||||
.pipe(
|
||||
startWith(this.poolsWindowPreference), // (trigger when the page loads)
|
||||
tap((value) => {
|
||||
this.storageService.setValue('poolsWindowPreference', value);
|
||||
this.poolsWindowPreference = value;
|
||||
})
|
||||
)
|
||||
])
|
||||
// ...then refresh the mining stats
|
||||
.pipe(
|
||||
switchMap(() => {
|
||||
this.isLoading = true;
|
||||
return this.miningService.getMiningStats(this.poolsWindowPreference)
|
||||
.pipe(
|
||||
catchError((e) => of(this.getEmptyMiningStat()))
|
||||
);
|
||||
}),
|
||||
map(data => {
|
||||
data.pools = data.pools.map((pool: SinglePoolStats) => this.formatPoolUI(pool));
|
||||
return data;
|
||||
}),
|
||||
tap(data => {
|
||||
this.isLoading = false;
|
||||
this.prepareChartOptions(data);
|
||||
}),
|
||||
share()
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
}
|
||||
|
||||
formatPoolUI(pool: SinglePoolStats) {
|
||||
pool['blockText'] = pool.blockCount.toString() + ` (${pool.share}%)`;
|
||||
return pool;
|
||||
}
|
||||
|
||||
isMobile() {
|
||||
return (window.innerWidth <= 767.98);
|
||||
}
|
||||
|
||||
generatePoolsChartSerieData(miningStats) {
|
||||
const poolShareThreshold = this.isMobile() ? 1 : 0.5; // Do not draw pools which hashrate share is lower than that
|
||||
const data: object[] = [];
|
||||
|
||||
miningStats.pools.forEach((pool) => {
|
||||
if (parseFloat(pool.share) < poolShareThreshold) {
|
||||
return;
|
||||
}
|
||||
data.push({
|
||||
value: pool.share,
|
||||
name: pool.name + (this.isMobile() ? `` : ` (${pool.share}%)`),
|
||||
label: {
|
||||
color: '#FFFFFF',
|
||||
overflow: 'break',
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "#282d47",
|
||||
textStyle: {
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
formatter: () => {
|
||||
if (this.poolsWindowPreference === '24h') {
|
||||
return `<u><b>${pool.name} (${pool.share}%)</b></u><br>` +
|
||||
pool.lastEstimatedHashrate.toString() + ' PH/s' +
|
||||
`<br>` + pool.blockCount.toString() + ` blocks`;
|
||||
} else {
|
||||
return `<u><b>${pool.name} (${pool.share}%)</b></u><br>` +
|
||||
pool.blockCount.toString() + ` blocks`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
prepareChartOptions(miningStats) {
|
||||
let network = this.stateService.network;
|
||||
if (network === '') {
|
||||
network = 'bitcoin';
|
||||
}
|
||||
network = network.charAt(0).toUpperCase() + network.slice(1);
|
||||
|
||||
this.chartOptions = {
|
||||
title: {
|
||||
text: $localize`:@@mining.pool-chart-title:${network}:NETWORK: mining pools share`,
|
||||
subtext: $localize`:@@mining.pool-chart-sub-title:Estimated from the # of blocks mined`,
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: '#FFF',
|
||||
},
|
||||
subtextStyle: {
|
||||
color: '#CCC',
|
||||
fontStyle: 'italic',
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
top: this.isMobile() ? '5%' : '20%',
|
||||
name: 'Mining pool',
|
||||
type: 'pie',
|
||||
radius: this.isMobile() ? ['10%', '50%'] : ['20%', '80%'],
|
||||
data: this.generatePoolsChartSerieData(miningStats),
|
||||
labelLine: {
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
},
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
},
|
||||
itemStyle: {
|
||||
borderRadius: 2,
|
||||
borderWidth: 2,
|
||||
borderColor: '#000',
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
borderColor: '#FFF',
|
||||
borderRadius: 2,
|
||||
shadowBlur: 80,
|
||||
shadowColor: 'rgba(255, 255, 255, 0.75)',
|
||||
},
|
||||
labelLine: {
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default mining stats if something goes wrong
|
||||
*/
|
||||
getEmptyMiningStat() {
|
||||
return {
|
||||
lastEstimatedHashrate: 'Error',
|
||||
blockCount: 0,
|
||||
totalEmptyBlock: 0,
|
||||
totalEmptyBlockRatio: '',
|
||||
pools: [],
|
||||
availableTimespanDay: 0,
|
||||
miningUnits: {
|
||||
hashrateDivider: 1,
|
||||
hashrateUnit: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -270,7 +270,7 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-template #assetBox let-item>
|
||||
{{ item.value / 100000000 | number: '1.0-' + assetsMinimal[item.asset][3] }} {{ assetsMinimal[item.asset][1] }}
|
||||
{{ item.value / pow(10, assetsMinimal[item.asset][3]) | number: '1.' + assetsMinimal[item.asset][3] + '-' + assetsMinimal[item.asset][3] }} {{ assetsMinimal[item.asset][1] }}
|
||||
<br />
|
||||
{{ assetsMinimal[item.asset][0] }}
|
||||
<br />
|
||||
|
@ -119,6 +119,10 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
||||
return '0x' + (str.length % 2 ? '0' : '') + str;
|
||||
}
|
||||
|
||||
pow(base: number, exponent: number): number {
|
||||
return Math.pow(base, exponent);
|
||||
}
|
||||
|
||||
toggleDetails() {
|
||||
this.displayDetails = !this.displayDetails;
|
||||
this.ref.markForCheck();
|
||||
|
@ -51,3 +51,32 @@ export interface LiquidPegs {
|
||||
}
|
||||
|
||||
export interface ITranslators { [language: string]: string; }
|
||||
|
||||
export interface SinglePoolStats {
|
||||
pooldId: number;
|
||||
name: string;
|
||||
link: string;
|
||||
blockCount: number;
|
||||
emptyBlocks: number;
|
||||
rank: number;
|
||||
share: string;
|
||||
lastEstimatedHashrate: string;
|
||||
emptyBlockRatio: string;
|
||||
logo: string;
|
||||
}
|
||||
|
||||
export interface PoolsStats {
|
||||
blockCount: number;
|
||||
lastEstimatedHashrate: number;
|
||||
oldestIndexedBlockTimestamp: number;
|
||||
pools: SinglePoolStats[];
|
||||
}
|
||||
|
||||
export interface MiningStats {
|
||||
lastEstimatedHashrate: string,
|
||||
blockCount: number,
|
||||
totalEmptyBlock: number,
|
||||
totalEmptyBlockRatio: string,
|
||||
pools: SinglePoolStats[],
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { CpfpInfo, OptimizedMempoolStats, DifficultyAdjustment, AddressInformation, LiquidPegs, ITranslators } from '../interfaces/node-api.interface';
|
||||
import { CpfpInfo, OptimizedMempoolStats, DifficultyAdjustment, AddressInformation, LiquidPegs, ITranslators, PoolsStats } from '../interfaces/node-api.interface';
|
||||
import { Observable } from 'rxjs';
|
||||
import { StateService } from './state.service';
|
||||
import { WebsocketResponse } from '../interfaces/websocket.interface';
|
||||
@ -120,4 +120,8 @@ export class ApiService {
|
||||
postTransaction$(hexPayload: string): Observable<any> {
|
||||
return this.httpClient.post<any>(this.apiBaseUrl + this.apiBasePath + '/api/tx', hexPayload, { responseType: 'text' as 'json'});
|
||||
}
|
||||
|
||||
listPools$(interval: string | null) : Observable<PoolsStats> {
|
||||
return this.httpClient.get<PoolsStats>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pools/${interval}`);
|
||||
}
|
||||
}
|
||||
|
98
frontend/src/app/services/mining.service.ts
Normal file
98
frontend/src/app/services/mining.service.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { PoolsStats, SinglePoolStats } from '../interfaces/node-api.interface';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { StateService } from './state.service';
|
||||
|
||||
export interface MiningUnits {
|
||||
hashrateDivider: number;
|
||||
hashrateUnit: string;
|
||||
}
|
||||
|
||||
export interface MiningStats {
|
||||
lastEstimatedHashrate: string;
|
||||
blockCount: number;
|
||||
totalEmptyBlock: number;
|
||||
totalEmptyBlockRatio: string;
|
||||
pools: SinglePoolStats[];
|
||||
miningUnits: MiningUnits;
|
||||
availableTimespanDay: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MiningService {
|
||||
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
private apiService: ApiService,
|
||||
) { }
|
||||
|
||||
public getMiningStats(interval: string): Observable<MiningStats> {
|
||||
return this.apiService.listPools$(interval).pipe(
|
||||
map(pools => this.generateMiningStats(pools))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the hashrate power of ten we want to display
|
||||
*/
|
||||
public getMiningUnits(): MiningUnits {
|
||||
const powerTable = {
|
||||
0: 'H/s',
|
||||
3: 'kH/s',
|
||||
6: 'MH/s',
|
||||
9: 'GH/s',
|
||||
12: 'TH/s',
|
||||
15: 'PH/s',
|
||||
18: 'EH/s',
|
||||
};
|
||||
|
||||
// I think it's fine to hardcode this since we don't have x1000 hashrate jump everyday
|
||||
// If we want to support the mining dashboard for testnet, we can hardcode it too
|
||||
let selectedPower = 15;
|
||||
if (this.stateService.network === 'testnet') {
|
||||
selectedPower = 12;
|
||||
}
|
||||
|
||||
return {
|
||||
hashrateDivider: Math.pow(10, selectedPower),
|
||||
hashrateUnit: powerTable[selectedPower],
|
||||
};
|
||||
}
|
||||
|
||||
private generateMiningStats(stats: PoolsStats): MiningStats {
|
||||
const miningUnits = this.getMiningUnits();
|
||||
const hashrateDivider = miningUnits.hashrateDivider;
|
||||
|
||||
const totalEmptyBlock = Object.values(stats.pools).reduce((prev, cur) => {
|
||||
return prev + cur.emptyBlocks;
|
||||
}, 0);
|
||||
const totalEmptyBlockRatio = (totalEmptyBlock / stats.blockCount * 100).toFixed(2);
|
||||
const poolsStats = stats.pools.map((poolStat) => {
|
||||
return {
|
||||
share: (poolStat.blockCount / stats.blockCount * 100).toFixed(2),
|
||||
lastEstimatedHashrate: (poolStat.blockCount / stats.blockCount * stats.lastEstimatedHashrate / hashrateDivider).toFixed(2),
|
||||
emptyBlockRatio: (poolStat.emptyBlocks / poolStat.blockCount * 100).toFixed(2),
|
||||
logo: `./resources/mining-pools/` + poolStat.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg',
|
||||
...poolStat
|
||||
};
|
||||
});
|
||||
|
||||
const availableTimespanDay = (
|
||||
(new Date().getTime() / 1000) - (stats.oldestIndexedBlockTimestamp / 1000)
|
||||
) / 3600 / 24;
|
||||
|
||||
return {
|
||||
lastEstimatedHashrate: (stats.lastEstimatedHashrate / hashrateDivider).toFixed(2),
|
||||
blockCount: stats.blockCount,
|
||||
totalEmptyBlock: totalEmptyBlock,
|
||||
totalEmptyBlockRatio: totalEmptyBlockRatio,
|
||||
pools: poolsStats,
|
||||
miningUnits: miningUnits,
|
||||
availableTimespanDay: availableTimespanDay,
|
||||
};
|
||||
}
|
||||
}
|
@ -6,18 +6,28 @@ import { Router, ActivatedRoute } from '@angular/router';
|
||||
})
|
||||
export class StorageService {
|
||||
constructor(private router: Router, private route: ActivatedRoute) {
|
||||
let graphWindowPreference: string = this.getValue('graphWindowPreference');
|
||||
this.setDefaultValueIfNeeded('graphWindowPreference', '2h');
|
||||
this.setDefaultValueIfNeeded('poolsWindowPreference', '1w');
|
||||
}
|
||||
|
||||
setDefaultValueIfNeeded(key: string, defaultValue: string) {
|
||||
let graphWindowPreference: string = this.getValue(key);
|
||||
if (graphWindowPreference === null) { // First visit to mempool.space
|
||||
if (this.router.url.includes("graphs")) {
|
||||
this.setValue('graphWindowPreference', this.route.snapshot.fragment ? this.route.snapshot.fragment : "2h");
|
||||
if (this.router.url.includes('graphs') && key === 'graphWindowPreference' ||
|
||||
this.router.url.includes('pools') && key === 'poolsWindowPreference'
|
||||
) {
|
||||
this.setValue(key, this.route.snapshot.fragment ? this.route.snapshot.fragment : defaultValue);
|
||||
} else {
|
||||
this.setValue('graphWindowPreference', "2h");
|
||||
this.setValue(key, defaultValue);
|
||||
}
|
||||
} else if (this.router.url.includes("graphs")) { // Visit a different graphs#fragment from last visit
|
||||
if (this.route.snapshot.fragment !== null && graphWindowPreference !== this.route.snapshot.fragment) {
|
||||
this.setValue('graphWindowPreference', this.route.snapshot.fragment);
|
||||
}
|
||||
} else if (this.router.url.includes('graphs') && key === 'graphWindowPreference' ||
|
||||
this.router.url.includes('pools') && key === 'poolsWindowPreference'
|
||||
) {
|
||||
// Visit a different graphs#fragment from last visit
|
||||
if (this.route.snapshot.fragment !== null && graphWindowPreference !== this.route.snapshot.fragment) {
|
||||
this.setValue(key, this.route.snapshot.fragment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getValue(key: string): string {
|
||||
|
69
frontend/src/resources/mining-pools/default.svg
Normal file
69
frontend/src/resources/mining-pools/default.svg
Normal file
@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.2" width="135.73mm" height="135.73mm" viewBox="0 0 13573 13573" preserveAspectRatio="xMidYMid" fill-rule="evenodd" stroke-width="28.222" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" xmlns:ooo="http://xml.openoffice.org/svg/export" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:presentation="http://sun.com/xmlns/staroffice/presentation" xmlns:smil="http://www.w3.org/2001/SMIL20/" xmlns:anim="urn:oasis:names:tc:opendocument:xmlns:animation:1.0" xml:space="preserve">
|
||||
<defs class="ClipPathGroup">
|
||||
<clipPath id="presentation_clip_path" clipPathUnits="userSpaceOnUse">
|
||||
<rect x="0" y="0" width="13573" height="13573"/>
|
||||
</clipPath>
|
||||
<clipPath id="presentation_clip_path_shrink" clipPathUnits="userSpaceOnUse">
|
||||
<rect x="13" y="13" width="13546" height="13546"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<defs class="TextShapeIndex">
|
||||
<g ooo:slide="id1" ooo:id-list="id3"/>
|
||||
</defs>
|
||||
<defs class="EmbeddedBulletChars">
|
||||
<g id="bullet-char-template-57356" transform="scale(0.00048828125,-0.00048828125)">
|
||||
<path d="M 580,1141 L 1163,571 580,0 -4,571 580,1141 Z"/>
|
||||
</g>
|
||||
<g id="bullet-char-template-57354" transform="scale(0.00048828125,-0.00048828125)">
|
||||
<path d="M 8,1128 L 1137,1128 1137,0 8,0 8,1128 Z"/>
|
||||
</g>
|
||||
<g id="bullet-char-template-10146" transform="scale(0.00048828125,-0.00048828125)">
|
||||
<path d="M 174,0 L 602,739 174,1481 1456,739 174,0 Z M 1358,739 L 309,1346 659,739 1358,739 Z"/>
|
||||
</g>
|
||||
<g id="bullet-char-template-10132" transform="scale(0.00048828125,-0.00048828125)">
|
||||
<path d="M 2015,739 L 1276,0 717,0 1260,543 174,543 174,936 1260,936 717,1481 1274,1481 2015,739 Z"/>
|
||||
</g>
|
||||
<g id="bullet-char-template-10007" transform="scale(0.00048828125,-0.00048828125)">
|
||||
<path d="M 0,-2 C -7,14 -16,27 -25,37 L 356,567 C 262,823 215,952 215,954 215,979 228,992 255,992 264,992 276,990 289,987 310,991 331,999 354,1012 L 381,999 492,748 772,1049 836,1024 860,1049 C 881,1039 901,1025 922,1006 886,937 835,863 770,784 769,783 710,716 594,584 L 774,223 C 774,196 753,168 711,139 L 727,119 C 717,90 699,76 672,76 641,76 570,178 457,381 L 164,-76 C 142,-110 111,-127 72,-127 30,-127 9,-110 8,-76 1,-67 -2,-52 -2,-32 -2,-23 -1,-13 0,-2 Z"/>
|
||||
</g>
|
||||
<g id="bullet-char-template-10004" transform="scale(0.00048828125,-0.00048828125)">
|
||||
<path d="M 285,-33 C 182,-33 111,30 74,156 52,228 41,333 41,471 41,549 55,616 82,672 116,743 169,778 240,778 293,778 328,747 346,684 L 369,508 C 377,444 397,411 428,410 L 1163,1116 C 1174,1127 1196,1133 1229,1133 1271,1133 1292,1118 1292,1087 L 1292,965 C 1292,929 1282,901 1262,881 L 442,47 C 390,-6 338,-33 285,-33 Z"/>
|
||||
</g>
|
||||
<g id="bullet-char-template-9679" transform="scale(0.00048828125,-0.00048828125)">
|
||||
<path d="M 813,0 C 632,0 489,54 383,161 276,268 223,411 223,592 223,773 276,916 383,1023 489,1130 632,1184 813,1184 992,1184 1136,1130 1245,1023 1353,916 1407,772 1407,592 1407,412 1353,268 1245,161 1136,54 992,0 813,0 Z"/>
|
||||
</g>
|
||||
<g id="bullet-char-template-8226" transform="scale(0.00048828125,-0.00048828125)">
|
||||
<path d="M 346,457 C 273,457 209,483 155,535 101,586 74,649 74,723 74,796 101,859 155,911 209,963 273,989 346,989 419,989 480,963 531,910 582,859 608,796 608,723 608,648 583,586 532,535 482,483 420,457 346,457 Z"/>
|
||||
</g>
|
||||
<g id="bullet-char-template-8211" transform="scale(0.00048828125,-0.00048828125)">
|
||||
<path d="M -4,459 L 1135,459 1135,606 -4,606 -4,459 Z"/>
|
||||
</g>
|
||||
<g id="bullet-char-template-61548" transform="scale(0.00048828125,-0.00048828125)">
|
||||
<path d="M 173,740 C 173,903 231,1043 346,1159 462,1274 601,1332 765,1332 928,1332 1067,1274 1183,1159 1299,1043 1357,903 1357,740 1357,577 1299,437 1183,322 1067,206 928,148 765,148 601,148 462,206 346,322 231,437 173,577 173,740 Z"/>
|
||||
</g>
|
||||
</defs>
|
||||
<g>
|
||||
<g id="id2" class="Master_Slide">
|
||||
<g id="bg-id2" class="Background"/>
|
||||
<g id="bo-id2" class="BackgroundObjects"/>
|
||||
</g>
|
||||
</g>
|
||||
<g class="SlideGroup">
|
||||
<g>
|
||||
<g id="container-id1">
|
||||
<g id="id1" class="Slide" clip-path="url(#presentation_clip_path)">
|
||||
<g class="Page">
|
||||
<g class="com.sun.star.drawing.ClosedBezierShape">
|
||||
<g id="id3">
|
||||
<rect class="BoundingBox" stroke="none" fill="none" x="681" y="481" width="12413" height="12571"/>
|
||||
<path fill="rgb(178,178,178)" stroke="none" d="M 3025,482 C 2802,483 2580,504 2361,546 5189,2249 7300,4524 8967,7155 9034,5734 8462,4269 7551,3076 7178,3216 6719,3095 6402,2778 6085,2461 5964,2001 6103,1629 5158,916 4079,477 3025,482 Z M 11216,3076 L 12011,6397 10553,6630 10040,8762 11984,9797 10893,11277 11678,12442 9329,11711 9765,10551 7737,9655 8084,7418 5138,8956 5027,11026 2058,10295 1178,13050 13092,13050 13092,1022 11216,3076 Z M 6921,1567 C 6911,1567 6901,1567 6891,1568 6794,1577 6710,1613 6649,1674 6486,1837 6497,2174 6751,2428 7005,2683 7342,2693 7504,2531 7667,2368 7656,2031 7402,1777 7253,1628 7075,1562 6921,1567 Z M 5212,3389 L 682,7919 C 795,8235 974,8476 1350,8597 L 5886,4061 C 5679,3826 5454,3602 5212,3389 Z M 9412,3696 L 9658,5937 10384,3696 9412,3696 Z M 5920,5680 L 5386,6631 7837,6825 5920,5680 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 5.1 KiB |
@ -32,10 +32,10 @@ http {
|
||||
# timeout which a single keep-alive client connection will stay open
|
||||
keepalive_timeout 69s;
|
||||
# maximum time between packets nginx is allowed to pause when sending the client data
|
||||
send_timeout 10s;
|
||||
send_timeout 69s;
|
||||
|
||||
# number of requests per connection, does not affect SPDY
|
||||
keepalive_requests 100;
|
||||
keepalive_requests 1337;
|
||||
|
||||
# enable gzip compression
|
||||
gzip on;
|
||||
|
@ -1,16 +1,27 @@
|
||||
#!/usr/bin/env zsh
|
||||
hostname=$(hostname)
|
||||
while true
|
||||
do for url in /api/v1/statistics/2h \
|
||||
/api/v1/statistics/24h \
|
||||
/api/v1/statistics/1w \
|
||||
/api/v1/statistics/1m \
|
||||
/api/v1/statistics/3m \
|
||||
/api/v1/statistics/6m \
|
||||
/api/v1/statistics/1y \
|
||||
/api/v1/statistics/2y \
|
||||
/api/v1/statistics/3y \
|
||||
/
|
||||
do for url in / \
|
||||
'/api/v1/statistics/2h' \
|
||||
'/api/v1/statistics/24h' \
|
||||
'/api/v1/statistics/1w' \
|
||||
'/api/v1/statistics/1m' \
|
||||
'/api/v1/statistics/3m' \
|
||||
'/api/v1/statistics/6m' \
|
||||
'/api/v1/statistics/1y' \
|
||||
'/api/v1/statistics/2y' \
|
||||
'/api/v1/statistics/3y' \
|
||||
'/api/v1/mining/pools/24h' \
|
||||
'/api/v1/mining/pools/3d' \
|
||||
'/api/v1/mining/pools/1w' \
|
||||
'/api/v1/mining/pools/1m' \
|
||||
'/api/v1/mining/pools/3m' \
|
||||
'/api/v1/mining/pools/6m' \
|
||||
'/api/v1/mining/pools/1y' \
|
||||
'/api/v1/mining/pools/2y' \
|
||||
'/api/v1/mining/pools/3y' \
|
||||
'/api/v1/mining/pools/all' \
|
||||
|
||||
do
|
||||
curl -s "https://${hostname}${url}" >/dev/null
|
||||
done
|
||||
|
@ -20,10 +20,10 @@ client_header_timeout 10s;
|
||||
# timeout which a single keep-alive client connection will stay open
|
||||
keepalive_timeout 69s;
|
||||
# maximum time between packets nginx is allowed to pause when sending the client data
|
||||
send_timeout 10s;
|
||||
send_timeout 69s;
|
||||
|
||||
# number of requests per connection, does not affect SPDY
|
||||
keepalive_requests 100;
|
||||
keepalive_requests 1337;
|
||||
|
||||
# enable gzip compression
|
||||
gzip on;
|
||||
|
@ -1,6 +1,9 @@
|
||||
location /api/v1/statistics {
|
||||
try_files /dev/null @mempool-api-v1-warmcache;
|
||||
}
|
||||
location /api/v1/mining/pools {
|
||||
try_files /dev/null @mempool-api-v1-warmcache;
|
||||
}
|
||||
location /api/v1 {
|
||||
try_files /dev/null @mempool-api-v1-coldcache;
|
||||
}
|
||||
@ -26,7 +29,6 @@ location @mempool-api-v1-warmcache {
|
||||
proxy_cache api;
|
||||
proxy_cache_valid 200 10s;
|
||||
proxy_redirect off;
|
||||
expires 10s;
|
||||
}
|
||||
|
||||
location @mempool-api-v1-coldcache {
|
||||
|
Loading…
Reference in New Issue
Block a user