Initial code commit.

This commit is contained in:
Simon Lindh 2019-07-21 17:59:47 +03:00
parent 1b2955144a
commit fd2209e75a
113 changed files with 5791 additions and 0 deletions

42
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
# dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
/.sass-cache
/connect.lock
/coverage/*
/libpeerconnection.log
npm-debug.log
testem.log
/typings
# e2e
/e2e/*.js
/e2e/*.map
#System Files
.DS_Store
Thumbs.db
cache.json

View File

@ -0,0 +1,22 @@
{
"ENV": "dev",
"DB_HOST": "localhost",
"DB_PORT": 8889,
"DB_USER": "",
"DB_PASSWORD": "",
"DB_DATABASE": "",
"HTTP_PORT": 3000,
"API_ENDPOINT": "/api/v1/",
"CHAT_SSL_ENABLED": false,
"CHAT_SSL_PRIVKEY": "",
"CHAT_SSL_CERT": "",
"CHAT_SSL_CHAIN": "",
"MEMPOOL_REFRESH_RATE_MS": 500,
"INITIAL_BLOCK_AMOUNT": 8,
"KEEP_BLOCK_AMOUNT": 24,
"BITCOIN_NODE_HOST": "localhost",
"BITCOIN_NODE_PORT": 18332,
"BITCOIN_NODE_USER": "",
"BITCOIN_NODE_PASS": "",
"TX_PER_SECOND_SPAN_SECONDS": 150
}

27
backend/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "mempool-backend",
"version": "1.0.0",
"description": "Bitcoin mempool visualizer",
"main": "index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"bitcoin": "^3.0.1",
"compression": "^1.7.3",
"express": "^4.16.3",
"mysql2": "^1.6.1",
"request": "^2.88.0",
"ws": "^6.0.0"
},
"devDependencies": {
"@types/express": "^4.16.0",
"@types/mysql2": "github:types/mysql2",
"@types/request": "^2.48.2",
"@types/ws": "^6.0.1",
"tslint": "^5.11.0",
"typescript": "^3.1.1"
}
}

View File

@ -0,0 +1,84 @@
const config = require('../../mempool-config.json');
import * as bitcoin from 'bitcoin';
import { ITransaction, IMempoolInfo, IBlock } from '../interfaces';
class BitcoinApi {
client: any;
constructor() {
this.client = new bitcoin.Client({
host: config.BITCOIN_NODE_HOST,
port: config.BITCOIN_NODE_PORT,
user: config.BITCOIN_NODE_USER,
pass: config.BITCOIN_NODE_PASS,
});
}
getMempoolInfo(): Promise<IMempoolInfo> {
return new Promise((resolve, reject) => {
this.client.getMempoolInfo((err: Error, mempoolInfo: any) => {
if (err) {
return reject(err);
}
resolve(mempoolInfo);
});
});
}
getRawMempool(): Promise<ITransaction['txid'][]> {
return new Promise((resolve, reject) => {
this.client.getRawMemPool((err: Error, transactions: ITransaction['txid'][]) => {
if (err) {
return reject(err);
}
resolve(transactions);
});
});
}
getRawTransaction(txId: string): Promise<ITransaction> {
return new Promise((resolve, reject) => {
this.client.getRawTransaction(txId, true, (err: Error, txData: ITransaction) => {
if (err) {
return reject(err);
}
resolve(txData);
});
});
}
getBlockCount(): Promise<number> {
return new Promise((resolve, reject) => {
this.client.getBlockCount((err: Error, response: number) => {
if (err) {
return reject(err);
}
resolve(response);
});
});
}
getBlock(hash: string, verbosity: 1 | 2 = 1): Promise<IBlock> {
return new Promise((resolve, reject) => {
this.client.getBlock(hash, verbosity, (err: Error, block: IBlock) => {
if (err) {
return reject(err);
}
resolve(block);
});
});
}
getBlockHash(height: number): Promise<string> {
return new Promise((resolve, reject) => {
this.client.getBlockHash(height, (err: Error, response: string) => {
if (err) {
return reject(err);
}
resolve(response);
});
});
}
}
export default new BitcoinApi();

197
backend/src/api/blocks.ts Normal file
View File

@ -0,0 +1,197 @@
const config = require('../../mempool-config.json');
import bitcoinApi from './bitcoin-api-wrapper';
import { DB } from '../database';
import { IBlock, ITransaction } from '../interfaces';
import memPool from './mempool';
class Blocks {
private blocks: IBlock[] = [];
private newBlockCallback: Function | undefined;
public setNewBlockCallback(fn: Function) {
this.newBlockCallback = fn;
}
public getBlocks(): IBlock[] {
return this.blocks;
}
public formatBlock(block: IBlock) {
return {
hash: block.hash,
height: block.height,
nTx: block.nTx - 1,
size: block.size,
time: block.time,
weight: block.weight,
fees: block.fees,
minFee: block.minFee,
maxFee: block.maxFee,
medianFee: block.medianFee,
};
}
public async updateBlocks() {
try {
const blockCount = await bitcoinApi.getBlockCount();
let currentBlockHeight = 0;
if (this.blocks.length === 0) {
currentBlockHeight = blockCount - config.INITIAL_BLOCK_AMOUNT;
} else {
currentBlockHeight = this.blocks[this.blocks.length - 1].height;
}
while (currentBlockHeight < blockCount) {
currentBlockHeight++;
let block: IBlock | undefined;
const storedBlock = await this.$getBlockFromDatabase(currentBlockHeight);
if (storedBlock) {
block = storedBlock;
} else {
const blockHash = await bitcoinApi.getBlockHash(currentBlockHeight);
block = await bitcoinApi.getBlock(blockHash, 1);
const coinbase = await memPool.getRawTransaction(block.tx[0], true);
if (coinbase && coinbase.totalOut) {
block.fees = coinbase.totalOut;
}
const mempool = memPool.getMempool();
let found = 0;
let notFound = 0;
let transactions: ITransaction[] = [];
for (let i = 1; i < block.tx.length; i++) {
if (mempool[block.tx[i]]) {
transactions.push(mempool[block.tx[i]]);
found++;
} else {
const tx = await memPool.getRawTransaction(block.tx[i]);
if (tx) {
transactions.push(tx);
}
notFound++;
}
}
transactions.sort((a, b) => b.feePerVsize - a.feePerVsize);
transactions = transactions.filter((tx: ITransaction) => tx.feePerVsize);
block.minFee = transactions[transactions.length - 1] ? transactions[transactions.length - 1].feePerVsize : 0;
block.maxFee = transactions[0] ? transactions[0].feePerVsize : 0;
block.medianFee = this.median(transactions.map((tx) => tx.feePerVsize));
if (this.newBlockCallback) {
this.newBlockCallback(block);
}
await this.$saveBlockToDatabase(block);
await this.$saveTransactionsToDatabase(block.height, transactions);
console.log(`New block found (#${currentBlockHeight})! ${found} of ${block.tx.length} found in mempool. ${notFound} not found.`);
}
this.blocks.push(block);
if (this.blocks.length > config.KEEP_BLOCK_AMOUNT) {
this.blocks.shift();
}
}
} catch (err) {
console.log('Error getBlockCount', err);
}
}
private async $getBlockFromDatabase(height: number): Promise<IBlock | undefined> {
try {
const connection = await DB.pool.getConnection();
const query = `
SELECT * FROM blocks WHERE height = ?
`;
const [rows] = await connection.query<any>(query, [height]);
connection.release();
if (rows[0]) {
return rows[0];
}
} catch (e) {
console.log('$get() block error', e);
}
}
private async $saveBlockToDatabase(block: IBlock) {
try {
const connection = await DB.pool.getConnection();
const query = `
INSERT IGNORE INTO blocks
(height, hash, size, weight, minFee, maxFee, time, fees, nTx, medianFee)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const params: (any)[] = [
block.height,
block.hash,
block.size,
block.weight,
block.minFee,
block.maxFee,
block.time,
block.fees,
block.nTx - 1,
block.medianFee,
];
await connection.query(query, params);
connection.release();
} catch (e) {
console.log('$create() block error', e);
}
}
private async $saveTransactionsToDatabase(blockheight: number, transactions: ITransaction[]) {
try {
const connection = await DB.pool.getConnection();
for (let i = 0; i < transactions.length; i++) {
const query = `
INSERT IGNORE INTO transactions
(blockheight, txid, fee, feePerVsize)
VALUES(?, ?, ?, ?)
`;
const params: (any)[] = [
blockheight,
transactions[i].txid,
transactions[i].fee,
transactions[i].feePerVsize,
];
await connection.query(query, params);
}
connection.release();
} catch (e) {
console.log('$create() transaction error', e);
}
}
private median(numbers: number[]) {
if (!numbers.length) { return 0; }
let medianNr = 0;
const numsLen = numbers.length;
numbers.sort();
if (numsLen % 2 === 0) {
medianNr = (numbers[numsLen / 2 - 1] + numbers[numsLen / 2]) / 2;
} else {
medianNr = numbers[(numsLen - 1) / 2];
}
return medianNr;
}
}
export default new Blocks();

View File

@ -0,0 +1,16 @@
import * as fs from 'fs';
class DiskCache {
static FILE_NAME = './cache.json';
constructor() { }
saveData(dataBlob: string) {
fs.writeFileSync(DiskCache.FILE_NAME, dataBlob, 'utf8');
}
loadData(): string {
return fs.readFileSync(DiskCache.FILE_NAME, 'utf8');
}
}
export default new DiskCache();

View File

@ -0,0 +1,47 @@
import projectedBlocks from './projected-blocks';
import { DB } from '../database';
class FeeApi {
constructor() { }
public getRecommendedFee() {
const pBlocks = projectedBlocks.getProjectedBlocks();
if (!pBlocks.length) {
return {
'fastestFee': 0,
'halfHourFee': 0,
'hourFee': 0,
};
}
let firstMedianFee = Math.ceil(pBlocks[0].medianFee);
if (pBlocks.length === 1 && pBlocks[0].blockWeight <= 2000000) {
firstMedianFee = 1;
}
const secondMedianFee = pBlocks[1] ? Math.ceil(pBlocks[1].medianFee) : firstMedianFee;
const thirdMedianFee = pBlocks[2] ? Math.ceil(pBlocks[2].medianFee) : secondMedianFee;
return {
'fastestFee': firstMedianFee,
'halfHourFee': secondMedianFee,
'hourFee': thirdMedianFee,
};
}
public async $getTransactionsForBlock(blockHeight: number): Promise<any[]> {
try {
const connection = await DB.pool.getConnection();
const query = `SELECT feePerVsize AS fpv FROM transactions WHERE blockheight = ? ORDER BY feePerVsize ASC`;
const [rows] = await connection.query<any>(query, [blockHeight]);
connection.release();
return rows;
} catch (e) {
console.log('$getTransactionsForBlock() error', e);
return [];
}
}
}
export default new FeeApi();

View File

@ -0,0 +1,31 @@
import * as request from 'request';
class FiatConversion {
private tickers = {
'BTCUSD': {
'USD': 4110.78
},
};
constructor() { }
public startService() {
setInterval(this.updateCurrency.bind(this), 1000 * 60 * 60);
this.updateCurrency();
}
public getTickers() {
return this.tickers;
}
private updateCurrency() {
request('https://api.opennode.co/v1/rates', { json: true }, (err, res, body) => {
if (err) { return console.log(err); }
if (body && body.data) {
this.tickers = body.data;
}
});
}
}
export default new FiatConversion();

156
backend/src/api/mempool.ts Normal file
View File

@ -0,0 +1,156 @@
const config = require('../../mempool-config.json');
import bitcoinApi from './bitcoin-api-wrapper';
import { ITransaction, IMempoolInfo, IMempool } from '../interfaces';
class Mempool {
private mempool: IMempool = {};
private mempoolInfo: IMempoolInfo | undefined;
private mempoolChangedCallback: Function | undefined;
private txPerSecondArray: number[] = [];
private txPerSecond: number = 0;
private vBytesPerSecondArray: any[] = [];
private vBytesPerSecond: number = 0;
constructor() {
setInterval(this.updateTxPerSecond.bind(this), 1000);
}
public setMempoolChangedCallback(fn: Function) {
this.mempoolChangedCallback = fn;
}
public getMempool(): { [txid: string]: ITransaction } {
return this.mempool;
}
public setMempool(mempoolData: any) {
this.mempool = mempoolData;
}
public getMempoolInfo(): IMempoolInfo | undefined {
return this.mempoolInfo;
}
public getTxPerSecond(): number {
return this.txPerSecond;
}
public getVBytesPerSecond(): number {
return this.vBytesPerSecond;
}
public async getMemPoolInfo() {
try {
this.mempoolInfo = await bitcoinApi.getMempoolInfo();
} catch (err) {
console.log('Error getMempoolInfo', err);
}
}
public async getRawTransaction(txId: string, isCoinbase = false): Promise<ITransaction | false> {
try {
const transaction = await bitcoinApi.getRawTransaction(txId);
let totalIn = 0;
if (!isCoinbase) {
for (let i = 0; i < transaction.vin.length; i++) {
try {
const result = await bitcoinApi.getRawTransaction(transaction.vin[i].txid);
transaction.vin[i]['value'] = result.vout[transaction.vin[i].vout].value;
totalIn += result.vout[transaction.vin[i].vout].value;
} catch (err) {
console.log('Locating historical tx error');
}
}
}
let totalOut = 0;
transaction.vout.forEach((output) => totalOut += output.value);
if (totalIn > totalOut) {
transaction.fee = parseFloat((totalIn - totalOut).toFixed(8));
transaction.feePerWeightUnit = (transaction.fee * 100000000) / (transaction.vsize * 4) || 0;
transaction.feePerVsize = (transaction.fee * 100000000) / (transaction.vsize) || 0;
} else if (!isCoinbase) {
transaction.fee = 0;
transaction.feePerVsize = 0;
transaction.feePerWeightUnit = 0;
console.log('Minus fee error!');
}
transaction.totalOut = totalOut;
return transaction;
} catch (e) {
console.log(txId + ' not found');
return false;
}
}
public async updateMempool() {
console.log('Updating mempool');
const start = new Date().getTime();
let hasChange: boolean = false;
let txCount = 0;
try {
const transactions = await bitcoinApi.getRawMempool();
const diff = transactions.length - Object.keys(this.mempool).length;
for (const tx of transactions) {
if (!this.mempool[tx]) {
const transaction = await this.getRawTransaction(tx);
if (transaction) {
this.mempool[tx] = transaction;
txCount++;
this.txPerSecondArray.push(new Date().getTime());
this.vBytesPerSecondArray.push({
unixTime: new Date().getTime(),
vSize: transaction.vsize,
});
hasChange = true;
if (diff > 0) {
console.log('Calculated fee for transaction ' + txCount + ' / ' + diff);
} else {
console.log('Calculated fee for transaction ' + txCount);
}
} else {
console.log('Error finding transaction in mempool.');
}
}
}
const newMempool: IMempool = {};
transactions.forEach((tx) => {
if (this.mempool[tx]) {
newMempool[tx] = this.mempool[tx];
} else {
hasChange = true;
}
});
this.mempool = newMempool;
if (hasChange && this.mempoolChangedCallback) {
this.mempoolChangedCallback(this.mempool);
}
const end = new Date().getTime();
const time = end - start;
console.log('Mempool updated in ' + time / 1000 + ' seconds');
} catch (err) {
console.log('getRawMempool error.', err);
}
}
private updateTxPerSecond() {
const nowMinusTimeSpan = new Date().getTime() - (1000 * config.TX_PER_SECOND_SPAN_SECONDS);
this.txPerSecondArray = this.txPerSecondArray.filter((unixTime) => unixTime > nowMinusTimeSpan);
this.txPerSecond = this.txPerSecondArray.length / config.TX_PER_SECOND_SPAN_SECONDS || 0;
this.vBytesPerSecondArray = this.vBytesPerSecondArray.filter((data) => data.unixTime > nowMinusTimeSpan);
if (this.vBytesPerSecondArray.length) {
this.vBytesPerSecond = Math.round(
this.vBytesPerSecondArray.map((data) => data.vSize).reduce((a, b) => a + b) / config.TX_PER_SECOND_SPAN_SECONDS
);
}
}
}
export default new Mempool();

View File

@ -0,0 +1,104 @@
import { ITransaction, IProjectedBlock, IMempool, IProjectedBlockInternal } from '../interfaces';
class ProjectedBlocks {
private projectedBlocks: IProjectedBlockInternal[] = [];
constructor() {}
public getProjectedBlocks(txId?: string): IProjectedBlock[] {
return this.projectedBlocks.map((projectedBlock) => {
return {
blockSize: projectedBlock.blockSize,
blockWeight: projectedBlock.blockWeight,
nTx: projectedBlock.nTx,
minFee: projectedBlock.minFee,
maxFee: projectedBlock.maxFee,
minWeightFee: projectedBlock.minWeightFee,
maxWeightFee: projectedBlock.maxWeightFee,
medianFee: projectedBlock.medianFee,
fees: projectedBlock.fees,
hasMytx: txId ? projectedBlock.txIds.some((tx) => tx === txId) : false
};
});
}
public getProjectedBlockFeesForBlock(index: number) {
const projectedBlock = this.projectedBlocks[index];
if (!projectedBlock) {
throw new Error('No projected block for that index');
}
return projectedBlock.txFeePerVsizes.map((fpv) => {
return {'fpv': fpv};
});
}
public updateProjectedBlocks(memPool: IMempool): void {
const latestMempool = memPool;
const memPoolArray: ITransaction[] = [];
for (const i in latestMempool) {
if (latestMempool.hasOwnProperty(i)) {
memPoolArray.push(latestMempool[i]);
}
}
if (!memPoolArray.length) {
this.projectedBlocks = [];
}
memPoolArray.sort((a, b) => b.feePerWeightUnit - a.feePerWeightUnit);
const memPoolArrayFiltered = memPoolArray.filter((tx) => tx.feePerWeightUnit);
const projectedBlocks: any = [];
let blockWeight = 0;
let blockSize = 0;
let transactions: ITransaction[] = [];
memPoolArrayFiltered.forEach((tx) => {
if (blockWeight + tx.vsize * 4 < 4000000 || projectedBlocks.length === 3) {
blockWeight += tx.vsize * 4;
blockSize += tx.size;
transactions.push(tx);
} else {
projectedBlocks.push(this.dataToProjectedBlock(transactions, blockSize, blockWeight));
blockWeight = 0;
blockSize = 0;
transactions = [];
}
});
if (transactions.length) {
projectedBlocks.push(this.dataToProjectedBlock(transactions, blockSize, blockWeight));
}
this.projectedBlocks = projectedBlocks;
}
private dataToProjectedBlock(transactions: ITransaction[], blockSize: number, blockWeight: number): IProjectedBlockInternal {
return {
blockSize: blockSize,
blockWeight: blockWeight,
nTx: transactions.length - 1,
minFee: transactions[transactions.length - 1].feePerVsize,
maxFee: transactions[0].feePerVsize,
minWeightFee: transactions[transactions.length - 1].feePerWeightUnit,
maxWeightFee: transactions[0].feePerWeightUnit,
medianFee: this.median(transactions.map((tx) => tx.feePerVsize)),
txIds: transactions.map((tx) => tx.txid),
txFeePerVsizes: transactions.map((tx) => tx.feePerVsize).reverse(),
fees: transactions.map((tx) => tx.fee).reduce((acc, currValue) => acc + currValue),
};
}
private median(numbers: number[]) {
let medianNr = 0;
const numsLen = numbers.length;
numbers.sort();
if (numsLen % 2 === 0) {
medianNr = (numbers[numsLen / 2 - 1] + numbers[numsLen / 2]) / 2;
} else {
medianNr = numbers[(numsLen - 1) / 2];
}
return medianNr;
}
}
export default new ProjectedBlocks();

View File

@ -0,0 +1,379 @@
import memPool from './mempool';
import { DB } from '../database';
import { ITransaction, IMempoolStats } from '../interfaces';
class Statistics {
protected intervalTimer: NodeJS.Timer | undefined;
constructor() {
}
public startStatistics(): void {
const now = new Date();
const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(),
Math.floor(now.getMinutes() / 1) * 1 + 1, 0, 0);
const difference = nextInterval.getTime() - now.getTime();
setTimeout(() => {
this.runStatistics();
this.intervalTimer = setInterval(() => { this.runStatistics(); }, 1 * 60 * 1000);
}, difference);
}
private runStatistics(): void {
const currentMempool = memPool.getMempool();
const txPerSecond = memPool.getTxPerSecond();
const vBytesPerSecond = memPool.getVBytesPerSecond();
if (txPerSecond === 0) {
return;
}
console.log('Running statistics');
let memPoolArray: ITransaction[] = [];
for (const i in currentMempool) {
if (currentMempool.hasOwnProperty(i)) {
memPoolArray.push(currentMempool[i]);
}
}
// Remove 0 and undefined
memPoolArray = memPoolArray.filter((tx) => tx.feePerWeightUnit);
if (!memPoolArray.length) {
return;
}
memPoolArray.sort((a, b) => a.feePerWeightUnit - b.feePerWeightUnit);
const totalWeight = memPoolArray.map((tx) => tx.vsize).reduce((acc, curr) => acc + curr) * 4;
const totalFee = memPoolArray.map((tx) => tx.fee).reduce((acc, curr) => acc + curr);
const logFees = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200,
250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000];
const weightUnitFees: { [feePerWU: number]: number } = {};
const weightVsizeFees: { [feePerWU: number]: number } = {};
memPoolArray.forEach((transaction) => {
for (let i = 0; i < logFees.length; i++) {
if ((logFees[i] === 2000 && transaction.feePerWeightUnit >= 2000) || transaction.feePerWeightUnit <= logFees[i]) {
if (weightUnitFees[logFees[i]]) {
weightUnitFees[logFees[i]] += transaction.vsize * 4;
} else {
weightUnitFees[logFees[i]] = transaction.vsize * 4;
}
break;
}
}
});
memPoolArray.forEach((transaction) => {
for (let i = 0; i < logFees.length; i++) {
if ((logFees[i] === 2000 && transaction.feePerVsize >= 2000) || transaction.feePerVsize <= logFees[i]) {
if (weightVsizeFees[logFees[i]]) {
weightVsizeFees[logFees[i]] += transaction.vsize;
} else {
weightVsizeFees[logFees[i]] = transaction.vsize;
}
break;
}
}
});
this.$create({
added: 'NOW()',
unconfirmed_transactions: memPoolArray.length,
tx_per_second: txPerSecond,
vbytes_per_second: Math.round(vBytesPerSecond),
mempool_byte_weight: totalWeight,
total_fee: totalFee,
fee_data: JSON.stringify({
'wu': weightUnitFees,
'vsize': weightVsizeFees
}),
vsize_1: weightVsizeFees['1'] || 0,
vsize_2: weightVsizeFees['2'] || 0,
vsize_3: weightVsizeFees['3'] || 0,
vsize_4: weightVsizeFees['4'] || 0,
vsize_5: weightVsizeFees['5'] || 0,
vsize_6: weightVsizeFees['6'] || 0,
vsize_8: weightVsizeFees['8'] || 0,
vsize_10: weightVsizeFees['10'] || 0,
vsize_12: weightVsizeFees['12'] || 0,
vsize_15: weightVsizeFees['15'] || 0,
vsize_20: weightVsizeFees['20'] || 0,
vsize_30: weightVsizeFees['30'] || 0,
vsize_40: weightVsizeFees['40'] || 0,
vsize_50: weightVsizeFees['50'] || 0,
vsize_60: weightVsizeFees['60'] || 0,
vsize_70: weightVsizeFees['70'] || 0,
vsize_80: weightVsizeFees['80'] || 0,
vsize_90: weightVsizeFees['90'] || 0,
vsize_100: weightVsizeFees['100'] || 0,
vsize_125: weightVsizeFees['125'] || 0,
vsize_150: weightVsizeFees['150'] || 0,
vsize_175: weightVsizeFees['175'] || 0,
vsize_200: weightVsizeFees['200'] || 0,
vsize_250: weightVsizeFees['250'] || 0,
vsize_300: weightVsizeFees['300'] || 0,
vsize_350: weightVsizeFees['350'] || 0,
vsize_400: weightVsizeFees['400'] || 0,
vsize_500: weightVsizeFees['500'] || 0,
vsize_600: weightVsizeFees['600'] || 0,
vsize_700: weightVsizeFees['700'] || 0,
vsize_800: weightVsizeFees['800'] || 0,
vsize_900: weightVsizeFees['900'] || 0,
vsize_1000: weightVsizeFees['1000'] || 0,
vsize_1200: weightVsizeFees['1200'] || 0,
vsize_1400: weightVsizeFees['1400'] || 0,
vsize_1600: weightVsizeFees['1600'] || 0,
vsize_1800: weightVsizeFees['1800'] || 0,
vsize_2000: weightVsizeFees['2000'] || 0,
});
}
private async $create(statistics: IMempoolStats): Promise<void> {
try {
const connection = await DB.pool.getConnection();
const query = `INSERT INTO statistics(
added,
unconfirmed_transactions,
tx_per_second,
vbytes_per_second,
mempool_byte_weight,
fee_data,
total_fee,
vsize_1,
vsize_2,
vsize_3,
vsize_4,
vsize_5,
vsize_6,
vsize_8,
vsize_10,
vsize_12,
vsize_15,
vsize_20,
vsize_30,
vsize_40,
vsize_50,
vsize_60,
vsize_70,
vsize_80,
vsize_90,
vsize_100,
vsize_125,
vsize_150,
vsize_175,
vsize_200,
vsize_250,
vsize_300,
vsize_350,
vsize_400,
vsize_500,
vsize_600,
vsize_700,
vsize_800,
vsize_900,
vsize_1000,
vsize_1200,
vsize_1400,
vsize_1600,
vsize_1800,
vsize_2000
)
VALUES (${statistics.added}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
const params: (string | number)[] = [
statistics.unconfirmed_transactions,
statistics.tx_per_second,
statistics.vbytes_per_second,
statistics.mempool_byte_weight,
statistics.fee_data,
statistics.total_fee,
statistics.vsize_1,
statistics.vsize_2,
statistics.vsize_3,
statistics.vsize_4,
statistics.vsize_5,
statistics.vsize_6,
statistics.vsize_8,
statistics.vsize_10,
statistics.vsize_12,
statistics.vsize_15,
statistics.vsize_20,
statistics.vsize_30,
statistics.vsize_40,
statistics.vsize_50,
statistics.vsize_60,
statistics.vsize_70,
statistics.vsize_80,
statistics.vsize_90,
statistics.vsize_100,
statistics.vsize_125,
statistics.vsize_150,
statistics.vsize_175,
statistics.vsize_200,
statistics.vsize_250,
statistics.vsize_300,
statistics.vsize_350,
statistics.vsize_400,
statistics.vsize_500,
statistics.vsize_600,
statistics.vsize_700,
statistics.vsize_800,
statistics.vsize_900,
statistics.vsize_1000,
statistics.vsize_1200,
statistics.vsize_1400,
statistics.vsize_1600,
statistics.vsize_1800,
statistics.vsize_2000,
];
await connection.query(query, params);
connection.release();
} catch (e) {
console.log('$create() error', e);
}
}
public async $listLatestFromId(fromId: number): Promise<IMempoolStats[]> {
try {
const connection = await DB.pool.getConnection();
const query = `SELECT * FROM statistics WHERE id > ? ORDER BY id DESC`;
const [rows] = await connection.query<any>(query, [fromId]);
connection.release();
return rows;
} catch (e) {
console.log('$listLatestFromId() error', e);
return [];
}
}
private getQueryForDays(days: number, groupBy: number) {
return `SELECT id, added, unconfirmed_transactions,
AVG(tx_per_second) AS tx_per_second,
AVG(vbytes_per_second) AS vbytes_per_second,
AVG(vsize_1) AS vsize_1,
AVG(vsize_2) AS vsize_2,
AVG(vsize_3) AS vsize_3,
AVG(vsize_4) AS vsize_4,
AVG(vsize_5) AS vsize_5,
AVG(vsize_6) AS vsize_6,
AVG(vsize_8) AS vsize_8,
AVG(vsize_10) AS vsize_10,
AVG(vsize_12) AS vsize_12,
AVG(vsize_15) AS vsize_15,
AVG(vsize_20) AS vsize_20,
AVG(vsize_30) AS vsize_30,
AVG(vsize_40) AS vsize_40,
AVG(vsize_50) AS vsize_50,
AVG(vsize_60) AS vsize_60,
AVG(vsize_70) AS vsize_70,
AVG(vsize_80) AS vsize_80,
AVG(vsize_90) AS vsize_90,
AVG(vsize_100) AS vsize_100,
AVG(vsize_125) AS vsize_125,
AVG(vsize_150) AS vsize_150,
AVG(vsize_175) AS vsize_175,
AVG(vsize_200) AS vsize_200,
AVG(vsize_250) AS vsize_250,
AVG(vsize_300) AS vsize_300,
AVG(vsize_350) AS vsize_350,
AVG(vsize_400) AS vsize_400,
AVG(vsize_500) AS vsize_500,
AVG(vsize_600) AS vsize_600,
AVG(vsize_700) AS vsize_700,
AVG(vsize_800) AS vsize_800,
AVG(vsize_900) AS vsize_900,
AVG(vsize_1000) AS vsize_1000,
AVG(vsize_1200) AS vsize_1200,
AVG(vsize_1400) AS vsize_1400,
AVG(vsize_1600) AS vsize_1600,
AVG(vsize_1800) AS vsize_1800,
AVG(vsize_2000) AS vsize_2000 FROM statistics GROUP BY UNIX_TIMESTAMP(added) DIV ${groupBy} ORDER BY id DESC LIMIT ${days}`;
}
public async $list2H(): Promise<IMempoolStats[]> {
try {
const connection = await DB.pool.getConnection();
const query = `SELECT * FROM statistics ORDER BY id DESC LIMIT 120`;
const [rows] = await connection.query<any>(query);
connection.release();
return rows;
} catch (e) {
console.log('$list2H() error', e);
return [];
}
}
public async $list24H(): Promise<IMempoolStats[]> {
try {
const connection = await DB.pool.getConnection();
const query = this.getQueryForDays(120, 720);
const [rows] = await connection.query<any>(query);
connection.release();
return rows;
} catch (e) {
return [];
}
}
public async $list1W(): Promise<IMempoolStats[]> {
try {
const connection = await DB.pool.getConnection();
const query = this.getQueryForDays(120, 5040);
const [rows] = await connection.query<any>(query);
connection.release();
return rows;
} catch (e) {
console.log('$list1W() error', e);
return [];
}
}
public async $list1M(): Promise<IMempoolStats[]> {
try {
const connection = await DB.pool.getConnection();
const query = this.getQueryForDays(120, 20160);
const [rows] = await connection.query<any>(query);
connection.release();
return rows;
} catch (e) {
console.log('$list1M() error', e);
return [];
}
}
public async $list3M(): Promise<IMempoolStats[]> {
try {
const connection = await DB.pool.getConnection();
const query = this.getQueryForDays(120, 60480);
const [rows] = await connection.query<any>(query);
connection.release();
return rows;
} catch (e) {
console.log('$list3M() error', e);
return [];
}
}
public async $list6M(): Promise<IMempoolStats[]> {
try {
const connection = await DB.pool.getConnection();
const query = this.getQueryForDays(120, 120960);
const [rows] = await connection.query<any>(query);
connection.release();
return rows;
} catch (e) {
console.log('$list6M() error', e);
return [];
}
}
}
export default new Statistics();

26
backend/src/database.ts Normal file
View File

@ -0,0 +1,26 @@
const config = require('../mempool-config.json');
import { createPool } from 'mysql2/promise';
export class DB {
static pool = createPool({
host: config.DB_HOST,
port: config.DB_PORT,
database: config.DB_DATABASE,
user: config.DB_USER,
password: config.DB_PASSWORD,
connectionLimit: 10,
supportBigNumbers: true,
});
}
export async function checkDbConnection() {
try {
const connection = await DB.pool.getConnection();
console.log('MySQL connection established.');
connection.release();
} catch (e) {
console.log('Could not connect to MySQL.');
console.log(e);
process.exit(1);
}
}

231
backend/src/index.ts Normal file
View File

@ -0,0 +1,231 @@
const config = require('../mempool-config.json');
import * as fs from 'fs';
import * as express from 'express';
import * as compression from 'compression';
import * as http from 'http';
import * as https from 'https';
import * as WebSocket from 'ws';
import bitcoinApi from './api/bitcoin-api-wrapper';
import diskCache from './api/disk-cache';
import memPool from './api/mempool';
import blocks from './api/blocks';
import projectedBlocks from './api/projected-blocks';
import statistics from './api/statistics';
import { IBlock, IMempool } from './interfaces';
import routes from './routes';
import fiatConversion from './api/fiat-conversion';
class MempoolSpace {
private wss: WebSocket.Server;
private server: https.Server | http.Server;
private app: any;
constructor() {
this.app = express();
this.app
.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
next();
})
.use(compression());
if (config.ENV === 'dev') {
this.server = http.createServer(this.app);
this.wss = new WebSocket.Server({ server: this.server });
} else {
const credentials = {
cert: fs.readFileSync('/etc/letsencrypt/live/mempool.space/fullchain.pem'),
key: fs.readFileSync('/etc/letsencrypt/live/mempool.space/privkey.pem'),
};
this.server = https.createServer(credentials, this.app);
this.wss = new WebSocket.Server({ server: this.server });
}
this.setUpRoutes();
this.setUpWebsocketHandling();
this.setUpMempoolCache();
this.runMempoolIntervalFunctions();
statistics.startStatistics();
fiatConversion.startService();
this.server.listen(8999, () => {
console.log(`Server started on port 8999 :)`);
});
}
private async runMempoolIntervalFunctions() {
await blocks.updateBlocks();
await memPool.getMemPoolInfo();
await memPool.updateMempool();
setTimeout(this.runMempoolIntervalFunctions.bind(this), config.MEMPOOL_REFRESH_RATE_MS);
}
private setUpMempoolCache() {
const cacheData = diskCache.loadData();
if (cacheData) {
memPool.setMempool(JSON.parse(cacheData));
}
process.on('SIGINT', (options) => {
console.log('SIGINT');
diskCache.saveData(JSON.stringify(memPool.getMempool()));
process.exit(2);
});
}
private setUpWebsocketHandling() {
this.wss.on('connection', (client: WebSocket) => {
let theBlocks = blocks.getBlocks();
theBlocks = theBlocks.concat([]).splice(theBlocks.length - config.INITIAL_BLOCK_AMOUNT);
const formatedBlocks = theBlocks.map((b) => blocks.formatBlock(b));
client.send(JSON.stringify({
'mempoolInfo': memPool.getMempoolInfo(),
'blocks': formatedBlocks,
'projectedBlocks': projectedBlocks.getProjectedBlocks(),
'txPerSecond': memPool.getTxPerSecond(),
'vBytesPerSecond': memPool.getVBytesPerSecond(),
'conversions': fiatConversion.getTickers()['BTCUSD'],
}));
client.on('message', async (message: any) => {
try {
const parsedMessage = JSON.parse(message);
if (parsedMessage.action === 'track-tx' && parsedMessage.txId && /^[a-fA-F0-9]{64}$/.test(parsedMessage.txId)) {
const tx = await memPool.getRawTransaction(parsedMessage.txId);
if (tx) {
console.log('Now tracking: ' + parsedMessage.txId);
client['trackingTx'] = true;
client['txId'] = parsedMessage.txId;
client['tx'] = tx;
if (tx.blockhash) {
const currentBlocks = blocks.getBlocks();
const foundBlock = currentBlocks.find((block) => block.tx && block.tx.some((i: string) => i === parsedMessage.txId));
if (foundBlock) {
console.log('Found block by looking in local cache');
client['blockHeight'] = foundBlock.height;
} else {
const theBlock = await bitcoinApi.getBlock(tx.blockhash);
if (theBlock) {
client['blockHeight'] = theBlock.height;
}
}
} else {
client['blockHeight'] = 0;
}
client.send(JSON.stringify({
'projectedBlocks': projectedBlocks.getProjectedBlocks(client['txId']),
'track-tx': {
tracking: true,
blockHeight: client['blockHeight'],
tx: client['tx'],
}
}));
} else {
console.log('TX NOT FOUND, NOT TRACKING');
client.send(JSON.stringify({
'track-tx': {
tracking: false,
blockHeight: 0,
message: 'not-found',
}
}));
}
}
if (parsedMessage.action === 'stop-tracking-tx') {
console.log('STOP TRACKING');
client['trackingTx'] = false;
client.send(JSON.stringify({
'track-tx': {
tracking: false,
blockHeight: 0,
message: 'not-found',
}
}));
}
} catch (e) {
console.log(e);
}
});
client.on('close', () => {
client['trackingTx'] = false;
});
});
blocks.setNewBlockCallback((block: IBlock) => {
const formattedBlocks = blocks.formatBlock(block);
this.wss.clients.forEach((client) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
if (client['trackingTx'] === true && client['blockHeight'] === 0) {
if (block.tx.some((tx) => tx === client['txId'])) {
client['blockHeight'] = block.height;
}
}
client.send(JSON.stringify({
'block': formattedBlocks,
'track-tx': {
tracking: client['trackingTx'] || false,
blockHeight: client['blockHeight'],
}
}));
});
});
memPool.setMempoolChangedCallback((newMempool: IMempool) => {
projectedBlocks.updateProjectedBlocks(newMempool);
let pBlocks = projectedBlocks.getProjectedBlocks();
const mempoolInfo = memPool.getMempoolInfo();
const txPerSecond = memPool.getTxPerSecond();
const vBytesPerSecond = memPool.getVBytesPerSecond();
this.wss.clients.forEach((client: WebSocket) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
if (client['trackingTx'] && client['blockHeight'] === 0) {
pBlocks = projectedBlocks.getProjectedBlocks(client['txId']);
}
client.send(JSON.stringify({
'projectedBlocks': pBlocks,
'mempoolInfo': mempoolInfo,
'txPerSecond': txPerSecond,
'vBytesPerSecond': vBytesPerSecond,
'track-tx': {
tracking: client['trackingTx'] || false,
blockHeight: client['blockHeight'],
}
}));
});
});
}
private setUpRoutes() {
this.app
.get(config.API_ENDPOINT + 'transactions/height/:id', routes.$getgetTransactionsForBlock)
.get(config.API_ENDPOINT + 'transactions/projected/:id', routes.getgetTransactionsForProjectedBlock)
.get(config.API_ENDPOINT + 'fees/recommended', routes.getRecommendedFees)
.get(config.API_ENDPOINT + 'statistics/live', routes.getLiveResult)
.get(config.API_ENDPOINT + 'statistics/2h', routes.get2HStatistics)
.get(config.API_ENDPOINT + 'statistics/24h', routes.get24HStatistics)
.get(config.API_ENDPOINT + 'statistics/1w', routes.get1WHStatistics)
.get(config.API_ENDPOINT + 'statistics/1m', routes.get1MStatistics)
.get(config.API_ENDPOINT + 'statistics/3m', routes.get3MStatistics)
.get(config.API_ENDPOINT + 'statistics/6m', routes.get6MStatistics)
;
}
}
const mempoolSpace = new MempoolSpace();

151
backend/src/interfaces.ts Normal file
View File

@ -0,0 +1,151 @@
export interface IMempoolInfo {
size: number;
bytes: number;
usage: number;
maxmempool: number;
mempoolminfee: number;
minrelaytxfee: number;
}
export interface ITransaction {
txid: string;
hash: string;
version: number;
size: number;
vsize: number;
locktime: number;
vin: Vin[];
vout: Vout[];
hex: string;
fee: number;
feePerWeightUnit: number;
feePerVsize: number;
blockhash?: string;
confirmations?: number;
time?: number;
blocktime?: number;
totalOut?: number;
}
export interface IBlock {
hash: string;
confirmations: number;
strippedsize: number;
size: number;
weight: number;
height: number;
version: number;
versionHex: string;
merkleroot: string;
tx: any;
time: number;
mediantime: number;
nonce: number;
bits: string;
difficulty: number;
chainwork: string;
nTx: number;
previousblockhash: string;
fees: number;
minFee?: number;
maxFee?: number;
medianFee?: number;
}
interface ScriptSig {
asm: string;
hex: string;
}
interface Vin {
txid: string;
vout: number;
scriptSig: ScriptSig;
sequence: number;
}
interface ScriptPubKey {
asm: string;
hex: string;
reqSigs: number;
type: string;
addresses: string[];
}
interface Vout {
value: number;
n: number;
scriptPubKey: ScriptPubKey;
}
export interface IMempoolStats {
id?: number;
added: string;
unconfirmed_transactions: number;
tx_per_second: number;
vbytes_per_second: number;
total_fee: number;
mempool_byte_weight: number;
fee_data: string;
vsize_1: number;
vsize_2: number;
vsize_3: number;
vsize_4: number;
vsize_5: number;
vsize_6: number;
vsize_8: number;
vsize_10: number;
vsize_12: number;
vsize_15: number;
vsize_20: number;
vsize_30: number;
vsize_40: number;
vsize_50: number;
vsize_60: number;
vsize_70: number;
vsize_80: number;
vsize_90: number;
vsize_100: number;
vsize_125: number;
vsize_150: number;
vsize_175: number;
vsize_200: number;
vsize_250: number;
vsize_300: number;
vsize_350: number;
vsize_400: number;
vsize_500: number;
vsize_600: number;
vsize_700: number;
vsize_800: number;
vsize_900: number;
vsize_1000: number;
vsize_1200: number;
vsize_1400: number;
vsize_1600: number;
vsize_1800: number;
vsize_2000: number;
}
export interface IProjectedBlockInternal extends IProjectedBlock {
txIds: string[];
txFeePerVsizes: number[];
}
export interface IProjectedBlock {
blockSize: number;
blockWeight: number;
maxFee: number;
maxWeightFee: number;
medianFee: number;
minFee: number;
minWeightFee: number;
nTx: number;
fees: number;
hasMyTxId?: boolean;
}
export interface IMempool { [txid: string]: ITransaction; }

63
backend/src/routes.ts Normal file
View File

@ -0,0 +1,63 @@
import statistics from './api/statistics';
import feeApi from './api/fee-api';
import projectedBlocks from './api/projected-blocks';
class Routes {
constructor() {}
public async getLiveResult(req, res) {
const result = await statistics.$listLatestFromId(req.query.lastId);
res.send(result);
}
public async get2HStatistics(req, res) {
const result = await statistics.$list2H();
res.send(result);
}
public async get24HStatistics(req, res) {
const result = await statistics.$list24H();
res.send(result);
}
public async get1WHStatistics(req, res) {
const result = await statistics.$list1W();
res.send(result);
}
public async get1MStatistics(req, res) {
const result = await statistics.$list1M();
res.send(result);
}
public async get3MStatistics(req, res) {
const result = await statistics.$list3M();
res.send(result);
}
public async get6MStatistics(req, res) {
const result = await statistics.$list6M();
res.send(result);
}
public async getRecommendedFees(req, res) {
const result = feeApi.getRecommendedFee();
res.send(result);
}
public async $getgetTransactionsForBlock(req, res) {
const result = await feeApi.$getTransactionsForBlock(req.params.id);
res.send(result);
}
public async getgetTransactionsForProjectedBlock(req, res) {
try {
const result = await projectedBlocks.getProjectedBlockFeesForBlock(req.params.id);
res.send(result);
} catch (e) {
res.status(500).send(e.message);
}
}
}
export default new Routes();

20
backend/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2015",
"strict": true,
"noImplicitAny": false,
"sourceMap": false,
"outDir": "dist",
"moduleResolution": "node",
"typeRoots": [
"node_modules/@types"
]
},
"include": [
"src/**/*.ts",
],
"exclude": [
"dist/**"
]
}

137
backend/tslint.json Normal file
View File

@ -0,0 +1,137 @@
{
"rules": {
"arrow-return-shorthand": true,
"callable-types": true,
"class-name": true,
"comment-format": [
true,
"check-space"
],
"curly": true,
"deprecation": {
"severity": "warn"
},
"eofline": true,
"forin": true,
"import-blacklist": [
true,
"rxjs",
"rxjs/Rx"
],
"import-spacing": true,
"indent": [
true,
"spaces"
],
"interface-over-type-literal": true,
"label-position": true,
"max-line-length": [
true,
140
],
"member-access": false,
"member-ordering": [
true,
{
"order": [
"static-field",
"instance-field",
"static-method",
"instance-method"
]
}
],
"no-arg": true,
"no-bitwise": true,
"no-console": [
true,
"debug",
"info",
"time",
"timeEnd",
"trace"
],
"no-construct": true,
"no-debugger": true,
"no-duplicate-super": true,
"no-empty": false,
"no-empty-interface": true,
"no-eval": true,
"no-inferrable-types": false,
"no-misused-new": true,
"no-non-null-assertion": true,
"no-shadowed-variable": true,
"no-string-literal": false,
"no-string-throw": true,
"no-switch-case-fall-through": true,
"no-trailing-whitespace": true,
"no-unnecessary-initializer": true,
"no-unused-expression": true,
"no-use-before-declare": true,
"no-var-keyword": true,
"object-literal-sort-keys": false,
"one-line": [
true,
"check-open-brace",
"check-catch",
"check-else",
"check-whitespace"
],
"prefer-const": true,
"quotemark": [
true,
"single"
],
"radix": true,
"semicolon": [
true,
"always"
],
"triple-equals": [
true,
"allow-null-check"
],
"typedef-whitespace": [
true,
{
"call-signature": "nospace",
"index-signature": "nospace",
"parameter": "nospace",
"property-declaration": "nospace",
"variable-declaration": "nospace"
}
],
"unified-signatures": true,
"variable-name": false,
"whitespace": [
true,
"check-branch",
"check-decl",
"check-operator",
"check-separator",
"check-type"
],
"directive-selector": [
true,
"attribute",
"app",
"camelCase"
],
"component-selector": [
true,
"element",
"app",
"kebab-case"
],
"no-output-on-prefix": true,
"use-input-property-decorator": true,
"use-output-property-decorator": true,
"use-host-property-decorator": true,
"no-input-rename": true,
"no-output-rename": true,
"use-life-cycle-interface": true,
"use-pipe-transform-interface": true,
"component-class-suffix": true,
"directive-class-suffix": true
}
}

13
frontend/.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

39
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/out-tsc
# dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db

127
frontend/angular.json Normal file
View File

@ -0,0 +1,127 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"mempool": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"schematics": {
"@schematics/angular:component": {
"styleext": "scss"
}
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/mempool",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
"assets": [
"src/favicon.ico",
"src/assets",
"src/.htaccess"
],
"styles": [
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "mempool:build"
},
"configurations": {
"production": {
"browserTarget": "mempool:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "mempool:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.spec.json",
"karmaConfig": "src/karma.conf.js",
"styles": [
"src/styles.scss"
],
"scripts": [],
"assets": [
"src/favicon.ico",
"src/assets"
]
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"src/tsconfig.app.json",
"src/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
},
"mempool-e2e": {
"root": "e2e/",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "mempool:serve"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": "e2e/tsconfig.e2e.json",
"exclude": [
"**/node_modules/**"
]
}
}
}
}
},
"defaultProject": "mempool"
}

View File

@ -0,0 +1,28 @@
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter');
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./src/**/*.e2e-spec.ts'
],
capabilities: {
'browserName': 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.e2e.json')
});
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};

View File

@ -0,0 +1,14 @@
import { AppPage } from './app.po';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getParagraphText()).toEqual('Welcome to app!');
});
});

View File

@ -0,0 +1,11 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo() {
return browser.get('/');
}
getParagraphText() {
return element(by.css('app-root h1')).getText();
}
}

View File

@ -0,0 +1,13 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"module": "commonjs",
"target": "es5",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}
}

53
frontend/package.json Normal file
View File

@ -0,0 +1,53 @@
{
"name": "mempool",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve --aot --proxy-config proxy.conf.json",
"build": "ng build --prod --vendorChunk=false --build-optimizer=true",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
"private": true,
"dependencies": {
"@angular/animations": "^8.0.0",
"@angular/common": "^8.0.0",
"@angular/compiler": "^8.0.0",
"@angular/core": "^8.0.0",
"@angular/forms": "^8.0.0",
"@angular/platform-browser": "^8.0.0",
"@angular/platform-browser-dynamic": "^8.0.0",
"@angular/router": "^8.0.0",
"@ng-bootstrap/ng-bootstrap": "^3.3.1",
"bootstrap": "^4.3.1",
"chartist": "^0.11.2",
"core-js": "^2.6.9",
"ng-chartist": "^2.0.0-beta.1",
"rxjs": "^6.5.2",
"tslib": "^1.9.0",
"zone.js": "~0.9.1"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.800.0",
"@angular/cli": "~8.0.2",
"@angular/compiler-cli": "^8.0.0",
"@angular/language-service": "^8.0.0",
"@types/chartist": "^0.9.46",
"@types/jasmine": "^2.8.16",
"@types/jasminewd2": "^2.0.6",
"@types/node": "~8.9.4",
"codelyzer": "~5.1.0",
"jasmine-core": "~2.99.1",
"jasmine-spec-reporter": "~4.2.1",
"karma": "~1.7.1",
"karma-chrome-launcher": "~2.2.0",
"karma-coverage-istanbul-reporter": "~1.4.2",
"karma-jasmine": "~1.1.1",
"karma-jasmine-html-reporter": "^0.2.2",
"protractor": "~5.3.0",
"ts-node": "~7.0.0",
"tslint": "~5.15.0",
"typescript": "~3.4.3"
}
}

6
frontend/proxy.conf.json Normal file
View File

@ -0,0 +1,6 @@
{
"/api": {
"target": "http://localhost:8999/",
"secure": false
}
}

7
frontend/src/.htaccess Normal file
View File

@ -0,0 +1,7 @@
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} -s [OR]
RewriteCond %{REQUEST_FILENAME} -l [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^.*$ - [NC,L]
RewriteRule ^(.*) /index.html [NC,L]

View File

@ -0,0 +1,41 @@
<div class="text-center">
<img src="./assets/mempool-tube.png" width="63" height="63" />
<br /><br />
<h2>About</h2>
<p>Mempool.Space is a realtime Bitcoin blockchain visualizer and statistics website focused on SegWit.</p>
<p>Created by <a href="http://t.me/softcrypto">@softcrypto</a> (Telegram). <a href="https://twitter.com/softcrypt0">@softcrypt0</a> (Twitter).
<br />Designed by <a href="https://emeraldo.io">emeraldo.io</a>.</p>
<h2>Fee API</h2>
<div class="col-4 mx-auto">
<input class="form-control" type="text" value="https://mempool.space:8999/api/v1/fees/recommended" readonly>
</div>
<br />
<h1>Donate</h1>
<h3>Segwit native</h3>
<img src="./assets/btc-qr-code-segwit.png" width="200" height="200" />
<br />
bc1qqrmgr60uetlmrpylhtllawyha9z5gw6hwdmk2t
<br /><br />
<h3>Segwit compatibility</h3>
<img src="./assets/btc-qr-code.png" width="200" height="200" />
<br />
3Ccig4G4u8hbExnxBJHeE5ZmxxWxvEQ65f
<br /><br />
<h3>PayNym</h3>
<img src="./assets/paynym-code.png" width="200" height="200" />
<br />
<p style="word-wrap: break-word; overflow-wrap: break-word;max-width: 300px; text-align: center; margin: auto;">
PM8TJZWDn1XbYmVVMR3RP9Kt1BW69VCSLTC12UB8iWUiKcEBJsxB4UUKBMJxc3LVaxtU5d524sLFrTy9kFuyPQ73QkEagGcMfCE6M38E5C67EF8KAqvS
</p>
</div>

View File

@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-about',
templateUrl: './about.component.html',
styleUrls: ['./about.component.scss']
})
export class AboutComponent implements OnInit {
constructor() { }
ngOnInit() {
}
}

View File

@ -0,0 +1,40 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { BlockchainComponent } from './blockchain/blockchain.component';
import { AboutComponent } from './about/about.component';
import { StatisticsComponent } from './statistics/statistics.component';
const routes: Routes = [
{
path: '',
children: [],
component: BlockchainComponent
},
{
path: 'tx/:id',
children: [],
component: BlockchainComponent
},
{
path: 'about',
children: [],
component: AboutComponent
},
{
path: 'statistics',
component: StatisticsComponent,
},
{
path: 'graphs',
component: StatisticsComponent,
},
{
path: '**',
redirectTo: ''
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }

View File

@ -0,0 +1,32 @@
<header>
<nav class="navbar navbar-expand-md navbar-dark bg-dark">
<a class="navbar-brand" routerLink="/"><img src="/assets/mempool-space-logo.png" width="180" class="logo"> <span class="badge badge-warning" style="margin-left: 10px;" *ngIf="isOffline">Offline</span></a>
<button class="navbar-toggler" type="button" (click)="collapse()" aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse" id="navbarCollapse" [ngClass]="{'show': navCollapsed}">
<ul class="navbar-nav mr-auto">
<li class="nav-item" routerLinkActive="active" [ngClass]="{'active': txActive.isActive}" [routerLinkActiveOptions]="{exact: true}">
<a class="nav-link" routerLink="/" (click)="collapse()">Blocks</a>
<a class="nav-link" routerLink="/tx" routerLinkActive #txActive="routerLinkActive" style="display: none;"></a>
</li>
<li class="nav-item" routerLinkActive="active">
<a class="nav-link" routerLink="/graphs" (click)="collapse()">Graphs</a>
</li>
<li class="nav-item" routerLinkActive="active">
<a class="nav-link" routerLink="/about" (click)="collapse()">About</a>
</li>
</ul>
<form [formGroup]="searchForm" class="form-inline mt-2 mt-md-0 mr-4" (submit)="searchForm.valid && search()" novalidate>
<input formControlName="txId" required style="width: 300px;" class="form-control mr-sm-2" type="text" placeholder="Track transaction (TXID)" aria-label="Search">
<button class="btn btn-primary my-2 my-sm-0" type="submit">Track</button>
</form>
</div>
</nav>
</header>
<br />
<router-outlet></router-outlet>

View File

@ -0,0 +1,28 @@
li.nav-item.active {
background-color: #653b9c;
}
li.nav-item {
padding: 10px;
}
.navbar {
z-index: 100;
}
@media (min-width: 768px) {
.navbar {
padding: 0rem 1rem;
}
li.nav-item {
padding: 20px;
}
}
.logo {
margin-left: 40px;
}
li.nav-item a {
color: #ffffff;
}

View File

@ -0,0 +1,52 @@
import { Component, OnInit } from '@angular/core';
import { MemPoolService } from './services/mem-pool.service';
import { Router } from '@angular/router';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
navCollapsed = false;
isOffline = false;
searchForm: FormGroup;
constructor(
private memPoolService: MemPoolService,
private router: Router,
private formBuilder: FormBuilder,
) { }
ngOnInit() {
this.searchForm = this.formBuilder.group({
txId: ['', Validators.pattern('^[a-fA-F0-9]{64}$')],
});
this.memPoolService.isOffline
.subscribe((state) => {
this.isOffline = state;
});
}
collapse(): void {
this.navCollapsed = !this.navCollapsed;
}
search() {
const txId = this.searchForm.value.txId;
if (txId) {
if (window.location.pathname === '/' || window.location.pathname.substr(0, 4) === '/tx/') {
window.history.pushState({}, '', `/tx/${txId}`);
} else {
this.router.navigate(['/tx/', txId]);
}
this.memPoolService.txIdSearch.next(txId);
this.searchForm.setValue({
txId: '',
});
this.collapse();
}
}
}

View File

@ -0,0 +1,45 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { BlockchainComponent } from './blockchain/blockchain.component';
import { AppRoutingModule } from './app-routing.module';
import { SharedModule } from './shared/shared.module';
import { MemPoolService } from './services/mem-pool.service';
import { HttpClientModule } from '@angular/common/http';
import { FooterComponent } from './footer/footer.component';
import { AboutComponent } from './about/about.component';
import { TxBubbleComponent } from './tx-bubble/tx-bubble.component';
import { ReactiveFormsModule } from '@angular/forms';
import { BlockModalComponent } from './block-modal/block-modal.component';
import { StatisticsComponent } from './statistics/statistics.component';
import { ProjectedBlockModalComponent } from './projected-block-modal/projected-block-modal.component';
@NgModule({
declarations: [
AppComponent,
BlockchainComponent,
FooterComponent,
StatisticsComponent,
AboutComponent,
TxBubbleComponent,
BlockModalComponent,
ProjectedBlockModalComponent,
],
imports: [
ReactiveFormsModule,
BrowserModule,
HttpClientModule,
AppRoutingModule,
SharedModule,
],
providers: [
MemPoolService,
],
entryComponents: [
BlockModalComponent,
ProjectedBlockModalComponent,
],
bootstrap: [AppComponent]
})
export class AppModule { }

View File

@ -0,0 +1,45 @@
<div class="modal-header">
<h4 class="modal-title">Fee distribution for block <a href="https://www.blockstream.info/block-height/{{ block.height }}" target="_blank">#{{ block.height }}</a></h4>
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss('Cross click')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div>
<table class="table table-borderless table-sm">
<tr>
<th>Median fee:</th>
<td>~{{ block.medianFee | ceil }} sat/vB <span *ngIf="conversions">(~<span class="green-color">{{ conversions.USD * (block.medianFee/100000000)*250 | currency:'USD':'symbol':'1.2-2' }}</span>/tx)</span></td>
<th>Block size:</th>
<td>{{ block.size | bytes: 2 }}</td>
</tr>
<tr>
<th>Fee span:</th>
<td class="yellow-color">{{ block.minFee | ceil }} - {{ block.maxFee | ceil }} sat/vB</td>
<th>Tx count:</th>
<td>{{ block.nTx }} transactions</td>
</tr>
<tr>
<th>Total fees:</th>
<td>{{ (block.fees - 12.5) | number: '1.2-2' }} BTC <span *ngIf="conversions">(<span class="green-color">{{ conversions.USD * (block.fees - 12.5) | currency:'USD':'symbol':'1.0-0' }}</span>)</span></td>
<th>Block reward + fees:</th>
<td>{{ block.fees | number: '1.2-2' }} BTC <span *ngIf="conversions">(<span class="green-color">{{ conversions.USD * block.fees | currency:'USD':'symbol':'1.0-0' }}</span>)</span></td>
</tr>
</table>
</div>
<hr>
<div style="height: 400px;" *ngIf="mempoolVsizeFeesData; else loadingFees">
<app-chartist
[data]="mempoolVsizeFeesData"
[type]="'Bar'"
[options]="mempoolVsizeFeesOptions">
</app-chartist>
</div>
<ng-template #loadingFees>
<div class="text-center">
<div class="spinner-border text-light"></div>
</div>
</ng-template>
</div>

View File

@ -0,0 +1,7 @@
.yellow-color {
color: #ffd800;
}
.green-color {
color: #3bcc49;
}

View File

@ -0,0 +1,73 @@
import { Component, OnInit, Input } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ApiService } from '../services/api.service';
import { IBlock } from '../blockchain/interfaces';
import { MemPoolService } from '../services/mem-pool.service';
import * as Chartist from 'chartist';
@Component({
selector: 'app-block-modal',
templateUrl: './block-modal.component.html',
styleUrls: ['./block-modal.component.scss']
})
export class BlockModalComponent implements OnInit {
@Input() block: IBlock;
mempoolVsizeFeesData: any;
mempoolVsizeFeesOptions: any;
conversions: any;
constructor(
public activeModal: NgbActiveModal,
private apiService: ApiService,
private memPoolService: MemPoolService,
) { }
ngOnInit() {
this.mempoolVsizeFeesOptions = {
showArea: false,
showLine: false,
fullWidth: false,
showPoint: false,
low: 0,
axisX: {
position: 'start',
showLabel: false,
offset: 0,
showGrid: false,
},
axisY: {
position: 'end',
scaleMinSpace: 40,
showGrid: false,
},
plugins: [
Chartist.plugins.tooltip({
tooltipOffset: {
x: 15,
y: 250
},
transformTooltipTextFnc: (value: number): any => {
return Math.ceil(value) + ' sat/vB';
},
anchorToPoint: false,
})
]
};
this.memPoolService.conversions
.subscribe((conversions) => {
this.conversions = conversions;
});
this.apiService.listTransactionsForBlock$(this.block.height)
.subscribe((data) => {
this.mempoolVsizeFeesData = {
labels: data.map((x, i) => i),
series: [data.map((tx) => tx.fpv)]
};
});
}
}

View File

@ -0,0 +1,69 @@
<div *ngIf="blocks.length === 0" class="text-center">
<h3>Loading blocks...</h3>
<br>
<div class="spinner-border text-light"></div>
</div>
<div *ngIf="blocks.length !== 0 && txTrackingLoading" class="text-center black-background">
<h3>Locating transaction...</h3>
</div>
<div *ngIf="txShowTxNotFound" class="text-center black-background">
<h3>Transaction not found!</h3>
</div>
<div class="text-center" class="blockchain-wrapper">
<div class="position-container">
<div class="projected-blocks-container">
<div *ngFor="let projectedBlock of projectedBlocks; let i = index; trackBy: trackByProjectedFn">
<div (click)="openProjectedBlockModal(projectedBlock, i);" class="bitcoin-block text-center projected-block" id="projected-block-{{ i }}" [ngStyle]="getStyleForProjectedBlockAtIndex(i)">
<div class="block-body" *ngIf="projectedBlocks?.length">
<div class="fees">
~{{ projectedBlock.medianFee | ceil }} sat/vB
<br/>
<span class="yellow-color">{{ projectedBlock.minFee | ceil }} - {{ projectedBlock.maxFee | ceil }} sat/vB</span>
</div>
<div class="block-size">{{ projectedBlock.blockSize | bytes: 2 }}</div>
<div class="transaction-count">{{ projectedBlock.nTx }} transactions</div>
<div class="time-difference" *ngIf="i !== 3">In ~{{ 10 * i + 10 }} minutes</div>
<ng-template [ngIf]="i === 3 && projectedBlocks?.length >= 4 && (projectedBlock.blockWeight / 4000000 | ceil) > 1">
<div class="time-difference">+{{ projectedBlock.blockWeight / 4000000 | ceil }} blocks</div>
</ng-template>
</div>
<span class="animated-border"></span>
</div>
</div>
</div>
<div class="blocks-container" *ngIf="blocks.length">
<div *ngFor="let block of blocks; let i = index; trackBy: trackByBlocksFn" >
<div (click)="openBlockModal(block);" class="text-center bitcoin-block mined-block" id="bitcoin-block-{{ block.height }}" [ngStyle]="getStyleForBlock(block)">
<div class="block-height">
<a href="https://www.blockstream.info/block-height/{{ block.height }}" target="_blank">#{{ block.height }}</a>
</div>
<div class="block-body">
<div class="fees">
~{{ block.medianFee | ceil }} sat/vB
<br/>
<span class="yellow-color">{{ block.minFee | ceil }} - {{ block.maxFee | ceil }} sat/vB</span>
</div>
<div class="block-size">{{ block.size | bytes: 2 }}</div>
<div class="transaction-count">{{ block.nTx }} transactions</div>
<br /><br />
<div class="time-difference">{{ getTimeSinceMined(block) }} ago</div>
</div>
</div>
</div>
</div>
<div id="divider" *ngIf="blocks.length"></div>
</div>
</div>
<app-tx-bubble *ngIf="blocks?.length && txTrackingTx" [tx]="txTrackingTx" [arrowPosition]="txBubbleArrowPosition" [ngStyle]="txBubbleStyle" [latestBlockHeight]="blocks[0].height" [txTrackingBlockHeight]="txTrackingBlockHeight"></app-tx-bubble>
<app-footer></app-footer>

View File

@ -0,0 +1,195 @@
.block-filled {
width: 100%;
background-color: #aeffb0;
position: absolute;
bottom: 0;
left: 0;
}
.block-filled .segwit {
background-color: #16ca1a;
}
.bitcoin-block {
width: 125px;
height: 125px;
cursor: pointer;
}
.mined-block {
position: absolute;
top: 0px;
transition: 1s;
}
.block-size {
font-size: 18px;
font-weight: bold;
}
.blocks-container {
position: absolute;
top: 0px;
left: 40px;
}
.projected-blocks-container {
position: absolute;
top: 0px;
right: 0px;
left: 0px;
animation: opacityPulse 2s ease-out;
animation-iteration-count: infinite;
opacity: 1;
}
.projected-block {
position: absolute;
top: 0;
}
.block-body {
text-align: center;
}
@keyframes opacityPulse {
0% {opacity: 0.7;}
50% {opacity: 1.0;}
100% {opacity: 0.7;}
}
.time-difference {
position: absolute;
bottom: 10px;
text-align: center;
width: 100%;
font-size: 14px;
}
#divider {
width: 3px;
height: 3000px;
left: 0;
top: -1000px;
background-image: url('/assets/divider-new.png');
background-repeat: repeat-y;
position: absolute;
margin-bottom: 120px;
}
#divider > img {
position: absolute;
left: -100px;
top: -28px;
}
.fees {
font-size: 10px;
margin-top: 10px;
margin-bottom: 2px;
}
.btcblockmiddle {
height: 18px;
}
.breakRow {
height: 30px;
margin-top: 20px;
}
.yellow-color {
color: #ffd800;
}
.transaction-count {
font-size: 12px;
}
.blockchain-wrapper {
overflow: hidden;
}
.position-container {
position: absolute;
left: 50%;
top: calc(50% - 60px);
}
.block-height {
position: absolute;
font-size: 12px;
bottom: 160px;
width: 100%;
left: -12px;
text-shadow: 0px 32px 3px #111;
z-index: 100;
}
@media (max-width: 767.98px) {
#divider {
top: -50px;
}
.position-container {
top: 100px;
}
.projected-blocks-container {
position: absolute;
left: -165px;
top: -40px;
}
.block-height {
bottom: 125px;
left: inherit;
text-shadow: inherit;
z-index: inherit;
}
}
@media (min-width: 1920px) {
.position-container {
transform: scale(1.3);
}
}
@media (min-width: 768px) {
.bitcoin-block::after {
content: '';
width: 125px;
height: 24px;
position:absolute;
top: -24px;
left: -20px;
background-color: #232838;
transform:skew(40deg);
transform-origin:top;
}
.bitcoin-block::before {
content: '';
width: 20px;
height: 125px;
position: absolute;
top: -12px;
left: -20px;
background-color: #191c27;
transform: skewY(50deg);
transform-origin: top;
}
.projected-block.bitcoin-block::after {
background-color: #403834;
}
.projected-block.bitcoin-block::before {
background-color: #2d2825;
}
}
.black-background {
background-color: #11131f;
z-index: 100;
position: relative;
}

View File

@ -0,0 +1,272 @@
import { Component, OnInit, OnDestroy, Renderer2, HostListener } from '@angular/core';
import { IMempoolDefaultResponse, IBlock, IProjectedBlock, ITransaction } from './interfaces';
import { retryWhen, tap } from 'rxjs/operators';
import { MemPoolService } from '../services/mem-pool.service';
import { ApiService } from '../services/api.service';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { BlockModalComponent } from '../block-modal/block-modal.component';
import { ProjectedBlockModalComponent } from '../projected-block-modal/projected-block-modal.component';
@Component({
selector: 'app-blockchain',
templateUrl: './blockchain.component.html',
styleUrls: ['./blockchain.component.scss']
})
export class BlockchainComponent implements OnInit, OnDestroy {
blocks: IBlock[] = [];
projectedBlocks: IProjectedBlock[] = [];
subscription: any;
socket: any;
innerWidth: any;
txBubbleStyle: any = {};
txTrackingLoading = false;
txTrackingEnabled = false;
txTrackingTx: ITransaction | null = null;
txTrackingBlockHeight = 0;
txShowTxNotFound = false;
txBubbleArrowPosition = 'top';
@HostListener('window:resize', ['$event'])
onResize(event: Event) {
this.innerWidth = window.innerWidth;
this.moveTxBubbleToPosition();
}
constructor(
private memPoolService: MemPoolService,
private apiService: ApiService,
private renderer: Renderer2,
private route: ActivatedRoute,
private modalService: NgbModal,
) {}
ngOnInit() {
this.txBubbleStyle = {
'position': 'absolute',
'top': '425px',
'visibility': 'hidden',
};
this.innerWidth = window.innerWidth;
this.socket = this.apiService.websocketSubject;
this.subscription = this.socket
.pipe(
retryWhen((errors: any) => errors.pipe(
tap(() => this.memPoolService.isOffline.next(true))))
)
.subscribe((response: IMempoolDefaultResponse) => {
this.memPoolService.isOffline.next(false);
if (response.mempoolInfo && response.txPerSecond !== undefined) {
this.memPoolService.loaderSubject.next({
memPoolInfo: response.mempoolInfo,
txPerSecond: response.txPerSecond,
vBytesPerSecond: response.vBytesPerSecond,
});
}
if (response.blocks && response.blocks.length) {
this.blocks = response.blocks;
this.blocks.reverse();
}
if (response.block) {
if (!this.blocks.some((block) => response.block !== undefined && response.block.height === block.height )) {
this.blocks.unshift(response.block);
if (this.blocks.length >= 8) {
this.blocks.pop();
}
}
}
if (response.conversions) {
this.memPoolService.conversions.next(response.conversions);
}
if (response.projectedBlocks) {
this.projectedBlocks = response.projectedBlocks;
const mempoolWeight = this.projectedBlocks.map((block) => block.blockWeight).reduce((a, b) => a + b);
this.memPoolService.mempoolWeight.next(mempoolWeight);
}
if (response['track-tx']) {
if (response['track-tx'].tracking) {
this.txTrackingEnabled = true;
this.txTrackingBlockHeight = response['track-tx'].blockHeight;
if (response['track-tx'].tx) {
this.txTrackingTx = response['track-tx'].tx;
this.txTrackingLoading = false;
}
} else {
this.txTrackingEnabled = false;
this.txTrackingTx = null;
this.txTrackingBlockHeight = 0;
}
if (response['track-tx'].message && response['track-tx'].message === 'not-found') {
this.txTrackingLoading = false;
this.txShowTxNotFound = true;
setTimeout(() => { this.txShowTxNotFound = false; }, 2000);
}
setTimeout(() => {
this.moveTxBubbleToPosition();
});
}
},
(err: Error) => console.log(err)
);
this.renderer.addClass(document.body, 'disable-scroll');
this.route.paramMap
.subscribe((params: ParamMap) => {
const txId: string | null = params.get('id');
if (!txId) {
return;
}
this.txTrackingLoading = true;
this.socket.next({'action': 'track-tx', 'txId': txId});
});
this.memPoolService.txIdSearch
.subscribe((txId) => {
if (txId) {
this.txTrackingLoading = true;
this.socket.next({'action': 'track-tx', 'txId': txId});
}
});
}
moveTxBubbleToPosition() {
let element: HTMLElement | null = null;
if (this.txTrackingBlockHeight === 0) {
const index = this.projectedBlocks.findIndex((pB) => pB.hasMytx);
if (index > -1) {
element = document.getElementById('projected-block-' + index);
} else {
return;
}
} else {
element = document.getElementById('bitcoin-block-' + this.txTrackingBlockHeight);
}
this.txBubbleStyle['visibility'] = 'visible';
this.txBubbleStyle['position'] = 'absolute';
if (!element) {
if (this.innerWidth <= 768) {
this.txBubbleArrowPosition = 'bottom';
this.txBubbleStyle['left'] = window.innerWidth / 2 - 50 + 'px';
this.txBubbleStyle['bottom'] = '270px';
this.txBubbleStyle['top'] = 'inherit';
this.txBubbleStyle['position'] = 'fixed';
} else {
this.txBubbleStyle['left'] = window.innerWidth - 220 + 'px';
this.txBubbleArrowPosition = 'right';
this.txBubbleStyle['top'] = '425px';
}
} else {
this.txBubbleArrowPosition = 'top';
const domRect: DOMRect | ClientRect = element.getBoundingClientRect();
this.txBubbleStyle['left'] = domRect.left - 50 + 'px';
this.txBubbleStyle['top'] = domRect.top + 125 + window.scrollY + 'px';
if (domRect.left + 100 > window.innerWidth) {
this.txBubbleStyle['left'] = window.innerWidth - 220 + 'px';
this.txBubbleArrowPosition = 'right';
} else if (domRect.left + 220 > window.innerWidth) {
this.txBubbleStyle['left'] = window.innerWidth - 240 + 'px';
this.txBubbleArrowPosition = 'top-right';
} else {
this.txBubbleStyle['left'] = domRect.left + 15 + 'px';
}
if (domRect.left < 86) {
this.txBubbleArrowPosition = 'top-left';
this.txBubbleStyle['left'] = 125 + 'px';
}
}
}
getTimeSinceMined(block: IBlock): string {
const minutes = ((new Date().getTime()) - (new Date(block.time * 1000).getTime())) / 1000 / 60;
if (minutes >= 120) {
return Math.floor(minutes / 60) + ' hours';
}
if (minutes >= 60) {
return Math.floor(minutes / 60) + ' hour';
}
if (minutes <= 1) {
return '< 1 minute';
}
if (minutes === 1) {
return '1 minute';
}
return Math.round(minutes) + ' minutes';
}
getStyleForBlock(block: IBlock) {
const greenBackgroundHeight = 100 - (block.weight / 4000000) * 100;
if (this.innerWidth <= 768) {
return {
'top': 155 * this.blocks.indexOf(block) + 'px',
'background': `repeating-linear-gradient(#2d3348, #2d3348 ${greenBackgroundHeight}%,
#9339f4 ${Math.max(greenBackgroundHeight, 0)}%, #105fb0 100%)`,
};
} else {
return {
'left': 155 * this.blocks.indexOf(block) + 'px',
'background': `repeating-linear-gradient(#2d3348, #2d3348 ${greenBackgroundHeight}%,
#9339f4 ${Math.max(greenBackgroundHeight, 0)}%, #105fb0 100%)`,
};
}
}
getStyleForProjectedBlockAtIndex(index: number) {
const greenBackgroundHeight = 100 - (this.projectedBlocks[index].blockWeight / 4000000) * 100;
if (this.innerWidth <= 768) {
if (index === 3) {
return {
'top': 40 + index * 155 + 'px'
};
}
return {
'top': 40 + index * 155 + 'px',
'background': `repeating-linear-gradient(#554b45, #554b45 ${greenBackgroundHeight}%,
#bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`,
};
} else {
if (index === 3) {
return {
'right': 40 + index * 155 + 'px'
};
}
return {
'right': 40 + index * 155 + 'px',
'background': `repeating-linear-gradient(#554b45, #554b45 ${greenBackgroundHeight}%,
#bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`,
};
}
}
trackByProjectedFn(index: number) {
return index;
}
trackByBlocksFn(index: number, item: IBlock) {
return item.height;
}
ngOnDestroy() {
if (this.subscription) {
this.subscription.unsubscribe();
}
this.renderer.removeClass(document.body, 'disable-scroll');
}
openBlockModal(block: IBlock) {
const modalRef = this.modalService.open(BlockModalComponent, { size: 'lg' });
modalRef.componentInstance.block = block;
}
openProjectedBlockModal(block: IBlock, index: number) {
const modalRef = this.modalService.open(ProjectedBlockModalComponent, { size: 'lg' });
modalRef.componentInstance.block = block;
modalRef.componentInstance.index = index;
}
}

View File

@ -0,0 +1,176 @@
export interface IMempoolInfo {
size: number;
bytes: number;
usage: number;
maxmempool: number;
mempoolminfee: number;
minrelaytxfee: number;
}
export interface IMempoolDefaultResponse {
mempoolInfo?: IMempoolInfo;
blocks?: IBlock[];
block?: IBlock;
projectedBlocks?: IProjectedBlock[];
txPerSecond?: number;
vBytesPerSecond: number;
'track-tx'?: ITrackTx;
conversions?: any;
}
export interface ITrackTx {
tx?: ITransaction;
blockHeight: number;
tracking: boolean;
message?: string;
}
export interface IProjectedBlock {
blockSize: number;
blockWeight: number;
maxFee: number;
maxWeightFee: number;
medianFee: number;
minFee: number;
minWeightFee: number;
nTx: number;
hasMytx: boolean;
}
export interface IStrippedBlock {
bits: number;
difficulty: number;
hash: string;
height: number;
nTx: number;
size: number;
strippedsize: number;
time: number;
weight: number;
}
export interface ITransaction {
txid: string;
hash: string;
version: number;
size: number;
vsize: number;
locktime: number;
vin: Vin[];
vout: Vout[];
hex: string;
fee: number;
feePerVsize: number;
feePerWeightUnit: number;
}
export interface IBlock {
hash: string;
confirmations: number;
strippedsize: number;
size: number;
weight: number;
height: number;
version: number;
versionHex: string;
merkleroot: string;
tx: ITransaction[];
time: number;
mediantime: number;
nonce: number;
bits: string;
difficulty: number;
chainwork: string;
nTx: number;
previousblockhash: string;
minFee: number;
maxFee: number;
medianFee: number;
fees: number;
}
interface ScriptSig {
asm: string;
hex: string;
}
interface Vin {
txid: string;
vout: number;
scriptSig: ScriptSig;
sequence: number;
}
interface ScriptPubKey {
asm: string;
hex: string;
reqSigs: number;
type: string;
addresses: string[];
}
interface Vout {
value: number;
n: number;
scriptPubKey: ScriptPubKey;
}
export interface IMempoolStats {
id: number;
added: string;
unconfirmed_transactions: number;
tx_per_second: number;
vbytes_per_second: number;
mempool_byte_weight: number;
fee_data: IFeeData;
vsize_1: number;
vsize_2: number;
vsize_3: number;
vsize_4: number;
vsize_5: number;
vsize_6: number;
vsize_8: number;
vsize_10: number;
vsize_12: number;
vsize_15: number;
vsize_20: number;
vsize_30: number;
vsize_40: number;
vsize_50: number;
vsize_60: number;
vsize_70: number;
vsize_80: number;
vsize_90: number;
vsize_100: number;
vsize_125: number;
vsize_150: number;
vsize_175: number;
vsize_200: number;
vsize_250: number;
vsize_300: number;
vsize_350: number;
vsize_400: number;
vsize_500: number;
vsize_600: number;
vsize_700: number;
vsize_800: number;
vsize_900: number;
vsize_1000: number;
vsize_1200: number;
vsize_1400: number;
vsize_1600: number;
vsize_1800: number;
vsize_2000: number;
}
export interface IBlockTransaction {
f: number;
fpv: number;
}
interface IFeeData {
wu: { [ fee: string ]: number };
vsize: { [ fee: string ]: number };
}

View File

@ -0,0 +1,18 @@
<footer class="footer">
<div class="container">
<div class="my-2 my-md-0 mr-md-3">
<div *ngIf="memPoolInfo" class="info-block">
<span class="unconfirmedTx">Unconfirmed transactions:</span>&nbsp;<b>{{ memPoolInfo?.memPoolInfo?.size | number }} ({{ mempoolBlocks }} block<span [hidden]="mempoolBlocks <= 1">s</span>)</b>
<br />
<span class="mempoolSize">Tx per second:</span>&nbsp;<b>{{ memPoolInfo?.txPerSecond | number : '1.2-2' }} tx/s</b>
<br />
<span class="txPerSecond">Tx weight per second:</span>&nbsp;
<div class="progress">
<div class="progress-bar {{ progressClass }}" role="progressbar" [ngStyle]="{'width': progressWidth}">{{ memPoolInfo?.vBytesPerSecond | ceil | number }} vBytes/s</div>
</div>
</div>
</div>
</div>
</footer>

View File

@ -0,0 +1,42 @@
.footer {
position: fixed;
bottom: 0;
width: 100%;
height: 120px;
background-color: #1d1f31;
}
.footer > .container {
margin-top: 25px;
}
.txPerSecond {
color: #4a9ff4;
}
.mempoolSize {
color: #4a68b9;
}
.unconfirmedTx {
color: #f14d80;
}
.info-block {
float:left;
}
.progress {
display: inline-flex;
width: 150px;
background-color: #2d3348;
height: 1.1rem;
}
.progress-bar {
padding: 4px;
}
.bg-warning {
background-color: #b58800 !important;
}

View File

@ -0,0 +1,54 @@
import { Component, OnInit } from '@angular/core';
import { MemPoolService, MemPoolState } from '../services/mem-pool.service';
@Component({
selector: 'app-footer',
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss']
})
export class FooterComponent implements OnInit {
memPoolInfo: MemPoolState | undefined;
mempoolBlocks = 0;
progressWidth = '';
progressClass: string;
constructor(
private memPoolService: MemPoolService
) { }
ngOnInit() {
this.memPoolService.loaderSubject
.subscribe((mempoolState) => {
this.memPoolInfo = mempoolState;
this.updateProgress();
});
this.memPoolService.mempoolWeight
.subscribe((mempoolWeight) => {
this.mempoolBlocks = Math.ceil(mempoolWeight / 4000000);
});
}
updateProgress() {
if (!this.memPoolInfo) {
return;
}
const vBytesPerSecondLimit = 1667;
let vBytesPerSecond = this.memPoolInfo.vBytesPerSecond;
if (vBytesPerSecond > 1667) {
vBytesPerSecond = 1667;
}
const percent = Math.round((vBytesPerSecond / vBytesPerSecondLimit) * 100);
this.progressWidth = percent + '%';
if (percent <= 75) {
this.progressClass = 'bg-success';
} else if (percent <= 99) {
this.progressClass = 'bg-warning';
} else {
this.progressClass = 'bg-danger';
}
}
}

View File

@ -0,0 +1,41 @@
<div class="modal-header">
<h4 class="modal-title">Fee distribution for projected block</h4>
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss('Cross click')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div>
<table class="table table-borderless table-sm">
<tr>
<th>Median fee:</th>
<td>~{{ block.medianFee | ceil }} sat/vB <span *ngIf="conversions">(~<span class="green-color">{{ conversions.USD * (block.medianFee/100000000)*250 | currency:'USD':'symbol':'1.2-2' }}</span>/tx)</span></td>
<th>Tx count:</th>
<td>{{ block.nTx }} transactions</td>
</tr>
<tr>
<th>Fee span:</th>
<td class="yellow-color">{{ block.minFee | ceil }} - {{ block.maxFee | ceil }} sat/vB</td>
</tr>
<tr>
<th>Total fees:</th>
<td>{{ block.fees | number: '1.2-2' }} BTC <span *ngIf="conversions">(<span class="green-color">{{ conversions.USD * block.fees| currency:'USD':'symbol':'1.0-0' }}</span>)</span></td>
</tr>
</table>
</div>
<hr>
<div style="height: 400px;" *ngIf="mempoolVsizeFeesData; else loadingFees">
<app-chartist
[data]="mempoolVsizeFeesData"
[type]="'Bar'"
[options]="mempoolVsizeFeesOptions">
</app-chartist>
</div>
<ng-template #loadingFees>
<div class="text-center">
<div class="spinner-border text-light"></div>
</div>
</ng-template>
</div>

View File

@ -0,0 +1,7 @@
.yellow-color {
color: #ffd800;
}
.green-color {
color: #3bcc49;
}

View File

@ -0,0 +1,74 @@
import { Component, OnInit, Input } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ApiService } from '../services/api.service';
import { IBlock } from '../blockchain/interfaces';
import { MemPoolService } from '../services/mem-pool.service';
import * as Chartist from 'chartist';
@Component({
selector: 'app-projected-block-modal',
templateUrl: './projected-block-modal.component.html',
styleUrls: ['./projected-block-modal.component.scss']
})
export class ProjectedBlockModalComponent implements OnInit {
@Input() block: IBlock;
@Input() index: number;
mempoolVsizeFeesData: any;
mempoolVsizeFeesOptions: any;
conversions: any;
constructor(
public activeModal: NgbActiveModal,
private apiService: ApiService,
private memPoolService: MemPoolService,
) { }
ngOnInit() {
this.mempoolVsizeFeesOptions = {
showArea: false,
showLine: false,
fullWidth: false,
showPoint: false,
low: 0,
axisX: {
position: 'start',
showLabel: false,
offset: 0,
showGrid: false,
},
axisY: {
position: 'end',
scaleMinSpace: 40,
showGrid: false,
},
plugins: [
Chartist.plugins.tooltip({
tooltipOffset: {
x: 15,
y: 250
},
transformTooltipTextFnc: (value: number): any => {
return Math.ceil(value) + ' sat/vB';
},
anchorToPoint: false,
})
]
};
this.memPoolService.conversions
.subscribe((conversions) => {
this.conversions = conversions;
});
this.apiService.listTransactionsForProjectedBlock$(this.index)
.subscribe((data) => {
this.mempoolVsizeFeesData = {
labels: data.map((x, i) => i),
series: [data.map((tx) => tx.fpv)]
};
});
}
}

View File

@ -0,0 +1,68 @@
import { Injectable } from '@angular/core';
import { environment } from '../../environments/environment';
import { webSocket } from 'rxjs/webSocket';
import { HttpClient, HttpParams } from '@angular/common/http';
import { IMempoolDefaultResponse, IMempoolStats, IBlockTransaction } from '../blockchain/interfaces';
import { Observable } from 'rxjs';
let WEB_SOCKET_URL = 'wss://mempool.space:8999';
let API_BASE_URL = 'https://mempool.space:8999/api/v1';
if (!environment.production) {
WEB_SOCKET_URL = 'ws://localhost:8999';
API_BASE_URL = '/api/v1';
}
@Injectable({
providedIn: 'root'
})
export class ApiService {
constructor(
private httpClient: HttpClient,
) { }
websocketSubject = webSocket<IMempoolDefaultResponse>(WEB_SOCKET_URL);
listTransactionsForBlock$(height: number): Observable<IBlockTransaction[]> {
return this.httpClient.get<IBlockTransaction[]>(API_BASE_URL + '/transactions/height/' + height);
}
listTransactionsForProjectedBlock$(index: number): Observable<IBlockTransaction[]> {
return this.httpClient.get<IBlockTransaction[]>(API_BASE_URL + '/transactions/projected/' + index);
}
listLiveStatistics$(lastId: number): Observable<IMempoolStats[]> {
const params = new HttpParams()
.set('lastId', lastId.toString());
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/live', {
params: params
});
}
list2HStatistics$(): Observable<IMempoolStats[]> {
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/2h');
}
list24HStatistics$(): Observable<IMempoolStats[]> {
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/24h');
}
list1WStatistics$(): Observable<IMempoolStats[]> {
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/1w');
}
list1MStatistics$(): Observable<IMempoolStats[]> {
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/1m');
}
list3MStatistics$(): Observable<IMempoolStats[]> {
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/3m');
}
list6MStatistics$(): Observable<IMempoolStats[]> {
return this.httpClient.get<IMempoolStats[]>(API_BASE_URL + '/statistics/6m');
}
}

View File

@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
import { Subject, ReplaySubject } from 'rxjs';
import { IMempoolInfo } from '../blockchain/interfaces';
export interface MemPoolState {
memPoolInfo: IMempoolInfo;
txPerSecond: number;
vBytesPerSecond: number;
}
@Injectable({
providedIn: 'root'
})
export class MemPoolService {
loaderSubject = new Subject<MemPoolState>();
isOffline = new Subject<boolean>();
txIdSearch = new Subject<string>();
conversions = new ReplaySubject<any>();
mempoolWeight = new Subject<number>();
}

View File

@ -0,0 +1,63 @@
/* tslint:disable */
import { Pipe, PipeTransform } from '@angular/core';
import { isNumberFinite, isPositive, isInteger, toDecimal } from './utils';
export type ByteUnit = 'B' | 'kB' | 'MB' | 'GB' | 'TB';
@Pipe({
name: 'bytes'
})
export class BytesPipe implements PipeTransform {
static formats: { [key: string]: { max: number, prev?: ByteUnit } } = {
'B': {max: 1000},
'kB': {max: Math.pow(1000, 2), prev: 'B'},
'MB': {max: Math.pow(1000, 3), prev: 'kB'},
'GB': {max: Math.pow(1000, 4), prev: 'MB'},
'TB': {max: Number.MAX_SAFE_INTEGER, prev: 'GB'}
};
transform(input: any, decimal: number = 0, from: ByteUnit = 'B', to?: ByteUnit): any {
if (!(isNumberFinite(input) &&
isNumberFinite(decimal) &&
isInteger(decimal) &&
isPositive(decimal))) {
return input;
}
let bytes = input;
let unit = from;
while (unit !== 'B') {
bytes *= 1024;
unit = BytesPipe.formats[unit].prev!;
}
if (to) {
const format = BytesPipe.formats[to];
const result = toDecimal(BytesPipe.calculateResult(format, bytes), decimal);
return BytesPipe.formatResult(result, to);
}
for (const key in BytesPipe.formats) {
const format = BytesPipe.formats[key];
if (bytes < format.max) {
const result = toDecimal(BytesPipe.calculateResult(format, bytes), decimal);
return BytesPipe.formatResult(result, key);
}
}
}
static formatResult(result: number, unit: string): string {
return `${result} ${unit}`;
}
static calculateResult(format: { max: number, prev?: ByteUnit }, bytes: number) {
const prev = format.prev ? BytesPipe.formats[format.prev] : undefined;
return prev ? bytes / prev.max : bytes;
}
}

View File

@ -0,0 +1,311 @@
/* tslint:disable */
export type CollectionPredicate = (item?: any, index?: number, collection?: any[]) => boolean;
export function isUndefined(value: any): value is undefined {
return typeof value === 'undefined';
}
export function isNull(value: any): value is null {
return value === null;
}
export function isNumber(value: any): value is number {
return typeof value === 'number';
}
export function isNumberFinite(value: any): value is number {
return isNumber(value) && isFinite(value);
}
// Not strict positive
export function isPositive(value: number): boolean {
return value >= 0;
}
export function isInteger(value: number): boolean {
// No rest, is an integer
return (value % 1) === 0;
}
export function isNil(value: any): value is (null | undefined) {
return value === null || typeof (value) === 'undefined';
}
export function isString(value: any): value is string {
return typeof value === 'string';
}
export function isObject(value: any): boolean {
return value !== null && typeof value === 'object';
}
export function isArray(value: any): boolean {
return Array.isArray(value);
}
export function isFunction(value: any): boolean {
return typeof value === 'function';
}
export function toDecimal(value: number, decimal: number): number {
return Math.round(value * Math.pow(10, decimal)) / Math.pow(10, decimal);
}
export function upperFirst(value: string): string {
return value.slice(0, 1).toUpperCase() + value.slice(1);
}
export function createRound(method: string): Function {
// <any>Math to suppress error
const func: any = (<any>Math)[method];
return function (value: number, precision: number = 0) {
if (typeof value === 'string') {
throw new TypeError('Rounding method needs a number');
}
if (typeof precision !== 'number' || isNaN(precision)) {
precision = 0;
}
if (precision) {
let pair = `${value}e`.split('e');
const val = func(`${pair[0]}e` + (+pair[1] + precision));
pair = `${val}e`.split('e');
return +(pair[0] + 'e' + (+pair[1] - precision));
}
return func(value);
};
}
export function leftPad(str: string, len: number = 0, ch: any = ' ') {
str = String(str);
ch = toString(ch);
let i = -1;
const length = len - str.length;
while (++i < length && (str.length + ch.length) <= len) {
str = ch + str;
}
return str;
}
export function rightPad(str: string, len: number = 0, ch: any = ' ') {
str = String(str);
ch = toString(ch);
let i = -1;
const length = len - str.length;
while (++i < length && (str.length + ch.length) <= len) {
str += ch;
}
return str;
}
export function toString(value: number | string) {
return `${value}`;
}
export function pad(str: string, len: number = 0, ch: any = ' '): string {
str = String(str);
ch = toString(ch);
let i = -1;
const length = len - str.length;
let left = true;
while (++i < length) {
const l = (str.length + ch.length <= len) ? (str.length + ch.length) : (str.length + 1);
if (left) {
str = leftPad(str, l, ch);
} else {
str = rightPad(str, l, ch);
}
left = !left;
}
return str;
}
export function flatten(input: any[], index: number = 0): any[] {
if (index >= input.length) {
return input;
}
if (isArray(input[index])) {
return flatten(
input.slice(0, index).concat(input[index], input.slice(index + 1)),
index
);
}
return flatten(input, index + 1);
}
export function getProperty(value: { [key: string]: any }, key: string): any {
if (isNil(value) || !isObject(value)) {
return undefined;
}
const keys: string[] = key.split('.');
let result: any = value[keys.shift()!];
for (const kk of keys) {
if (isNil(result) || !isObject(result)) {
return undefined;
}
result = result[kk];
}
return result;
}
export function sum(input: Array<number>, initial = 0): number {
return input.reduce((previous: number, current: number) => previous + current, initial);
}
// http://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array-in-javascript
export function shuffle(input: any): any {
if (!isArray(input)) {
return input;
}
const copy = [...input];
for (let i = copy.length; i; --i) {
const j = Math.floor(Math.random() * i);
const x = copy[i - 1];
copy[i - 1] = copy[j];
copy[j] = x;
}
return copy;
}
export function deepIndexOf(collection: any[], value: any) {
let index = -1;
const length = collection.length;
while (++index < length) {
if (deepEqual(value, collection[index])) {
return index;
}
}
return -1;
}
export function deepEqual(a: any, b: any) {
if (a === b) {
return true;
}
if (!(typeof a === 'object' && typeof b === 'object')) {
return a === b;
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) {
return false;
}
// Test for A's keys different from B.
const hasOwn = Object.prototype.hasOwnProperty;
for (let i = 0; i < keysA.length; i++) {
const key = keysA[i];
if (!hasOwn.call(b, keysA[i]) || !deepEqual(a[key], b[key])) {
return false;
}
}
return true;
}
export function isDeepObject(object: any) {
return object.__isDeepObject__;
}
export function wrapDeep(object: any) {
return new DeepWrapper(object);
}
export function unwrapDeep(object: any) {
if (isDeepObject(object)) {
return object.data;
}
return object;
}
export class DeepWrapper {
public __isDeepObject__ = true;
constructor(public data: any) { }
}
export function count(input: any): any {
if (!isArray(input) && !isObject(input) && !isString(input)) {
return input;
}
if (isObject(input)) {
return Object.keys(input).map((value) => input[value]).length;
}
return input.length;
}
export function empty(input: any): any {
if (!isArray(input)) {
return input;
}
return input.length === 0;
}
export function every(input: any, predicate: CollectionPredicate) {
if (!isArray(input) || !predicate) {
return input;
}
let result = true;
let i = -1;
while (++i < input.length && result) {
result = predicate(input[i], i, input);
}
return result;
}
export function takeUntil(input: any[], predicate: CollectionPredicate) {
let i = -1;
const result: any = [];
while (++i < input.length && !predicate(input[i], i, input)) {
result[i] = input[i];
}
return result;
}
export function takeWhile(input: any[], predicate: CollectionPredicate) {
return takeUntil(input, (item: any, index: number | undefined, collection: any[] | undefined) =>
!predicate(item, index, collection));
}

View File

@ -0,0 +1,8 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'ceil' })
export class CeilPipe implements PipeTransform {
transform(nr: number) {
return Math.ceil(nr);
}
}

View File

@ -0,0 +1,8 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'round' })
export class RoundPipe implements PipeTransform {
transform(nr: number) {
return Math.round(nr);
}
}

View File

@ -0,0 +1,34 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgbButtonsModule, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { BytesPipe } from './pipes/bytes-pipe/bytes.pipe';
import { RoundPipe } from './pipes/math-round-pipe/math-round.pipe';
import { CeilPipe } from './pipes/math-ceil/math-ceil.pipe';
import { ChartistComponent } from '../statistics/chartist.component';
@NgModule({
imports: [
CommonModule,
NgbButtonsModule.forRoot(),
NgbModalModule.forRoot(),
],
declarations: [
ChartistComponent,
RoundPipe,
CeilPipe,
BytesPipe,
],
exports: [
RoundPipe,
CeilPipe,
BytesPipe,
NgbButtonsModule,
NgbModalModule,
ChartistComponent,
],
providers: [
BytesPipe
]
})
export class SharedModule { }

View File

@ -0,0 +1,72 @@
@import "../../styles.scss";
.ct-bar-label {
font-size: 20px;
font-weight: bold;
fill: #fff;
}
.ct-target-line {
stroke: #f5f5f5;
stroke-width: 3px;
stroke-dasharray: 7px;
}
.ct-area {
stroke: none;
fill-opacity: 0.9;
}
.ct-label {
fill: rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 0.4);
}
.ct-grid {
stroke: rgba(255, 255, 255, 0.2);
}
/* LEGEND */
.ct-legend {
position: absolute;
z-index: 10;
left: 0px;
list-style: none;
font-size: 13px;
padding: 0px 0px 0px 30px;
top: 90px;
li {
position: relative;
padding-left: 23px;
margin-bottom: 0px;
}
li:before {
width: 12px;
height: 12px;
position: absolute;
left: 0;
content: '';
border: 3px solid transparent;
border-radius: 2px;
}
li.inactive:before {
background: transparent;
}
&.ct-legend-inside {
position: absolute;
top: 0;
right: 0;
}
@for $i from 0 to length($ct-series-colors) {
.ct-series-#{$i}:before {
background-color: nth($ct-series-colors, $i + 1);
border-color: nth($ct-series-colors, $i + 1);
}
}
}

View File

@ -0,0 +1,657 @@
import {
Component,
ElementRef,
Input,
OnChanges,
OnDestroy,
OnInit,
SimpleChanges,
ViewEncapsulation
} from '@angular/core';
import * as Chartist from 'chartist';
/**
* Possible chart types
* @type {String}
*/
export type ChartType = 'Pie' | 'Bar' | 'Line';
export type ChartInterfaces =
| Chartist.IChartistPieChart
| Chartist.IChartistBarChart
| Chartist.IChartistLineChart;
export type ChartOptions =
| Chartist.IBarChartOptions
| Chartist.ILineChartOptions
| Chartist.IPieChartOptions;
export type ResponsiveOptionTuple = Chartist.IResponsiveOptionTuple<
ChartOptions
>;
export type ResponsiveOptions = ResponsiveOptionTuple[];
/**
* Represent a chart event.
* For possible values, check the Chartist docs.
*/
export interface ChartEvent {
[eventName: string]: (data: any) => void;
}
@Component({
selector: 'app-chartist',
template: '<ng-content></ng-content>',
styleUrls: ['./chartist.component.scss'],
encapsulation: ViewEncapsulation.None,
})
export class ChartistComponent implements OnInit, OnChanges, OnDestroy {
@Input()
// @ts-ignore
public data: Promise<Chartist.IChartistData> | Chartist.IChartistData;
// @ts-ignore
@Input() public type: Promise<ChartType> | ChartType;
@Input()
// @ts-ignore
public options: Promise<Chartist.IChartOptions> | Chartist.IChartOptions;
@Input()
// @ts-ignore
public responsiveOptions: Promise<ResponsiveOptions> | ResponsiveOptions;
// @ts-ignore
@Input() public events: ChartEvent;
// @ts-ignore
public chart: ChartInterfaces;
private element: HTMLElement;
constructor(element: ElementRef) {
this.element = element.nativeElement;
}
public ngOnInit(): Promise<ChartInterfaces> {
if (!this.type || !this.data) {
Promise.reject('Expected at least type and data.');
}
return this.renderChart().then((chart) => {
if (this.events !== undefined) {
this.bindEvents(chart);
}
return chart;
});
}
public ngOnChanges(changes: SimpleChanges): void {
this.update(changes);
}
public ngOnDestroy(): void {
if (this.chart) {
this.chart.detach();
}
}
public renderChart(): Promise<ChartInterfaces> {
const promises: any[] = [
this.type,
this.element,
this.data,
this.options,
this.responsiveOptions
];
return Promise.all(promises).then((values) => {
const [type, ...args]: any = values;
if (!(type in Chartist)) {
throw new Error(`${type} is not a valid chart type`);
}
this.chart = (Chartist as any)[type](...args);
return this.chart;
});
}
public update(changes: SimpleChanges): void {
if (!this.chart || 'type' in changes) {
this.renderChart();
} else {
if (changes.data) {
this.data = changes.data.currentValue;
}
if (changes.options) {
this.options = changes.options.currentValue;
}
(this.chart as any).update(this.data, this.options);
}
}
public bindEvents(chart: any): void {
for (const event of Object.keys(this.events)) {
chart.on(event, this.events[event]);
}
}
}
/**
* Chartist.js plugin to display a "target" or "goal" line across the chart.
* Only tested with bar charts. Works for horizontal and vertical bars.
*/
(function(window, document, Chartist) {
'use strict';
const defaultOptions = {
// The class name so you can style the text
className: 'ct-target-line',
// The axis to draw the line. y == vertical bars, x == horizontal
axis: 'y',
// What value the target line should be drawn at
value: null
};
Chartist.plugins = Chartist.plugins || {};
Chartist.plugins.ctTargetLine = function (options: any) {
options = Chartist.extend({}, defaultOptions, options);
return function ctTargetLine (chart: any) {
chart.on('created', function(context: any) {
const projectTarget = {
y: function (chartRect: any, bounds: any, value: any) {
const targetLineY = chartRect.y1 - (chartRect.height() / bounds.max * value);
return {
x1: chartRect.x1,
x2: chartRect.x2,
y1: targetLineY,
y2: targetLineY
};
},
x: function (chartRect: any, bounds: any, value: any) {
const targetLineX = chartRect.x1 + (chartRect.width() / bounds.max * value);
return {
x1: targetLineX,
x2: targetLineX,
y1: chartRect.y1,
y2: chartRect.y2
};
}
};
// @ts-ignore
const targetLine = projectTarget[options.axis](context.chartRect, context.bounds, options.value);
context.svg.elem('line', targetLine, options.className);
});
};
};
}(window, document, Chartist));
/**
* Chartist.js plugin to display a data label on top of the points in a line chart.
*
*/
/* global Chartist */
(function(window, document, Chartist) {
'use strict';
const defaultOptions = {
labelClass: 'ct-label',
labelOffset: {
x: 0,
y: -10
},
textAnchor: 'middle',
align: 'center',
labelInterpolationFnc: Chartist.noop
};
const labelPositionCalculation = {
point: function(data: any) {
return {
x: data.x,
y: data.y
};
},
bar: {
left: function(data: any) {
return {
x: data.x1,
y: data.y1
};
},
center: function(data: any) {
return {
x: data.x1 + (data.x2 - data.x1) / 2,
y: data.y1
};
},
right: function(data: any) {
return {
x: data.x2,
y: data.y1
};
}
}
};
Chartist.plugins = Chartist.plugins || {};
Chartist.plugins.ctPointLabels = function(options: any) {
options = Chartist.extend({}, defaultOptions, options);
function addLabel(position: any, data: any) {
// if x and y exist concat them otherwise output only the existing value
const value = data.value.x !== undefined && data.value.y ?
(data.value.x + ', ' + data.value.y) :
data.value.y || data.value.x;
data.group.elem('text', {
x: position.x + options.labelOffset.x,
y: position.y + options.labelOffset.y,
style: 'text-anchor: ' + options.textAnchor
}, options.labelClass).text(options.labelInterpolationFnc(value));
}
return function ctPointLabels(chart: any) {
if (chart instanceof Chartist.Line || chart instanceof Chartist.Bar) {
chart.on('draw', function(data: any) {
// @ts-ignore
const positonCalculator = labelPositionCalculation[data.type]
// @ts-ignore
&& labelPositionCalculation[data.type][options.align] || labelPositionCalculation[data.type];
if (positonCalculator) {
addLabel(positonCalculator(data), data);
}
});
}
};
};
}(window, document, Chartist));
const defaultOptions = {
className: '',
classNames: false,
removeAll: false,
legendNames: false,
clickable: true,
onClick: null,
position: 'top'
};
Chartist.plugins.legend = function (options: any) {
let cachedDOMPosition;
// Catch invalid options
if (options && options.position) {
if (!(options.position === 'top' || options.position === 'bottom' || options.position instanceof HTMLElement)) {
throw Error('The position you entered is not a valid position');
}
if (options.position instanceof HTMLElement) {
// Detatch DOM element from options object, because Chartist.extend
// currently chokes on circular references present in HTMLElements
cachedDOMPosition = options.position;
delete options.position;
}
}
options = Chartist.extend({}, defaultOptions, options);
if (cachedDOMPosition) {
// Reattatch the DOM Element position if it was removed before
options.position = cachedDOMPosition;
}
return function legend(chart: any) {
function removeLegendElement() {
const legendElement = chart.container.querySelector('.ct-legend');
if (legendElement) {
legendElement.parentNode.removeChild(legendElement);
}
}
// Set a unique className for each series so that when a series is removed,
// the other series still have the same color.
function setSeriesClassNames() {
chart.data.series = chart.data.series.map(function (series: any, seriesIndex: any) {
if (typeof series !== 'object') {
series = {
value: series
};
}
series.className = series.className || chart.options.classNames.series + '-' + Chartist.alphaNumerate(seriesIndex);
return series;
});
}
function createLegendElement() {
const legendElement = document.createElement('ul');
legendElement.className = 'ct-legend';
if (chart instanceof Chartist.Pie) {
legendElement.classList.add('ct-legend-inside');
}
if (typeof options.className === 'string' && options.className.length > 0) {
legendElement.classList.add(options.className);
}
if (chart.options.width) {
legendElement.style.cssText = 'width: ' + chart.options.width + 'px;margin: 0 auto;';
}
return legendElement;
}
// Get the right array to use for generating the legend.
function getLegendNames(useLabels: any) {
return options.legendNames || (useLabels ? chart.data.labels : chart.data.series);
}
// Initialize the array that associates series with legends.
// -1 indicates that there is no legend associated with it.
function initSeriesMetadata(useLabels: any) {
const seriesMetadata = new Array(chart.data.series.length);
for (let i = 0; i < chart.data.series.length; i++) {
seriesMetadata[i] = {
data: chart.data.series[i],
label: useLabels ? chart.data.labels[i] : null,
legend: -1
};
}
return seriesMetadata;
}
function createNameElement(i: any, legendText: any, classNamesViable: any) {
const li = document.createElement('li');
li.classList.add('ct-series-' + i);
// Append specific class to a legend element, if viable classes are given
if (classNamesViable) {
li.classList.add(options.classNames[i]);
}
li.setAttribute('data-legend', i);
li.textContent = legendText;
return li;
}
// Append the legend element to the DOM
function appendLegendToDOM(legendElement: any) {
if (!(options.position instanceof HTMLElement)) {
switch (options.position) {
case 'top':
chart.container.insertBefore(legendElement, chart.container.childNodes[0]);
break;
case 'bottom':
chart.container.insertBefore(legendElement, null);
break;
}
} else {
// Appends the legend element as the last child of a given HTMLElement
options.position.insertBefore(legendElement, null);
}
}
function addClickHandler(legendElement: any, legends: any, seriesMetadata: any, useLabels: any) {
legendElement.addEventListener('click', function(e: any) {
const li = e.target;
if (li.parentNode !== legendElement || !li.hasAttribute('data-legend'))
return;
e.preventDefault();
const legendIndex = parseInt(li.getAttribute('data-legend'));
const legend = legends[legendIndex];
if (!legend.active) {
legend.active = true;
li.classList.remove('inactive');
} else {
legend.active = false;
li.classList.add('inactive');
const activeCount = legends.filter(function(legend: any) { return legend.active; }).length;
if (!options.removeAll && activeCount == 0) {
// If we can't disable all series at the same time, let's
// reenable all of them:
for (let i = 0; i < legends.length; i++) {
legends[i].active = true;
legendElement.childNodes[i].classList.remove('inactive');
}
}
}
const newSeries = [];
const newLabels = [];
for (let i = 0; i < seriesMetadata.length; i++) {
if (seriesMetadata[i].legend !== -1 && legends[seriesMetadata[i].legend].active) {
newSeries.push(seriesMetadata[i].data);
newLabels.push(seriesMetadata[i].label);
}
}
chart.data.series = newSeries;
if (useLabels) {
chart.data.labels = newLabels;
}
chart.update();
if (options.onClick) {
options.onClick(chart, e);
}
});
}
removeLegendElement();
const legendElement = createLegendElement();
const useLabels = chart instanceof Chartist.Pie && chart.data.labels && chart.data.labels.length;
const legendNames = getLegendNames(useLabels);
const seriesMetadata = initSeriesMetadata(useLabels);
const legends: any = [];
// Check if given class names are viable to append to legends
const classNamesViable = Array.isArray(options.classNames) && options.classNames.length === legendNames.length;
// Loop through all legends to set each name in a list item.
legendNames.forEach(function (legend: any, i: any) {
const legendText = legend.name || legend;
const legendSeries = legend.series || [i];
const li = createNameElement(i, legendText, classNamesViable);
legendElement.appendChild(li);
legendSeries.forEach(function(seriesIndex: any) {
seriesMetadata[seriesIndex].legend = i;
});
legends.push({
text: legendText,
series: legendSeries,
active: true
});
});
chart.on('created', function (data: any) {
appendLegendToDOM(legendElement);
});
if (options.clickable) {
setSeriesClassNames();
addClickHandler(legendElement, legends, seriesMetadata, useLabels);
}
};
};
Chartist.plugins.tooltip = function (options: any) {
options = Chartist.extend({}, defaultOptions, options);
return function tooltip(chart: any) {
let tooltipSelector = options.pointClass;
if (chart.constructor.name === Chartist.Bar.prototype.constructor.name) {
tooltipSelector = 'ct-bar';
} else if (chart.constructor.name === Chartist.Pie.prototype.constructor.name) {
// Added support for donut graph
if (chart.options.donut) {
tooltipSelector = 'ct-slice-donut';
} else {
tooltipSelector = 'ct-slice-pie';
}
}
const $chart = chart.container;
let $toolTip = $chart.querySelector('.chartist-tooltip');
if (!$toolTip) {
$toolTip = document.createElement('div');
$toolTip.className = (!options.class) ? 'chartist-tooltip' : 'chartist-tooltip ' + options.class;
if (!options.appendToBody) {
$chart.appendChild($toolTip);
} else {
document.body.appendChild($toolTip);
}
}
let height = $toolTip.offsetHeight;
let width = $toolTip.offsetWidth;
hide($toolTip);
function on(event: any, selector: any, callback: any) {
$chart.addEventListener(event, function (e: any) {
if (!selector || hasClass(e.target, selector)) {
callback(e);
}
});
}
on('mouseover', tooltipSelector, function (event: any) {
const $point = event.target;
let tooltipText = '';
const isPieChart = (chart instanceof Chartist.Pie) ? $point : $point.parentNode;
const seriesName = (isPieChart) ? $point.parentNode.getAttribute('ct:meta') || $point.parentNode.getAttribute('ct:series-name') : '';
let meta = $point.getAttribute('ct:meta') || seriesName || '';
const hasMeta = !!meta;
let value = $point.getAttribute('ct:value');
if (options.transformTooltipTextFnc && typeof options.transformTooltipTextFnc === 'function') {
value = options.transformTooltipTextFnc(value);
}
if (options.tooltipFnc && typeof options.tooltipFnc === 'function') {
tooltipText = options.tooltipFnc(meta, value);
} else {
if (options.metaIsHTML) {
const txt = document.createElement('textarea');
txt.innerHTML = meta;
meta = txt.value;
}
meta = '<span class="chartist-tooltip-meta">' + meta + '</span>';
if (hasMeta) {
tooltipText += meta + '<br>';
} else {
// For Pie Charts also take the labels into account
// Could add support for more charts here as well!
if (chart instanceof Chartist.Pie) {
const label = next($point, 'ct-label');
if (label) {
tooltipText += text(label) + '<br>';
}
}
}
if (value) {
if (options.currency) {
if (options.currencyFormatCallback != undefined) {
value = options.currencyFormatCallback(value, options);
} else {
value = options.currency + value.replace(/(\d)(?=(\d{3})+(?:\.\d+)?$)/g, '$1,');
}
}
value = '<span class="chartist-tooltip-value">' + value + '</span>';
tooltipText += value;
}
}
if (tooltipText) {
$toolTip.innerHTML = tooltipText;
setPosition(event);
show($toolTip);
// Remember height and width to avoid wrong position in IE
height = $toolTip.offsetHeight;
width = $toolTip.offsetWidth;
}
});
on('mouseout', tooltipSelector, function () {
hide($toolTip);
});
on('mousemove', null, function (event: any) {
if (false === options.anchorToPoint) {
setPosition(event);
}
});
function setPosition(event: any) {
height = height || $toolTip.offsetHeight;
width = width || $toolTip.offsetWidth;
const offsetX = - width / 2 + options.tooltipOffset.x
const offsetY = - height + options.tooltipOffset.y;
let anchorX, anchorY;
if (!options.appendToBody) {
const box = $chart.getBoundingClientRect();
const left = event.pageX - box.left - window.pageXOffset ;
const top = event.pageY - box.top - window.pageYOffset ;
if (true === options.anchorToPoint && event.target.x2 && event.target.y2) {
anchorX = parseInt(event.target.x2.baseVal.value);
anchorY = parseInt(event.target.y2.baseVal.value);
}
$toolTip.style.top = (anchorY || top) + offsetY + 'px';
$toolTip.style.left = (anchorX || left) + offsetX + 'px';
} else {
$toolTip.style.top = event.pageY + offsetY + 'px';
$toolTip.style.left = event.pageX + offsetX + 'px';
}
}
}
};
function show(element: any) {
if (!hasClass(element, 'tooltip-show')) {
element.className = element.className + ' tooltip-show';
}
}
function hide(element: any) {
const regex = new RegExp('tooltip-show' + '\\s*', 'gi');
element.className = element.className.replace(regex, '').trim();
}
function hasClass(element: any, className: any) {
return (' ' + element.getAttribute('class') + ' ').indexOf(' ' + className + ' ') > -1;
}
function next(element: any, className: any) {
do {
element = element.nextSibling;
} while (element && !hasClass(element, className));
return element;
}
function text(element: any) {
return element.innerText || element.textContent;
}

View File

@ -0,0 +1,108 @@
<div class="container" style="max-width: 100%;">
<!--
<ul class="nav nav-pills" id="myTab" role="tablist">
<li class="nav-item">
<a class="nav-link" routerLinkActive="active" routerLink="mempool" role="tab">Mempool</a>
</li>
<li class="nav-item">
<a class="nav-link" routerLinkActive="active" routerLink="blocks" role="tab">Blocks</a>
</li>
</ul>
<br/>
-->
<div class="row">
<div class="col-lg-12" *ngIf="loading">
<div class="text-center">
<h3>Loading graphs...</h3>
<br>
<div class="spinner-border text-light"></div>
</div>
</div>
<div class="col-lg-12">
<div class="card mb-3" *ngIf="mempoolVsizeFeesData">
<div class="card-header">
<i class="fa fa-area-chart"></i> Mempool by vbytes (satoshis/vbyte)
<form [formGroup]="radioGroupForm" style="float: right;">
<div class="spinner-border text-light bootstrap-spinner" *ngIf="spinnerLoading"></div>
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'2h'" [routerLink]="['/graphs']" fragment="2h"> 2H (LIVE)
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'24h'" [routerLink]="['/graphs']" fragment="24h"> 24H
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'1w'" [routerLink]="['/graphs']" fragment="1w"> 1W
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'1m'" [routerLink]="['/graphs']" fragment="1m"> 1M
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'3m'" [routerLink]="['/graphs']" fragment="3m"> 3M
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'6m'" [routerLink]="['/graphs']" fragment="6m"> 6M
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'1y'" disabled> 1Y
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'all'" disabled> All
</label>
</div>
</form>
</div>
<div class="card-body">
<div style="height: 600px;">
<app-chartist
[data]="mempoolVsizeFeesData"
[type]="'Line'"
[options]="mempoolVsizeFeesOptions">
</app-chartist>
</div>
</div>
</div>
</div>
<div class="col-lg-12">
<div class="card mb-3" *ngIf="mempoolTransactionsWeightPerSecondData">
<div class="card-header">
<i class="fa fa-area-chart"></i> Transactions weight per second (vBytes/s)</div>
<div class="card-body">
<div style="height: 600px;">
<app-chartist
[data]="mempoolTransactionsWeightPerSecondData"
[type]="'Line'"
[options]="transactionsWeightPerSecondOptions">
</app-chartist>
</div>
</div>
</div>
</div>
<div class="col-lg-12">
<div class="card mb-3" *ngIf="mempoolTransactionsPerSecondData">
<div class="card-header">
<i class="fa fa-area-chart"></i> Transactions per second (tx/s)</div>
<div class="card-body">
<div style="height: 600px;">
<app-chartist
[data]="mempoolTransactionsPerSecondData"
[type]="'Line'"
[options]="transactionsPerSecondOptions">
</app-chartist>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,16 @@
.card-header {
border-bottom: 0;
background-color: none;
font-size: 20px;
}
.card {
background-color: transparent;
border: 0;
}
.bootstrap-spinner {
width: 22px;
height: 22px;
margin-right: 10px;
}

View File

@ -0,0 +1,274 @@
import { Component, OnInit, LOCALE_ID, Inject } from '@angular/core';
import { ApiService } from '../services/api.service';
import { formatDate } from '@angular/common';
import { BytesPipe } from '../shared/pipes/bytes-pipe/bytes.pipe';
import * as Chartist from 'chartist';
import { FormGroup, FormBuilder } from '@angular/forms';
import { IMempoolStats } from '../blockchain/interfaces';
import { Subject, of, merge} from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-statistics',
templateUrl: './statistics.component.html',
styleUrls: ['./statistics.component.scss']
})
export class StatisticsComponent implements OnInit {
loading = true;
spinnerLoading = false;
mempoolStats: IMempoolStats[] = [];
mempoolVsizeFeesData: any;
mempoolUnconfirmedTransactionsData: any;
mempoolTransactionsPerSecondData: any;
mempoolTransactionsWeightPerSecondData: any;
mempoolVsizeFeesOptions: any;
transactionsPerSecondOptions: any;
transactionsWeightPerSecondOptions: any;
radioGroupForm: FormGroup;
reloadData$: Subject<any> = new Subject();
constructor(
private apiService: ApiService,
@Inject(LOCALE_ID) private locale: string,
private bytesPipe: BytesPipe,
private formBuilder: FormBuilder,
private route: ActivatedRoute,
) {
this.radioGroupForm = this.formBuilder.group({
'dateSpan': '2h'
});
}
ngOnInit() {
const now = new Date();
const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(),
Math.floor(now.getMinutes() / 1) * 1 + 1, 0, 0);
const difference = nextInterval.getTime() - now.getTime();
setTimeout(() => {
setInterval(() => {
if (this.radioGroupForm.controls['dateSpan'].value === '2h') {
this.reloadData$.next();
}
}, 60 * 1000);
}, difference + 1000); // Next whole minute + 1 second
const labelInterpolationFnc = (value: any, index: any) => {
const nr = 6;
switch (this.radioGroupForm.controls['dateSpan'].value) {
case '2h':
case '24h':
value = formatDate(value, 'HH:mm', this.locale);
break;
case '1w':
value = formatDate(value, 'dd/MM HH:mm', this.locale);
break;
case '1m':
case '3m':
case '6m':
value = formatDate(value, 'dd/MM', this.locale);
}
return index % nr === 0 ? value : null;
};
this.mempoolVsizeFeesOptions = {
showArea: true,
showLine: false,
fullWidth: true,
showPoint: false,
low: 0,
axisX: {
labelInterpolationFnc: labelInterpolationFnc,
offset: 40
},
axisY: {
labelInterpolationFnc: (value: number): any => {
return this.bytesPipe.transform(value);
},
offset: 160
},
plugins: [
Chartist.plugins.ctTargetLine({
value: 1000000
}),
Chartist.plugins.legend({
legendNames: [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200,
250, 300, 350, 400, 500, 600].map((sats, i, arr) => {
if (sats === 600) {
return '500+';
}
if (i === 0) {
return '1 sat/vbyte';
}
return arr[i - 1] + ' - ' + sats;
})
})
]
};
this.transactionsWeightPerSecondOptions = {
showArea: false,
showLine: true,
showPoint: false,
low: 0,
axisY: {
offset: 40
},
axisX: {
labelInterpolationFnc: labelInterpolationFnc
},
plugins: [
Chartist.plugins.ctTargetLine({
value: 1667
}),
]
};
this.transactionsPerSecondOptions = {
showArea: false,
showLine: true,
showPoint: false,
low: 0,
axisY: {
offset: 40
},
axisX: {
labelInterpolationFnc: labelInterpolationFnc
},
};
this.route
.fragment
.subscribe((fragment) => {
if (['2h', '24h', '1w', '1m', '3m', '6m'].indexOf(fragment) > -1) {
this.radioGroupForm.controls['dateSpan'].setValue(fragment);
}
});
merge(
of(''),
this.reloadData$,
this.radioGroupForm.controls['dateSpan'].valueChanges
.pipe(
tap(() => {
this.mempoolStats = [];
})
)
)
.pipe(
switchMap(() => {
this.spinnerLoading = true;
if (this.radioGroupForm.controls['dateSpan'].value === '6m') {
return this.apiService.list6MStatistics$();
}
if (this.radioGroupForm.controls['dateSpan'].value === '3m') {
return this.apiService.list3MStatistics$();
}
if (this.radioGroupForm.controls['dateSpan'].value === '1m') {
return this.apiService.list1MStatistics$();
}
if (this.radioGroupForm.controls['dateSpan'].value === '1w') {
return this.apiService.list1WStatistics$();
}
if (this.radioGroupForm.controls['dateSpan'].value === '24h') {
return this.apiService.list24HStatistics$();
}
if (this.radioGroupForm.controls['dateSpan'].value === '2h' && !this.mempoolStats.length) {
return this.apiService.list2HStatistics$();
}
const lastId = this.mempoolStats[0].id;
return this.apiService.listLiveStatistics$(lastId);
})
)
.subscribe((mempoolStats) => {
let hasChange = false;
if (this.radioGroupForm.controls['dateSpan'].value === '2h' && this.mempoolStats.length) {
if (mempoolStats.length) {
this.mempoolStats = mempoolStats.concat(this.mempoolStats);
this.mempoolStats = this.mempoolStats.slice(0, this.mempoolStats.length - mempoolStats.length);
hasChange = true;
}
} else {
this.mempoolStats = mempoolStats;
hasChange = true;
}
if (hasChange) {
this.handleNewMempoolData(this.mempoolStats.concat([]));
}
this.loading = false;
this.spinnerLoading = false;
});
}
handleNewMempoolData(mempoolStats: IMempoolStats[]) {
mempoolStats.reverse();
const labels = mempoolStats.map(stats => stats.added);
/** Active admins summed up */
this.mempoolTransactionsPerSecondData = {
labels: labels,
series: [mempoolStats.map((stats) => stats.tx_per_second)],
};
this.mempoolTransactionsWeightPerSecondData = {
labels: labels,
series: [mempoolStats.map((stats) => stats.vbytes_per_second)],
};
const finalArrayVbyte = this.generateArray(mempoolStats);
// Remove the 0-1 fee vbyte since it's practially empty
finalArrayVbyte.shift();
this.mempoolVsizeFeesData = {
labels: labels,
series: finalArrayVbyte
};
}
getTimeToNextTenMinutes(): number {
const now = new Date();
const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(),
Math.floor(now.getMinutes() / 10) * 10 + 10, 0, 0);
return nextInterval.getTime() - now.getTime();
}
generateArray(mempoolStats: IMempoolStats[]) {
const logFees = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200,
250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000];
logFees.reverse();
const finalArray: number[][] = [];
let feesArray: number[] = [];
logFees.forEach((fee) => {
feesArray = [];
mempoolStats.forEach((stats) => {
// @ts-ignore
const theFee = stats['vsize_' + fee];
if (theFee) {
feesArray.push(parseInt(theFee, 10));
} else {
feesArray.push(0);
}
});
if (finalArray.length) {
feesArray = feesArray.map((value, i) => value + finalArray[finalArray.length - 1][i]);
}
finalArray.push(feesArray);
});
finalArray.reverse();
return finalArray;
}
}

View File

@ -0,0 +1,26 @@
<div class="txBubble" *ngIf="tx">
<span class="txBubbleText" ngClass="arrow-{{ arrowPosition }}">
<table style="width: 100%;">
<tr>
<td class="text-left"><b>Transaction hash</b></td>
<td class="text-right"><a href="https://www.blockstream.info/tx/{{ tx?.txid }}" target="_blank">{{ txIdShort }}</a></td>
</tr>
<tr>
<td class="text-left"><b>Fees:</b></td>
<td class="text-right">{{ tx?.fee }} BTC</td>
</tr>
<tr>
<td class="text-left"><b>Fee per vByte:</b></td>
<td class="text-right">{{ tx?.feePerVsize | number : '1.2-2' }} sat/vB</td>
</tr>
</table>
<br />
<span *ngIf="txTrackingBlockHeight === 0">
<button type="button" class="btn btn-danger">Unconfirmed</button>
</span>
<span *ngIf="txTrackingBlockHeight > 0">
<button type="button" class="btn btn-success">{{ confirmations }} confirmation<span *ngIf="confirmations > 1">s</span></button>
</span>
</span>
</div>

View File

@ -0,0 +1,65 @@
.txBubble {
position: relative;
display: inline-block;
border-bottom: 1px dotted #000000;
z-index: 99;
}
.txBubble .txBubbleText {
width: 300px;
background-color: #ffffff;
color: #000;
text-align: center;
border-radius: 6px;
padding: 5px 0;
position: absolute;
z-index: 1;
top: 150%;
left: 50%;
margin-left: -100px;
padding: 10px;
font-size: 14px;
}
.txBubble .txBubbleText::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
margin-left: -10px;
border-width: 10px;
border-style: solid;
border-color: transparent transparent white transparent;
}
.txBubble .arrow-right.txBubbleText::after {
top: calc(50% - 10px);
border-color: transparent transparent transparent white;
right: -20px;
left: auto;
}
.txBubble .arrow-left.txBubbleText::after {
top: calc(50% - 10px);
left: 0;
margin-left: -20px;
border-width: 10px;
border-color: transparent white transparent transparent;
}
.txBubble .arrow-bottom.txBubbleText::after {
bottom: -20px;
left: 50%;
margin-left: -10px;
border-width: 10px;
border-style: solid;
border-color: white transparent transparent transparent;
}
.txBubble .arrow-top-right.txBubbleText::after {
left: 80%;
}
.txBubble .arrow-top-left.txBubbleText::after {
left: 20%;
}

View File

@ -0,0 +1,26 @@
import { Component, OnInit, Input, OnChanges } from '@angular/core';
import { ITransaction } from '../blockchain/interfaces';
@Component({
selector: 'app-tx-bubble',
templateUrl: './tx-bubble.component.html',
styleUrls: ['./tx-bubble.component.scss']
})
export class TxBubbleComponent implements OnChanges {
@Input() tx: ITransaction | null = null;
@Input() txTrackingBlockHeight = 0;
@Input() latestBlockHeight = 0;
@Input() arrowPosition: 'top' | 'right' | 'bottom' | 'top-right' | 'top-left' = 'top';
txIdShort = '';
confirmations = 0;
constructor() { }
ngOnChanges() {
if (this.tx) {
this.txIdShort = this.tx.txid.substring(0, 6) + '...' + this.tx.txid.substring(this.tx.txid.length - 6);
}
this.confirmations = (this.latestBlockHeight - this.txTrackingBlockHeight) + 1;
}
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,41 @@
{
"name": "App",
"icons": [
{
"src": "\/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "\/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "\/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "\/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "\/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "\/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -0,0 +1,9 @@
# This file is currently used by autoprefixer to adjust CSS to support the below specified browsers
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For IE 9-11 support, please uncomment the last line of the file and adjust as needed
> 0.5%
last 2 versions
Firefox ESR
not dead
# IE 9-11

View File

@ -0,0 +1,3 @@
export const environment = {
production: true
};

View File

@ -0,0 +1,15 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false
};
/*
* In development mode, to ignore zone related error stack frames such as
* `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can
* import the following file, but please comment it out in production mode
* because it will have performance impact when throw error
*/
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.

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