Merge pull request #30 from mempool-space/v2

mempool v2 repo merge
This commit is contained in:
wiz 2020-02-26 03:46:02 +09:00 committed by GitHub
commit 3709b652ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
215 changed files with 7713 additions and 14216 deletions

View file

@ -4,8 +4,9 @@ RUN mkdir /mempool.space/
COPY ./backend /mempool.space/backend/ COPY ./backend /mempool.space/backend/
COPY ./frontend /mempool.space/frontend/ COPY ./frontend /mempool.space/frontend/
COPY ./mariadb-structure.sql /mempool.space/mariadb-structure.sql COPY ./mariadb-structure.sql /mempool.space/mariadb-structure.sql
#COPY ./nginx.conf /mempool.space/nginx.conf
RUN apk add mariadb mariadb-client git nginx npm rsync bash RUN apk add mariadb mariadb-client jq git nginx npm rsync
RUN mysql_install_db --user=mysql --datadir=/var/lib/mysql/ RUN mysql_install_db --user=mysql --datadir=/var/lib/mysql/
RUN /usr/bin/mysqld_safe --datadir='/var/lib/mysql/'& \ RUN /usr/bin/mysqld_safe --datadir='/var/lib/mysql/'& \
@ -31,8 +32,12 @@ ENV DB_PORT 3306
ENV DB_USER mempool ENV DB_USER mempool
ENV DB_PASSWORD mempool ENV DB_PASSWORD mempool
ENV DB_DATABASE mempool ENV DB_DATABASE mempool
ENV HTTP_PORT 80
ENV API_ENDPOINT /api/v1/ ENV API_ENDPOINT /api/v1/
ENV CHAT_SSL_ENABLED false ENV CHAT_SSL_ENABLED false
#ENV CHAT_SSL_PRIVKEY
#ENV CHAT_SSL_CERT
#ENV CHAT_SSL_CHAIN
ENV MEMPOOL_REFRESH_RATE_MS 500 ENV MEMPOOL_REFRESH_RATE_MS 500
ENV INITIAL_BLOCK_AMOUNT 8 ENV INITIAL_BLOCK_AMOUNT 8
ENV DEFAULT_PROJECTED_BLOCKS_AMOUNT 3 ENV DEFAULT_PROJECTED_BLOCKS_AMOUNT 3
@ -42,8 +47,8 @@ ENV BITCOIN_NODE_PORT 8332
ENV BITCOIN_NODE_USER bitcoinuser ENV BITCOIN_NODE_USER bitcoinuser
ENV BITCOIN_NODE_PASS bitcoinpass ENV BITCOIN_NODE_PASS bitcoinpass
ENV TX_PER_SECOND_SPAN_SECONDS 150 ENV TX_PER_SECOND_SPAN_SECONDS 150
ENV BACKEND_API bitcoind
ENV ELECTRS_API_URL https://www.blockstream.info/api #RUN echo "mysqld_safe& sleep 20 && cd /mempool.space/backend && rm -f mempool-config.json && rm -f cache.json && touch cache.json && jq -n env > mempool-config.json && node dist/index.js" > /entrypoint.sh
RUN cd /mempool.space/frontend/ && \ RUN cd /mempool.space/frontend/ && \
npm run build && \ npm run build && \

View file

@ -1,25 +1,19 @@
{ {
"ENV": "dev", "HTTP_PORT": 8999,
"DB_HOST": "localhost", "DB_HOST": "localhost",
"DB_PORT": 3306, "DB_PORT": 3306,
"DB_USER": "mempool", "DB_USER": "mempool",
"DB_PASSWORD": "mempool", "DB_PASSWORD": "mempool",
"DB_DATABASE": "mempool", "DB_DATABASE": "mempool",
"HTTP_PORT": 3000,
"API_ENDPOINT": "/api/v1/", "API_ENDPOINT": "/api/v1/",
"CHAT_SSL_ENABLED": false, "ELECTRS_POLL_RATE_MS": 2000,
"CHAT_SSL_PRIVKEY": "", "MEMPOOL_REFRESH_RATE_MS": 2000,
"CHAT_SSL_CERT": "",
"CHAT_SSL_CHAIN": "",
"MEMPOOL_REFRESH_RATE_MS": 500,
"INITIAL_BLOCK_AMOUNT": 8,
"DEFAULT_PROJECTED_BLOCKS_AMOUNT": 3, "DEFAULT_PROJECTED_BLOCKS_AMOUNT": 3,
"KEEP_BLOCK_AMOUNT": 24, "KEEP_BLOCK_AMOUNT": 24,
"BITCOIN_NODE_HOST": "localhost", "INITIAL_BLOCK_AMOUNT": 8,
"BITCOIN_NODE_PORT": 8332, "TX_PER_SECOND_SPAN_SECONDS": 150,
"BITCOIN_NODE_USER": "", "ELECTRS_API_URL": "https://www.blockstream.info/testnet/api",
"BITCOIN_NODE_PASS": "", "SSL": false,
"BACKEND_API": "bitcoind", "SSL_CERT_FILE_PATH": "/etc/letsencrypt/live/mysite/fullchain.pem",
"ELECTRS_API_URL": "https://www.blockstream.info/api", "SSL_KEY_FILE_PATH": "/etc/letsencrypt/live/mysite/privkey.pem"
"TX_PER_SECOND_SPAN_SECONDS": 150
} }

View file

@ -1,31 +1,26 @@
{ {
"name": "mempool-backend", "name": "mempool-space-explorer-backend",
"version": "1.0.0", "version": "1.0.0",
"description": "Bitcoin Mempool Visualizer", "description": "Mempool space backend",
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"start": "npm run build && node dist/index.js" "start": "npm run build && node dist/index.js"
}, },
"author": {
"name": "Simon Lindh",
"url": "https://github.com/mempool-space/mempool.space"
},
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bitcoin": "^3.0.1", "compression": "^1.7.4",
"compression": "^1.7.3", "express": "^4.17.1",
"express": "^4.16.3",
"mysql2": "^1.6.1", "mysql2": "^1.6.1",
"request": "^2.88.0", "request": "^2.88.0",
"ws": "^6.0.0" "ws": "^7.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.16.0", "@types/compression": "^1.0.1",
"@types/mysql2": "github:types/mysql2", "@types/express": "^4.17.2",
"@types/request": "^2.48.2", "@types/request": "^2.48.2",
"@types/ws": "^6.0.1", "@types/ws": "^6.0.4",
"tslint": "^5.11.0", "tslint": "^5.11.0",
"typescript": "^3.1.1" "typescript": "~3.6.4"
} }
} }

View file

@ -1,19 +0,0 @@
import { IMempoolInfo, ITransaction, IBlock } from '../../interfaces';
export interface AbstractBitcoinApi {
getMempoolInfo(): Promise<IMempoolInfo>;
getRawMempool(): Promise<ITransaction['txid'][]>;
getRawTransaction(txId: string): Promise<ITransaction>;
getBlockCount(): Promise<number>;
getBlockAndTransactions(hash: string): Promise<IBlock>;
getBlockHash(height: number): Promise<string>;
getBlock(hash: string): Promise<IBlock>;
getBlockTransactions(hash: string): Promise<IBlock>;
getBlockTransactionsFromIndex(hash: string, index: number): Promise<IBlock>;
getBlocks(): Promise<string>;
getBlocksFromHeight(height: number): Promise<string>;
getAddress(address: string): Promise<IBlock>;
getAddressTransactions(address: string): Promise<IBlock>;
getAddressTransactionsFromLastSeenTxid(address: string, lastSeenTxid: string): Promise<IBlock>;
}

View file

@ -1,16 +0,0 @@
const config = require('../../../mempool-config.json');
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
import BitcoindApi from './bitcoind-api';
import ElectrsApi from './electrs-api';
function factory(): AbstractBitcoinApi {
switch (config.BACKEND_API) {
case 'electrs':
return new ElectrsApi();
case 'bitcoind':
default:
return new BitcoindApi();
}
}
export default factory();

View file

@ -1,110 +0,0 @@
const config = require('../../../mempool-config.json');
import * as bitcoin from 'bitcoin';
import { ITransaction, IMempoolInfo, IBlock } from '../../interfaces';
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
class BitcoindApi implements AbstractBitcoinApi {
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);
});
});
}
getBlockAndTransactions(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);
});
});
}
getBlock(hash: string): Promise<IBlock> {
throw new Error('Method not implemented.');
}
getBlocks(): Promise<string> {
throw new Error('Method not implemented.');
}
getBlocksFromHeight(height: number): Promise<string> {
throw new Error('Method not implemented.');
}
getBlockTransactions(hash: string): Promise<IBlock> {
throw new Error('Method not implemented.');
}
getBlockTransactionsFromIndex(hash: string, index: number): Promise<IBlock> {
throw new Error('Method not implemented.');
}
getAddress(address: string): Promise<IBlock> {
throw new Error('Method not implemented.');
}
getAddressTransactions(address: string): Promise<IBlock> {
throw new Error('Method not implemented.');
}
getAddressTransactionsFromLastSeenTxid(address: string, lastSeenTxid: string): Promise<IBlock> {
throw new Error('Method not implemented.');
}
}
export default BitcoindApi;

View file

@ -1,14 +1,13 @@
const config = require('../../../mempool-config.json'); const config = require('../../../mempool-config.json');
import { ITransaction, IMempoolInfo, IBlock } from '../../interfaces'; import { Transaction, Block, MempoolInfo } from '../../interfaces';
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
import * as request from 'request'; import * as request from 'request';
class ElectrsApi implements AbstractBitcoinApi { class ElectrsApi {
constructor() { constructor() {
} }
getMempoolInfo(): Promise<IMempoolInfo> { getMempoolInfo(): Promise<MempoolInfo> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/mempool', { json: true, timeout: 10000 }, (err, res, response) => { request(config.ELECTRS_API_URL + '/mempool', { json: true, timeout: 10000 }, (err, res, response) => {
if (err) { if (err) {
@ -16,6 +15,10 @@ class ElectrsApi implements AbstractBitcoinApi {
} else if (res.statusCode !== 200) { } else if (res.statusCode !== 200) {
reject(response); reject(response);
} else { } else {
if (!response.count) {
reject('Empty data');
return;
}
resolve({ resolve({
size: response.count, size: response.count,
bytes: response.vsize, bytes: response.vsize,
@ -25,9 +28,9 @@ class ElectrsApi implements AbstractBitcoinApi {
}); });
} }
getRawMempool(): Promise<ITransaction['txid'][]> { getRawMempool(): Promise<Transaction['txid'][]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/mempool/txids', { json: true, timeout: 10000 }, (err, res, response) => { request(config.ELECTRS_API_URL + '/mempool/txids', { json: true, timeout: 10000, forever: true }, (err, res, response) => {
if (err) { if (err) {
reject(err); reject(err);
} else if (res.statusCode !== 200) { } else if (res.statusCode !== 200) {
@ -39,24 +42,21 @@ class ElectrsApi implements AbstractBitcoinApi {
}); });
} }
getRawTransaction(txId: string): Promise<ITransaction> { getRawTransaction(txId: string): Promise<Transaction> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/tx/' + txId, { json: true, timeout: 10000 }, (err, res, response) => { request(config.ELECTRS_API_URL + '/tx/' + txId, { json: true, timeout: 10000, forever: true }, (err, res, response) => {
if (err) { if (err) {
reject(err); reject(err);
} else if (res.statusCode !== 200) { } else if (res.statusCode !== 200) {
reject(response); reject(response);
} else { } else {
response.vsize = Math.round(response.weight / 4);
response.fee = response.fee / 100000000;
response.blockhash = response.status.block_hash;
resolve(response); resolve(response);
} }
}); });
}); });
} }
getBlockCount(): Promise<number> { getBlockHeightTip(): Promise<number> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/blocks/tip/height', { json: true, timeout: 10000 }, (err, res, response) => { request(config.ELECTRS_API_URL + '/blocks/tip/height', { json: true, timeout: 10000 }, (err, res, response) => {
if (err) { if (err) {
@ -70,29 +70,15 @@ class ElectrsApi implements AbstractBitcoinApi {
}); });
} }
getBlockAndTransactions(hash: string): Promise<IBlock> { getTxIdsForBlock(hash: string): Promise<string[]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/block/' + hash, { json: true, timeout: 10000 }, (err, res, response) => { request(config.ELECTRS_API_URL + '/block/' + hash + '/txids', { json: true, timeout: 10000 }, (err, res, response) => {
if (err) { if (err) {
reject(err); reject(err);
} else if (res.statusCode !== 200) { } else if (res.statusCode !== 200) {
reject(response); reject(response);
} else { } else {
request(config.ELECTRS_API_URL + '/block/' + hash + '/txids', { json: true, timeout: 10000 }, (err2, res2, response2) => { resolve(response);
if (err2) {
reject(err2);
} else if (res.statusCode !== 200) {
reject(response);
} else {
const block = response;
block.hash = hash;
block.nTx = block.tx_count;
block.time = block.timestamp;
block.tx = response2;
resolve(block);
}
});
} }
}); });
}); });
@ -112,20 +98,6 @@ class ElectrsApi implements AbstractBitcoinApi {
}); });
} }
getBlocks(): Promise<string> {
return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/blocks', { json: true, timeout: 10000 }, (err, res, response) => {
if (err) {
reject(err);
} else if (res.statusCode !== 200) {
reject(response);
} else {
resolve(response);
}
});
});
}
getBlocksFromHeight(height: number): Promise<string> { getBlocksFromHeight(height: number): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/blocks/' + height, { json: true, timeout: 10000 }, (err, res, response) => { request(config.ELECTRS_API_URL + '/blocks/' + height, { json: true, timeout: 10000 }, (err, res, response) => {
@ -140,7 +112,7 @@ class ElectrsApi implements AbstractBitcoinApi {
}); });
} }
getBlock(hash: string): Promise<IBlock> { getBlock(hash: string): Promise<Block> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/block/' + hash, { json: true, timeout: 10000 }, (err, res, response) => { request(config.ELECTRS_API_URL + '/block/' + hash, { json: true, timeout: 10000 }, (err, res, response) => {
if (err) { if (err) {
@ -153,77 +125,6 @@ class ElectrsApi implements AbstractBitcoinApi {
}); });
}); });
} }
getBlockTransactions(hash: string): Promise<IBlock> {
return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/block/' + hash + '/txs', { json: true, timeout: 10000 }, (err, res, response) => {
if (err) {
reject(err);
} else if (res.statusCode !== 200) {
reject(response);
} else {
resolve(response);
}
});
});
}
getBlockTransactionsFromIndex(hash: string, index: number): Promise<IBlock> {
return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/block/' + hash + '/txs/' + index, { json: true, timeout: 10000 }, (err, res, response) => {
if (err) {
reject(err);
} else if (res.statusCode !== 200) {
reject(response);
} else {
resolve(response);
}
});
});
}
getAddress(address: string): Promise<IBlock> {
return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/address/' + address, { json: true, timeout: 10000 }, (err, res, response) => {
if (err) {
reject(err);
} else if (res.statusCode !== 200) {
reject(response);
} else {
resolve(response);
}
});
});
}
getAddressTransactions(address: string): Promise<IBlock> {
return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/address/' + address + '/txs', { json: true, timeout: 10000 }, (err, res, response) => {
if (err) {
reject(err);
} else if (res.statusCode !== 200) {
reject(response);
} else {
resolve(response);
}
});
});
}
getAddressTransactionsFromLastSeenTxid(address: string, lastSeenTxid: string): Promise<IBlock> {
return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/address/' + address + '/txs/chain/' + lastSeenTxid,
{ json: true, timeout: 10000 }, (err, res, response) => {
if (err) {
reject(err);
} else if (res.statusCode !== 200) {
reject(response);
} else {
resolve(response);
}
});
});
}
} }
export default ElectrsApi; export default new ElectrsApi();

View file

@ -1,206 +1,85 @@
const config = require('../../mempool-config.json'); const config = require('../../mempool-config.json');
import bitcoinApi from './bitcoin/bitcoin-api-factory'; import bitcoinApi from './bitcoin/electrs-api';
import { DB } from '../database';
import { IBlock, ITransaction } from '../interfaces';
import memPool from './mempool'; import memPool from './mempool';
import { Block, TransactionExtended } from '../interfaces';
class Blocks { class Blocks {
private blocks: IBlock[] = []; private blocks: Block[] = [];
private newBlockCallback: Function | undefined;
private currentBlockHeight = 0; private currentBlockHeight = 0;
private newBlockCallback: Function = () => {};
constructor() { constructor() { }
setInterval(this.$clearOldTransactionsAndBlocksFromDatabase.bind(this), 86400000);
public getBlocks(): Block[] {
return this.blocks;
} }
public setNewBlockCallback(fn: Function) { public setNewBlockCallback(fn: Function) {
this.newBlockCallback = fn; 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() { public async updateBlocks() {
try { try {
const blockCount = await bitcoinApi.getBlockCount(); const blockHeightTip = await bitcoinApi.getBlockHeightTip();
if (this.blocks.length === 0) { if (this.blocks.length === 0) {
this.currentBlockHeight = blockCount - config.INITIAL_BLOCK_AMOUNT; this.currentBlockHeight = blockHeightTip - config.INITIAL_BLOCK_AMOUNT;
} else { } else {
this.currentBlockHeight = this.blocks[this.blocks.length - 1].height; this.currentBlockHeight = this.blocks[this.blocks.length - 1].height;
} }
while (this.currentBlockHeight < blockCount) { while (this.currentBlockHeight < blockHeightTip) {
this.currentBlockHeight++; if (this.currentBlockHeight === 0) {
this.currentBlockHeight = blockHeightTip;
let block: IBlock | undefined;
const storedBlock = await this.$getBlockFromDatabase(this.currentBlockHeight);
if (storedBlock) {
block = storedBlock;
} else { } else {
const blockHash = await bitcoinApi.getBlockHash(this.currentBlockHeight); this.currentBlockHeight++;
block = await bitcoinApi.getBlockAndTransactions(blockHash); console.log(`New block found (#${this.currentBlockHeight})!`);
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 {
console.log(`Fetching block tx ${i} of ${block.tx.length}`);
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));
console.log(`New block found (#${this.currentBlockHeight})! `
+ `${found} of ${block.tx.length} found in mempool. ${notFound} not found.`);
if (this.newBlockCallback) {
this.newBlockCallback(block);
}
this.$saveBlockToDatabase(block);
this.$saveTransactionsToDatabase(block.height, transactions);
} }
const blockHash = await bitcoinApi.getBlockHash(this.currentBlockHeight);
const block = await bitcoinApi.getBlock(blockHash);
const txIds = await bitcoinApi.getTxIdsForBlock(blockHash);
const mempool = memPool.getMempool();
let found = 0;
let notFound = 0;
const transactions: TransactionExtended[] = [];
for (let i = 1; i < txIds.length; i++) {
if (mempool[txIds[i]]) {
transactions.push(mempool[txIds[i]]);
found++;
} else {
console.log(`Fetching block tx ${i} of ${txIds.length}`);
const tx = await memPool.getTransactionExtended(txIds[i]);
if (tx) {
transactions.push(tx);
}
notFound++;
}
}
console.log(`${found} of ${txIds.length} found in mempool. ${notFound} not found.`);
transactions.sort((a, b) => b.feePerVsize - a.feePerVsize);
block.medianFee = transactions.length ? this.median(transactions.map((tx) => tx.feePerVsize)) : 0;
block.feeRange = transactions.length ? this.getFeesInRange(transactions, 8) : [0, 0];
this.blocks.push(block); this.blocks.push(block);
if (this.blocks.length > config.KEEP_BLOCK_AMOUNT) { if (this.blocks.length > config.KEEP_BLOCK_AMOUNT) {
this.blocks.shift(); this.blocks.shift();
} }
this.newBlockCallback(block, txIds, transactions);
} }
} catch (err) { } catch (err) {
console.log('Error getBlockCount', err); console.log('updateBlocks error', 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 async $clearOldTransactionsAndBlocksFromDatabase() {
try {
const connection = await DB.pool.getConnection();
let query = `DELETE FROM blocks WHERE height < ?`;
await connection.query<any>(query, [this.currentBlockHeight - config.KEEP_BLOCK_AMOUNT]);
query = `DELETE FROM transactions WHERE blockheight < ?`;
await connection.query<any>(query, [this.currentBlockHeight - config.KEEP_BLOCK_AMOUNT]);
connection.release();
} catch (e) {
console.log('$clearOldTransactionsFromDatabase() error', e);
} }
} }
private median(numbers: number[]) { private median(numbers: number[]) {
if (!numbers.length) { return 0; }
let medianNr = 0; let medianNr = 0;
const numsLen = numbers.length; const numsLen = numbers.length;
numbers.sort(); numbers.sort();
@ -211,6 +90,20 @@ class Blocks {
} }
return medianNr; return medianNr;
} }
private getFeesInRange(transactions: any[], rangeLength: number) {
const arr = [transactions[transactions.length - 1].feePerVsize];
const chunk = 1 / (rangeLength - 1);
let itemsToAdd = rangeLength - 2;
while (itemsToAdd > 0) {
arr.push(transactions[Math.floor(transactions.length * chunk * itemsToAdd)].feePerVsize);
itemsToAdd--;
}
arr.push(transactions[0].feePerVsize);
return arr;
}
} }
export default new Blocks(); export default new Blocks();

View file

@ -1,11 +1,11 @@
import projectedBlocks from './projected-blocks'; import projectedBlocks from './mempool-blocks';
import { DB } from '../database'; import { DB } from '../database';
class FeeApi { class FeeApi {
constructor() { } constructor() { }
public getRecommendedFee() { public getRecommendedFee() {
const pBlocks = projectedBlocks.getProjectedBlocks(); const pBlocks = projectedBlocks.getMempoolBlocks();
if (!pBlocks.length) { if (!pBlocks.length) {
return { return {
'fastestFee': 0, 'fastestFee': 0,
@ -15,7 +15,7 @@ class FeeApi {
} }
let firstMedianFee = Math.ceil(pBlocks[0].medianFee); let firstMedianFee = Math.ceil(pBlocks[0].medianFee);
if (pBlocks.length === 1 && pBlocks[0].blockWeight <= 2000000) { if (pBlocks.length === 1 && pBlocks[0].blockVSize <= 500000) {
firstMedianFee = 1; firstMedianFee = 1;
} }

View file

@ -0,0 +1,97 @@
const config = require('../../mempool-config.json');
import { MempoolBlock, TransactionExtended } from '../interfaces';
class MempoolBlocks {
private mempoolBlocks: MempoolBlock[] = [];
constructor() {}
public getMempoolBlocks(): MempoolBlock[] {
return this.mempoolBlocks;
}
public updateMempoolBlocks(memPool: { [txid: string]: TransactionExtended }): void {
const latestMempool = memPool;
const memPoolArray: TransactionExtended[] = [];
for (const i in latestMempool) {
if (latestMempool.hasOwnProperty(i)) {
memPoolArray.push(latestMempool[i]);
}
}
memPoolArray.sort((a, b) => b.feePerVsize - a.feePerVsize);
const transactionsSorted = memPoolArray.filter((tx) => tx.feePerVsize);
this.mempoolBlocks = this.calculateMempoolBlocks(transactionsSorted);
}
private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlock[] {
const mempoolBlocks: MempoolBlock[] = [];
let blockWeight = 0;
let blockSize = 0;
let transactions: TransactionExtended[] = [];
transactionsSorted.forEach((tx) => {
if (blockWeight + tx.vsize < 1000000 || mempoolBlocks.length === config.DEFAULT_PROJECTED_BLOCKS_AMOUNT) {
blockWeight += tx.vsize;
blockSize += tx.size;
transactions.push(tx);
} else {
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length));
blockWeight = 0;
blockSize = 0;
transactions = [];
}
});
if (transactions.length) {
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length));
}
return mempoolBlocks;
}
private dataToMempoolBlocks(transactions: TransactionExtended[], blockSize: number, blockVSize: number, blocksIndex: number): MempoolBlock {
let rangeLength = 3;
if (blocksIndex === 0) {
rangeLength = 8;
}
if (transactions.length > 4000) {
rangeLength = 5;
} else if (transactions.length > 10000) {
rangeLength = 8;
} else if (transactions.length > 25000) {
rangeLength = 10;
}
return {
blockSize: blockSize,
blockVSize: blockVSize,
nTx: transactions.length,
medianFee: this.median(transactions.map((tx) => tx.feePerVsize)),
feeRange: this.getFeesInRange(transactions, rangeLength),
};
}
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;
}
private getFeesInRange(transactions: TransactionExtended[], rangeLength: number) {
const arr = [transactions[transactions.length - 1].feePerVsize];
const chunk = 1 / (rangeLength - 1);
let itemsToAdd = rangeLength - 2;
while (itemsToAdd > 0) {
arr.push(transactions[Math.floor(transactions.length * chunk * itemsToAdd)].feePerVsize);
itemsToAdd--;
}
arr.push(transactions[0].feePerVsize);
return arr;
}
}
export default new MempoolBlocks();

View file

@ -1,10 +1,10 @@
const config = require('../../mempool-config.json'); const config = require('../../mempool-config.json');
import bitcoinApi from './bitcoin/bitcoin-api-factory'; import bitcoinApi from './bitcoin/electrs-api';
import { ITransaction, IMempoolInfo, IMempool } from '../interfaces'; import { MempoolInfo, TransactionExtended, Transaction } from '../interfaces';
class Mempool { class Mempool {
private mempool: IMempool = {}; private mempoolCache: any = {};
private mempoolInfo: IMempoolInfo | undefined; private mempoolInfo: MempoolInfo | undefined;
private mempoolChangedCallback: Function | undefined; private mempoolChangedCallback: Function | undefined;
private txPerSecondArray: number[] = []; private txPerSecondArray: number[] = [];
@ -21,15 +21,26 @@ class Mempool {
this.mempoolChangedCallback = fn; this.mempoolChangedCallback = fn;
} }
public getMempool(): { [txid: string]: ITransaction } { public getMempool(): { [txid: string]: TransactionExtended } {
return this.mempool; return this.mempoolCache;
} }
public setMempool(mempoolData: any) { public setMempool(mempoolData: any) {
this.mempool = mempoolData; this.mempoolCache = mempoolData;
if (this.mempoolChangedCallback && mempoolData) {
this.mempoolChangedCallback(mempoolData);
}
} }
public getMempoolInfo(): IMempoolInfo | undefined { public async updateMemPoolInfo() {
try {
this.mempoolInfo = await bitcoinApi.getMempoolInfo();
} catch (err) {
console.log('Error getMempoolInfo', err);
}
}
public getMempoolInfo(): MempoolInfo | undefined {
return this.mempoolInfo; return this.mempoolInfo;
} }
@ -41,52 +52,13 @@ class Mempool {
return this.vBytesPerSecond; return this.vBytesPerSecond;
} }
public async updateMemPoolInfo() { public async getTransactionExtended(txId: string): Promise<TransactionExtended | false> {
try { try {
this.mempoolInfo = await bitcoinApi.getMempoolInfo(); const transaction: Transaction = await bitcoinApi.getRawTransaction(txId);
} catch (err) { return Object.assign({
console.log('Error getMempoolInfo', err); vsize: transaction.weight / 4,
} feePerVsize: transaction.fee / (transaction.weight / 4),
} }, transaction);
public async getRawTransaction(txId: string, isCoinbase = false): Promise<ITransaction | false> {
try {
const transaction = await bitcoinApi.getRawTransaction(txId);
let totalOut = 0;
transaction.vout.forEach((output) => totalOut += output.value);
if (config.BACKEND_API === 'electrs') {
transaction.feePerWeightUnit = (transaction.fee * 100000000) / transaction.weight || 0;
transaction.feePerVsize = (transaction.fee * 100000000) / (transaction.vsize) || 0;
transaction.totalOut = totalOut / 100000000;
} else {
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');
}
}
}
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) { } catch (e) {
console.log(txId + ' not found'); console.log(txId + ' not found');
return false; return false;
@ -100,12 +72,14 @@ class Mempool {
let txCount = 0; let txCount = 0;
try { try {
const transactions = await bitcoinApi.getRawMempool(); const transactions = await bitcoinApi.getRawMempool();
const diff = transactions.length - Object.keys(this.mempool).length; const diff = transactions.length - Object.keys(this.mempoolCache).length;
for (const tx of transactions) { const newTransactions: TransactionExtended[] = [];
if (!this.mempool[tx]) {
const transaction = await this.getRawTransaction(tx); for (const txid of transactions) {
if (!this.mempoolCache[txid]) {
const transaction = await this.getTransactionExtended(txid);
if (transaction) { if (transaction) {
this.mempool[tx] = transaction; this.mempoolCache[txid] = transaction;
txCount++; txCount++;
this.txPerSecondArray.push(new Date().getTime()); this.txPerSecondArray.push(new Date().getTime());
this.vBytesPerSecondArray.push({ this.vBytesPerSecondArray.push({
@ -114,33 +88,38 @@ class Mempool {
}); });
hasChange = true; hasChange = true;
if (diff > 0) { if (diff > 0) {
console.log('Calculated fee for transaction ' + txCount + ' / ' + diff); console.log('Fetched transaction ' + txCount + ' / ' + diff);
} else { } else {
console.log('Calculated fee for transaction ' + txCount); console.log('Fetched transaction ' + txCount);
} }
newTransactions.push(transaction);
} else { } else {
console.log('Error finding transaction in mempool.'); console.log('Error finding transaction in mempool.');
} }
} }
if ((new Date().getTime()) - start > config.MEMPOOL_REFRESH_RATE_MS * 10) { if ((new Date().getTime()) - start > config.MEMPOOL_REFRESH_RATE_MS) {
break; break;
} }
} }
const newMempool: IMempool = {}; // Replace mempool to clear already confirmed transactions
const newMempool = {};
transactions.forEach((tx) => { transactions.forEach((tx) => {
if (this.mempool[tx]) { if (this.mempoolCache[tx]) {
newMempool[tx] = this.mempool[tx]; newMempool[tx] = this.mempoolCache[tx];
} else { } else {
hasChange = true; hasChange = true;
} }
}); });
this.mempool = newMempool; console.log(`New mempool size: ${Object.keys(newMempool).length} ` +
` Change: ${transactions.length - Object.keys(newMempool).length}`);
this.mempoolCache = newMempool;
if (hasChange && this.mempoolChangedCallback) { if (hasChange && this.mempoolChangedCallback) {
this.mempoolChangedCallback(this.mempool); this.mempoolChangedCallback(this.mempoolCache, newTransactions);
} }
const end = new Date().getTime(); const end = new Date().getTime();

View file

@ -1,102 +0,0 @@
const config = require('../../mempool-config.json');
import { ITransaction, IProjectedBlock, IMempool, IProjectedBlockInternal } from '../interfaces';
class ProjectedBlocks {
private transactionsSorted: ITransaction[] = [];
constructor() {}
public getProjectedBlockFeesForBlock(index: number) {
const projectedBlock = this.getProjectedBlocksInternal()[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]);
}
}
memPoolArray.sort((a, b) => b.feePerWeightUnit - a.feePerWeightUnit);
this.transactionsSorted = memPoolArray.filter((tx) => tx.feePerWeightUnit);
}
public getProjectedBlocks(txId?: string, numberOfBlocks: number = config.DEFAULT_PROJECTED_BLOCKS_AMOUNT): IProjectedBlock[] {
return this.getProjectedBlocksInternal(numberOfBlocks).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
};
});
}
private getProjectedBlocksInternal(numberOfBlocks: number = config.DEFAULT_PROJECTED_BLOCKS_AMOUNT): IProjectedBlockInternal[] {
const projectedBlocks: IProjectedBlockInternal[] = [];
let blockWeight = 0;
let blockSize = 0;
let transactions: ITransaction[] = [];
this.transactionsSorted.forEach((tx) => {
if (blockWeight + tx.vsize * 4 < 4000000 || projectedBlocks.length === numberOfBlocks) {
blockWeight += tx.weight || 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));
}
return projectedBlocks;
}
private dataToProjectedBlock(transactions: ITransaction[], blockSize: number, blockWeight: number): IProjectedBlockInternal {
return {
blockSize: blockSize,
blockWeight: blockWeight,
nTx: transactions.length,
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

@ -1,7 +1,7 @@
import memPool from './mempool'; import memPool from './mempool';
import { DB } from '../database'; import { DB } from '../database';
import { ITransaction, IMempoolStats } from '../interfaces'; import { Statistic, TransactionExtended, OptimizedStatistic } from '../interfaces';
class Statistics { class Statistics {
protected intervalTimer: NodeJS.Timer | undefined; protected intervalTimer: NodeJS.Timer | undefined;
@ -37,42 +37,28 @@ class Statistics {
console.log('Running statistics'); console.log('Running statistics');
let memPoolArray: ITransaction[] = []; let memPoolArray: TransactionExtended[] = [];
for (const i in currentMempool) { for (const i in currentMempool) {
if (currentMempool.hasOwnProperty(i)) { if (currentMempool.hasOwnProperty(i)) {
memPoolArray.push(currentMempool[i]); memPoolArray.push(currentMempool[i]);
} }
} }
// Remove 0 and undefined // Remove 0 and undefined
memPoolArray = memPoolArray.filter((tx) => tx.feePerWeightUnit); memPoolArray = memPoolArray.filter((tx) => tx.feePerVsize);
if (!memPoolArray.length) { if (!memPoolArray.length) {
return; return;
} }
memPoolArray.sort((a, b) => a.feePerWeightUnit - b.feePerWeightUnit); memPoolArray.sort((a, b) => a.feePerVsize - b.feePerVsize);
const totalWeight = memPoolArray.map((tx) => tx.vsize).reduce((acc, curr) => acc + curr) * 4; 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 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, 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]; 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 } = {}; 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) => { memPoolArray.forEach((transaction) => {
for (let i = 0; i < logFees.length; i++) { for (let i = 0; i < logFees.length; i++) {
if ((logFees[i] === 2000 && transaction.feePerVsize >= 2000) || transaction.feePerVsize <= logFees[i]) { if ((logFees[i] === 2000 && transaction.feePerVsize >= 2000) || transaction.feePerVsize <= logFees[i]) {
@ -93,10 +79,7 @@ class Statistics {
vbytes_per_second: Math.round(vBytesPerSecond), vbytes_per_second: Math.round(vBytesPerSecond),
mempool_byte_weight: totalWeight, mempool_byte_weight: totalWeight,
total_fee: totalFee, total_fee: totalFee,
fee_data: JSON.stringify({ fee_data: '',
'wu': weightUnitFees,
'vsize': weightVsizeFees
}),
vsize_1: weightVsizeFees['1'] || 0, vsize_1: weightVsizeFees['1'] || 0,
vsize_2: weightVsizeFees['2'] || 0, vsize_2: weightVsizeFees['2'] || 0,
vsize_3: weightVsizeFees['3'] || 0, vsize_3: weightVsizeFees['3'] || 0,
@ -143,7 +126,7 @@ class Statistics {
} }
} }
private async $create(statistics: IMempoolStats): Promise<number | undefined> { private async $create(statistics: Statistic): Promise<number | undefined> {
try { try {
const connection = await DB.pool.getConnection(); const connection = await DB.pool.getConnection();
const query = `INSERT INTO statistics( const query = `INSERT INTO statistics(
@ -295,95 +278,163 @@ class Statistics {
AVG(vsize_2000) AS vsize_2000 FROM statistics GROUP BY UNIX_TIMESTAMP(added) DIV ${groupBy} ORDER BY id DESC LIMIT ${days}`; AVG(vsize_2000) AS vsize_2000 FROM statistics GROUP BY UNIX_TIMESTAMP(added) DIV ${groupBy} ORDER BY id DESC LIMIT ${days}`;
} }
public async $get(id: number): Promise<IMempoolStats | undefined> { public async $get(id: number): Promise<OptimizedStatistic | undefined> {
try { try {
const connection = await DB.pool.getConnection(); const connection = await DB.pool.getConnection();
const query = `SELECT * FROM statistics WHERE id = ?`; const query = `SELECT * FROM statistics WHERE id = ?`;
const [rows] = await connection.query<any>(query, [id]); const [rows] = await connection.query<any>(query, [id]);
connection.release(); connection.release();
return rows[0]; if (rows[0]) {
return this.mapStatisticToOptimizedStatistic([rows[0]])[0];
}
} catch (e) { } catch (e) {
console.log('$list2H() error', e); console.log('$list2H() error', e);
} }
} }
public async $list2H(): Promise<IMempoolStats[]> { public async $list2H(): Promise<OptimizedStatistic[]> {
try { try {
const connection = await DB.pool.getConnection(); const connection = await DB.pool.getConnection();
const query = `SELECT * FROM statistics ORDER BY id DESC LIMIT 120`; const query = `SELECT * FROM statistics ORDER BY id DESC LIMIT 120`;
const [rows] = await connection.query<any>(query); const [rows] = await connection.query<any>(query);
connection.release(); connection.release();
return rows; return this.mapStatisticToOptimizedStatistic(rows);
} catch (e) { } catch (e) {
console.log('$list2H() error', e); console.log('$list2H() error', e);
return []; return [];
} }
} }
public async $list24H(): Promise<IMempoolStats[]> { public async $list24H(): Promise<OptimizedStatistic[]> {
try { try {
const connection = await DB.pool.getConnection(); const connection = await DB.pool.getConnection();
const query = this.getQueryForDays(120, 720); const query = this.getQueryForDays(120, 720);
const [rows] = await connection.query<any>(query); const [rows] = await connection.query<any>(query);
connection.release(); connection.release();
return rows; return this.mapStatisticToOptimizedStatistic(rows);
} catch (e) { } catch (e) {
return []; return [];
} }
} }
public async $list1W(): Promise<IMempoolStats[]> { public async $list1W(): Promise<OptimizedStatistic[]> {
try { try {
const connection = await DB.pool.getConnection(); const connection = await DB.pool.getConnection();
const query = this.getQueryForDays(120, 5040); const query = this.getQueryForDays(120, 5040);
const [rows] = await connection.query<any>(query); const [rows] = await connection.query<any>(query);
connection.release(); connection.release();
return rows; return this.mapStatisticToOptimizedStatistic(rows);
} catch (e) { } catch (e) {
console.log('$list1W() error', e); console.log('$list1W() error', e);
return []; return [];
} }
} }
public async $list1M(): Promise<IMempoolStats[]> { public async $list1M(): Promise<OptimizedStatistic[]> {
try { try {
const connection = await DB.pool.getConnection(); const connection = await DB.pool.getConnection();
const query = this.getQueryForDays(120, 20160); const query = this.getQueryForDays(120, 20160);
const [rows] = await connection.query<any>(query); const [rows] = await connection.query<any>(query);
connection.release(); connection.release();
return rows; return this.mapStatisticToOptimizedStatistic(rows);
} catch (e) { } catch (e) {
console.log('$list1M() error', e); console.log('$list1M() error', e);
return []; return [];
} }
} }
public async $list3M(): Promise<IMempoolStats[]> { public async $list3M(): Promise<OptimizedStatistic[]> {
try { try {
const connection = await DB.pool.getConnection(); const connection = await DB.pool.getConnection();
const query = this.getQueryForDays(120, 60480); const query = this.getQueryForDays(120, 60480);
const [rows] = await connection.query<any>(query); const [rows] = await connection.query<any>(query);
connection.release(); connection.release();
return rows; return this.mapStatisticToOptimizedStatistic(rows);
} catch (e) { } catch (e) {
console.log('$list3M() error', e); console.log('$list3M() error', e);
return []; return [];
} }
} }
public async $list6M(): Promise<IMempoolStats[]> { public async $list6M(): Promise<OptimizedStatistic[]> {
try { try {
const connection = await DB.pool.getConnection(); const connection = await DB.pool.getConnection();
const query = this.getQueryForDays(120, 120960); const query = this.getQueryForDays(120, 120960);
const [rows] = await connection.query<any>(query); const [rows] = await connection.query<any>(query);
connection.release(); connection.release();
return rows; return this.mapStatisticToOptimizedStatistic(rows);
} catch (e) { } catch (e) {
console.log('$list6M() error', e); console.log('$list6M() error', e);
return []; return [];
} }
} }
public async $list1Y(): Promise<OptimizedStatistic[]> {
try {
const connection = await DB.pool.getConnection();
const query = this.getQueryForDays(120, 241920);
const [rows] = await connection.query<any>(query);
connection.release();
return this.mapStatisticToOptimizedStatistic(rows);
} catch (e) {
console.log('$list6M() error', e);
return [];
}
}
private mapStatisticToOptimizedStatistic(statistic: Statistic[]): OptimizedStatistic[] {
return statistic.map((s) => {
return {
id: s.id || 0,
added: s.added,
unconfirmed_transactions: s.unconfirmed_transactions,
tx_per_second: s.tx_per_second,
vbytes_per_second: s.vbytes_per_second,
mempool_byte_weight: s.mempool_byte_weight,
total_fee: s.total_fee,
vsizes: [
s.vsize_1,
s.vsize_2,
s.vsize_3,
s.vsize_4,
s.vsize_5,
s.vsize_6,
s.vsize_8,
s.vsize_10,
s.vsize_12,
s.vsize_15,
s.vsize_20,
s.vsize_30,
s.vsize_40,
s.vsize_50,
s.vsize_60,
s.vsize_70,
s.vsize_80,
s.vsize_90,
s.vsize_100,
s.vsize_125,
s.vsize_150,
s.vsize_175,
s.vsize_200,
s.vsize_250,
s.vsize_300,
s.vsize_350,
s.vsize_400,
s.vsize_500,
s.vsize_600,
s.vsize_700,
s.vsize_800,
s.vsize_900,
s.vsize_1000,
s.vsize_1200,
s.vsize_1400,
s.vsize_1600,
s.vsize_1800,
s.vsize_2000,
]
};
});
}
} }
export default new Statistics(); export default new Statistics();

View file

@ -6,40 +6,42 @@ import * as http from 'http';
import * as https from 'https'; import * as https from 'https';
import * as WebSocket from 'ws'; import * as WebSocket from 'ws';
import bitcoinApi from './api/bitcoin/bitcoin-api-factory';
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, ITransaction, IMempoolStats } from './interfaces';
import routes from './routes'; import routes from './routes';
import blocks from './api/blocks';
import memPool from './api/mempool';
import mempoolBlocks from './api/mempool-blocks';
import diskCache from './api/disk-cache';
import statistics from './api/statistics';
import { Block, TransactionExtended, Statistic } from './interfaces';
import fiatConversion from './api/fiat-conversion'; import fiatConversion from './api/fiat-conversion';
class MempoolSpace { class Server {
private wss: WebSocket.Server; private wss: WebSocket.Server;
private server: https.Server | http.Server; private server: https.Server | http.Server;
private app: any; private app: any;
constructor() { constructor() {
this.app = express(); this.app = express();
this.app this.app
.use((req, res, next) => { .use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Origin', '*');
next(); next();
}) })
.use(compression()); .use(compression());
if (config.ENV === 'dev') {
this.server = http.createServer(this.app); if (config.SSL === true) {
this.wss = new WebSocket.Server({ server: this.server });
} else {
const credentials = { const credentials = {
cert: fs.readFileSync('/etc/letsencrypt/live/mempool.space/fullchain.pem'), cert: fs.readFileSync(config.SSL_CERT_FILE_PATH),
key: fs.readFileSync('/etc/letsencrypt/live/mempool.space/privkey.pem'), key: fs.readFileSync(config.SSL_KEY_FILE_PATH),
}; };
this.server = https.createServer(credentials, this.app); this.server = https.createServer(credentials, this.app);
this.wss = new WebSocket.Server({ server: this.server }); this.wss = new WebSocket.Server({ server: this.server });
} else {
this.server = http.createServer(this.app);
this.wss = new WebSocket.Server({ server: this.server });
} }
this.setUpRoutes(); this.setUpRoutes();
@ -50,20 +52,16 @@ class MempoolSpace {
statistics.startStatistics(); statistics.startStatistics();
fiatConversion.startService(); fiatConversion.startService();
const opts = { this.server.listen(config.HTTP_PORT, () => {
host: '127.0.0.1', console.log(`Server started on port ${config.HTTP_PORT}`);
port: 8999
};
this.server.listen(opts, () => {
console.log(`Server started on ${opts.host}:${opts.port}`);
}); });
} }
private async runMempoolIntervalFunctions() { private async runMempoolIntervalFunctions() {
await blocks.updateBlocks();
await memPool.updateMemPoolInfo(); await memPool.updateMemPoolInfo();
await blocks.updateBlocks();
await memPool.updateMempool(); await memPool.updateMempool();
setTimeout(this.runMempoolIntervalFunctions.bind(this), config.MEMPOOL_REFRESH_RATE_MS); setTimeout(this.runMempoolIntervalFunctions.bind(this), config.ELECTRS_POLL_RATE_MS);
} }
private setUpMempoolCache() { private setUpMempoolCache() {
@ -81,129 +79,116 @@ class MempoolSpace {
private setUpWebsocketHandling() { private setUpWebsocketHandling() {
this.wss.on('connection', (client: WebSocket) => { this.wss.on('connection', (client: WebSocket) => {
let theBlocks = blocks.getBlocks(); client.on('message', (message: any) => {
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 { try {
const parsedMessage = JSON.parse(message); const parsedMessage = JSON.parse(message);
if (parsedMessage.action === 'want') { if (parsedMessage.action === 'want') {
client['want-stats'] = parsedMessage.data.indexOf('stats') > -1;
client['want-blocks'] = parsedMessage.data.indexOf('blocks') > -1; client['want-blocks'] = parsedMessage.data.indexOf('blocks') > -1;
client['want-projected-blocks'] = parsedMessage.data.indexOf('projected-blocks') > -1; client['want-mempool-blocks'] = parsedMessage.data.indexOf('mempool-blocks') > -1;
client['want-live-2h-chart'] = parsedMessage.data.indexOf('live-2h-chart') > -1; client['want-live-2h-chart'] = parsedMessage.data.indexOf('live-2h-chart') > -1;
client['want-stats'] = parsedMessage.data.indexOf('stats') > -1;
} }
if (parsedMessage.action === 'track-tx' && parsedMessage.txId && /^[a-fA-F0-9]{64}$/.test(parsedMessage.txId)) { if (parsedMessage && parsedMessage['track-tx']) {
const tx = await memPool.getRawTransaction(parsedMessage.txId); if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-tx'])) {
if (tx) { client['track-tx'] = parsedMessage['track-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.getBlockAndTransactions(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 { } else {
console.log('TX NOT FOUND, NOT TRACKING'); client['track-tx'] = null;
client['trackingTx'] = false;
client['blockHeight'] = 0;
client['tx'] = null;
client.send(JSON.stringify({
'track-tx': {
tracking: false,
blockHeight: 0,
message: 'not-found',
}
}));
} }
} }
if (parsedMessage.action === 'stop-tracking-tx') {
console.log('STOP TRACKING'); if (parsedMessage && parsedMessage['track-address']) {
client['trackingTx'] = false; if (/^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,87})$/
.test(parsedMessage['track-address'])) {
client['track-address'] = parsedMessage['track-address'];
} else {
client['track-address'] = null;
}
}
if (parsedMessage.action === 'init') {
const _blocks = blocks.getBlocks();
if (!_blocks) {
return;
}
client.send(JSON.stringify({ client.send(JSON.stringify({
'track-tx': { 'mempoolInfo': memPool.getMempoolInfo(),
tracking: false, 'vBytesPerSecond': memPool.getVBytesPerSecond(),
blockHeight: 0, 'blocks': _blocks,
message: 'not-found', 'conversions': fiatConversion.getTickers()['BTCUSD'],
} 'mempool-blocks': mempoolBlocks.getMempoolBlocks(),
})); }));
} }
} catch (e) { } catch (e) {
console.log(e); console.log(e);
} }
}); });
});
client.on('close', () => { statistics.setNewStatisticsEntryCallback((stats: Statistic) => {
client['trackingTx'] = false; this.wss.clients.forEach((client: WebSocket) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
if (!client['want-live-2h-chart']) {
return;
}
client.send(JSON.stringify({
'live-2h-chart': stats
}));
}); });
}); });
blocks.setNewBlockCallback((block: IBlock) => { blocks.setNewBlockCallback((block: Block, txIds: string[], transactions: TransactionExtended[]) => {
const formattedBlocks = blocks.formatBlock(block);
this.wss.clients.forEach((client) => { this.wss.clients.forEach((client) => {
if (client.readyState !== WebSocket.OPEN) { if (client.readyState !== WebSocket.OPEN) {
return; return;
} }
const response = {}; if (!client['want-blocks']) {
return;
if (client['trackingTx'] === true && client['blockHeight'] === 0) {
if (block.tx.some((tx: ITransaction) => tx === client['txId'])) {
client['blockHeight'] = block.height;
}
} }
response['track-tx'] = { const response = {
tracking: client['trackingTx'] || false, 'block': block
blockHeight: client['blockHeight'],
}; };
response['block'] = formattedBlocks; if (client['track-tx'] && txIds.indexOf(client['track-tx']) > -1) {
client['track-tx'] = null;
response['txConfirmed'] = true;
}
if (client['track-address']) {
const foundTransactions: TransactionExtended[] = [];
transactions.forEach((tx) => {
const someVin = tx.vin.some((vin) => vin.prevout.scriptpubkey_address === client['track-address']);
if (someVin) {
foundTransactions.push(tx);
return;
}
const someVout = tx.vout.some((vout) => vout.scriptpubkey_address === client['track-address']);
if (someVout) {
foundTransactions.push(tx);
}
});
if (foundTransactions.length) {
response['address-block-transactions'] = foundTransactions;
}
}
client.send(JSON.stringify(response)); client.send(JSON.stringify(response));
}); });
}); });
memPool.setMempoolChangedCallback((newMempool: IMempool) => { memPool.setMempoolChangedCallback((newMempool: { [txid: string]: TransactionExtended }, newTransactions: TransactionExtended[]) => {
projectedBlocks.updateProjectedBlocks(newMempool); mempoolBlocks.updateMempoolBlocks(newMempool);
const mBlocks = mempoolBlocks.getMempoolBlocks();
const pBlocks = projectedBlocks.getProjectedBlocks();
const mempoolInfo = memPool.getMempoolInfo(); const mempoolInfo = memPool.getMempoolInfo();
const txPerSecond = memPool.getTxPerSecond();
const vBytesPerSecond = memPool.getVBytesPerSecond(); const vBytesPerSecond = memPool.getVBytesPerSecond();
this.wss.clients.forEach((client: WebSocket) => { this.wss.clients.forEach((client: WebSocket) => {
@ -215,18 +200,32 @@ class MempoolSpace {
if (client['want-stats']) { if (client['want-stats']) {
response['mempoolInfo'] = mempoolInfo; response['mempoolInfo'] = mempoolInfo;
response['txPerSecond'] = txPerSecond;
response['vBytesPerSecond'] = vBytesPerSecond; response['vBytesPerSecond'] = vBytesPerSecond;
response['track-tx'] = {
tracking: client['trackingTx'] || false,
blockHeight: client['blockHeight'],
};
} }
if (client['want-projected-blocks'] && client['trackingTx'] && client['blockHeight'] === 0) { if (client['want-mempool-blocks']) {
response['projectedBlocks'] = projectedBlocks.getProjectedBlocks(client['txId']); response['mempool-blocks'] = mBlocks;
} else if (client['want-projected-blocks']) { }
response['projectedBlocks'] = pBlocks;
// Send all new incoming transactions related to tracked address
if (client['track-address']) {
const foundTransactions: TransactionExtended[] = [];
newTransactions.forEach((tx) => {
const someVin = tx.vin.some((vin) => vin.prevout.scriptpubkey_address === client['track-address']);
if (someVin) {
foundTransactions.push(tx);
return;
}
const someVout = tx.vout.some((vout) => vout.scriptpubkey_address === client['track-address']);
if (someVout) {
foundTransactions.push(tx);
}
});
if (foundTransactions.length) {
response['address-transactions'] = foundTransactions;
}
} }
if (Object.keys(response).length) { if (Object.keys(response).length) {
@ -234,51 +233,21 @@ class MempoolSpace {
} }
}); });
}); });
statistics.setNewStatisticsEntryCallback((stats: IMempoolStats) => {
this.wss.clients.forEach((client: WebSocket) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
if (client['want-live-2h-chart']) {
client.send(JSON.stringify({
'live-2h-chart': stats
}));
}
});
});
} }
private setUpRoutes() { private setUpRoutes() {
this.app 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 + 'fees/recommended', routes.getRecommendedFees)
.get(config.API_ENDPOINT + 'fees/projected-blocks', routes.getProjectedBlocks) .get(config.API_ENDPOINT + 'fees/mempool-blocks', routes.getMempoolBlocks)
.get(config.API_ENDPOINT + 'statistics/2h', routes.get2HStatistics) .get(config.API_ENDPOINT + 'statistics/2h', routes.get2HStatistics)
.get(config.API_ENDPOINT + 'statistics/24h', routes.get24HStatistics.bind(routes)) .get(config.API_ENDPOINT + 'statistics/24h', routes.get24HStatistics.bind(routes))
.get(config.API_ENDPOINT + 'statistics/1w', routes.get1WHStatistics.bind(routes)) .get(config.API_ENDPOINT + 'statistics/1w', routes.get1WHStatistics.bind(routes))
.get(config.API_ENDPOINT + 'statistics/1m', routes.get1MStatistics.bind(routes)) .get(config.API_ENDPOINT + 'statistics/1m', routes.get1MStatistics.bind(routes))
.get(config.API_ENDPOINT + 'statistics/3m', routes.get3MStatistics.bind(routes)) .get(config.API_ENDPOINT + 'statistics/3m', routes.get3MStatistics.bind(routes))
.get(config.API_ENDPOINT + 'statistics/6m', routes.get6MStatistics.bind(routes)) .get(config.API_ENDPOINT + 'statistics/6m', routes.get6MStatistics.bind(routes))
.get(config.API_ENDPOINT + 'statistics/1y', routes.get1YStatistics.bind(routes))
; ;
if (config.BACKEND_API === 'electrs') {
this.app
.get(config.API_ENDPOINT + 'explorer/blocks', routes.getBlocks)
.get(config.API_ENDPOINT + 'explorer/blocks/:height', routes.getBlocks)
.get(config.API_ENDPOINT + 'explorer/tx/:id', routes.getRawTransaction)
.get(config.API_ENDPOINT + 'explorer/block/:hash', routes.getBlock)
.get(config.API_ENDPOINT + 'explorer/block/:hash/tx', routes.getBlockTransactions)
.get(config.API_ENDPOINT + 'explorer/block/:hash/tx/:index', routes.getBlockTransactionsFromIndex)
.get(config.API_ENDPOINT + 'explorer/address/:address', routes.getAddress)
.get(config.API_ENDPOINT + 'explorer/address/:address/tx', routes.getAddressTransactions)
.get(config.API_ENDPOINT + 'explorer/address/:address/tx/chain/:txid', routes.getAddressTransactionsFromTxid)
;
} }
}
} const server = new Server();
}
const mempoolSpace = new MempoolSpace();

View file

@ -1,4 +1,4 @@
export interface IMempoolInfo { export interface MempoolInfo {
size: number; size: number;
bytes: number; bytes: number;
usage?: number; usage?: number;
@ -7,80 +7,110 @@ export interface IMempoolInfo {
minrelaytxfee?: number; minrelaytxfee?: number;
} }
export interface ITransaction { export interface MempoolBlock {
blockSize: number;
blockVSize: number;
nTx: number;
medianFee: number;
feeRange: number[];
}
export interface Transaction {
txid: string; txid: string;
hash: string;
version: number; version: number;
size: number;
vsize: number;
weight: number;
locktime: number; locktime: number;
fee: number;
size: number;
weight: number;
vin: Vin[]; vin: Vin[];
vout: Vout[]; vout: Vout[];
hex: string; status: Status;
}
export interface TransactionExtended extends Transaction {
txid: string;
fee: number; 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; size: number;
weight: number; vsize: number;
height: number; feePerVsize: 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 { export interface Prevout {
asm: string; scriptpubkey: string;
hex: string; scriptpubkey_asm: string;
scriptpubkey_type: string;
scriptpubkey_address: string;
value: number;
} }
interface Vin { export interface Vin {
txid: string; txid: string;
vout: number; vout: number;
scriptSig: ScriptSig; prevout: Prevout;
sequence: number; scriptsig: string;
scriptsig_asm: string;
inner_redeemscript_asm?: string;
is_coinbase: boolean;
sequence: any;
witness?: string[];
inner_witnessscript_asm?: string;
} }
interface ScriptPubKey { export interface Vout {
asm: string; scriptpubkey: string;
hex: string; scriptpubkey_asm: string;
reqSigs: number; scriptpubkey_type: string;
type: string; scriptpubkey_address: string;
addresses: string[];
}
interface Vout {
value: number; value: number;
n: number;
scriptPubKey: ScriptPubKey;
} }
export interface IMempoolStats { export interface Status {
confirmed: boolean;
block_height?: number;
block_hash?: string;
block_time?: number;
}
export interface Block {
id: string;
height: number;
version: number;
timestamp: number;
tx_count: number;
size: number;
weight: number;
merkle_root: string;
previousblockhash: string;
nonce: any;
bits: number;
medianFee?: number;
feeRange?: number[];
}
export interface Address {
address: string;
chain_stats: ChainStats;
mempool_stats: MempoolStats;
}
export interface ChainStats {
funded_txo_count: number;
funded_txo_sum: number;
spent_txo_count: number;
spent_txo_sum: number;
tx_count: number;
}
export interface MempoolStats {
funded_txo_count: number;
funded_txo_sum: number;
spent_txo_count: number;
spent_txo_sum: number;
tx_count: number;
}
export interface Statistic {
id?: number; id?: number;
added: string; added: string;
unconfirmed_transactions: number; unconfirmed_transactions: number;
@ -130,23 +160,21 @@ export interface IMempoolStats {
vsize_2000: number; vsize_2000: number;
} }
export interface IProjectedBlockInternal extends IProjectedBlock { export interface OptimizedStatistic {
txIds: string[]; id: number;
txFeePerVsizes: number[]; added: string;
unconfirmed_transactions: number;
tx_per_second: number;
vbytes_per_second: number;
total_fee: number;
mempool_byte_weight: number;
vsizes: number[];
} }
export interface IProjectedBlock { export interface Outspend {
blockSize: number; spent: boolean;
blockWeight: number; txid: string;
maxFee: number; vin: number;
maxWeightFee: number; status: Status;
medianFee: number;
minFee: number;
minWeightFee: number;
nTx: number;
fees: number;
hasMyTxId?: boolean;
} }
export interface IMempool { [txid: string]: ITransaction; }

View file

@ -1,7 +1,6 @@
import statistics from './api/statistics'; import statistics from './api/statistics';
import feeApi from './api/fee-api'; import feeApi from './api/fee-api';
import projectedBlocks from './api/projected-blocks'; import mempoolBlocks from './api/mempool-blocks';
import bitcoinApi from './api/bitcoin/bitcoin-api-factory';
class Routes { class Routes {
private cache = {}; private cache = {};
@ -17,6 +16,7 @@ class Routes {
this.cache['1m'] = await statistics.$list1M(); this.cache['1m'] = await statistics.$list1M();
this.cache['3m'] = await statistics.$list3M(); this.cache['3m'] = await statistics.$list3M();
this.cache['6m'] = await statistics.$list6M(); this.cache['6m'] = await statistics.$list6M();
this.cache['1y'] = await statistics.$list1Y();
console.log('Statistics cache created'); console.log('Statistics cache created');
} }
@ -45,154 +45,23 @@ class Routes {
res.send(this.cache['6m']); res.send(this.cache['6m']);
} }
public get1YStatistics(req, res) {
res.send(this.cache['1y']);
}
public async getRecommendedFees(req, res) { public async getRecommendedFees(req, res) {
const result = feeApi.getRecommendedFee(); const result = feeApi.getRecommendedFee();
res.send(result); res.send(result);
} }
public async $getgetTransactionsForBlock(req, res) { public async getMempoolBlocks(req, res) {
const result = await feeApi.$getTransactionsForBlock(req.params.id);
res.send(result);
}
public async getgetTransactionsForProjectedBlock(req, res) {
try { try {
const result = await projectedBlocks.getProjectedBlockFeesForBlock(req.params.id); const result = await mempoolBlocks.getMempoolBlocks();
res.send(result); res.send(result);
} catch (e) { } catch (e) {
res.status(500).send(e.message); res.status(500).send(e.message);
} }
} }
public async getProjectedBlocks(req, res) {
try {
let txId: string | undefined;
if (req.query.txId && /^[a-fA-F0-9]{64}$/.test(req.query.txId)) {
txId = req.query.txId;
}
const result = await projectedBlocks.getProjectedBlocks(txId, 6);
res.send(result);
} catch (e) {
res.status(500).send(e.message);
}
}
public async getBlocks(req, res) {
try {
let result: string;
if (req.params.height) {
result = await bitcoinApi.getBlocksFromHeight(req.params.height);
} else {
result = await bitcoinApi.getBlocks();
}
res.send(result);
} catch (e) {
res.status(500).send(e.message);
}
}
public async getRawTransaction(req, res) {
try {
const result = await bitcoinApi.getRawTransaction(req.params.id);
res.send(result);
} catch (e) {
if (e.response) {
res.status(e.response.status).send(e.response.data);
} else {
res.status(500, e.message);
}
}
}
public async getBlock(req, res) {
try {
const result = await bitcoinApi.getBlock(req.params.hash);
res.send(result);
} catch (e) {
if (e.response) {
res.status(e.response.status).send(e.response.data);
} else {
res.status(500, e.message);
}
}
}
public async getBlockTransactions(req, res) {
try {
const result = await bitcoinApi.getBlockTransactions(req.params.hash);
res.send(result);
} catch (e) {
if (e.response) {
res.status(e.response.status).send(e.response.data);
} else {
if (e.response) {
res.status(e.response.status).send(e.response.data);
} else {
res.status(500, e.message);
}
}
}
}
public async getBlockTransactionsFromIndex(req, res) {
try {
const result = await bitcoinApi.getBlockTransactionsFromIndex(req.params.hash, req.params.index);
res.send(result);
} catch (e) {
if (e.response) {
res.status(e.response.status).send(e.response.data);
} else {
if (e.response) {
res.status(e.response.status).send(e.response.data);
} else {
res.status(500, e.message);
}
}
}
}
public async getAddress(req, res) {
try {
const result = await bitcoinApi.getAddress(req.params.address);
res.send(result);
} catch (e) {
if (e.response) {
res.status(e.response.status).send(e.response.data);
} else {
if (e.response) {
res.status(e.response.status).send(e.response.data);
} else {
res.status(500, e.message);
}
}
}
}
public async getAddressTransactions(req, res) {
try {
const result = await bitcoinApi.getAddressTransactions(req.params.address);
res.send(result);
} catch (e) {
if (e.response) {
res.status(e.response.status).send(e.response.data);
} else {
res.status(500, e.message);
}
}
}
public async getAddressTransactionsFromTxid(req, res) {
try {
const result = await bitcoinApi.getAddressTransactionsFromLastSeenTxid(req.params.address, req.params.txid);
res.send(result);
} catch (e) {
if (e.response) {
res.status(e.response.status).send(e.response.data);
} else {
res.status(500, e.message);
}
}
}
} }
export default new Routes(); export default new Routes();

View file

@ -31,6 +31,13 @@
resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8"
integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==
"@types/compression@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/compression/-/compression-1.0.1.tgz#f3682a6b3ce2dbd4aece48547153ebc592281fa7"
integrity sha512-GuoIYzD70h+4JUqUabsm31FGqvpCYHGKcLtor7nQ/YvUyNX0o9SJZ9boFI5HjFfbOda5Oe/XOvNK6FES8Y/79w==
dependencies:
"@types/express" "*"
"@types/connect@*": "@types/connect@*":
version "3.4.32" version "3.4.32"
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.32.tgz#aa0e9616b9435ccad02bc52b5b454ffc2c70ba28" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.32.tgz#aa0e9616b9435ccad02bc52b5b454ffc2c70ba28"
@ -39,17 +46,17 @@
"@types/node" "*" "@types/node" "*"
"@types/express-serve-static-core@*": "@types/express-serve-static-core@*":
version "4.16.10" version "4.17.0"
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.16.10.tgz#3c1313c6e6b75594561b473a286f016a9abf2132" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.0.tgz#e80c25903df5800e926402b7e8267a675c54a281"
integrity sha512-gM6evDj0OvTILTRKilh9T5dTaGpv1oYiFcJAfgSejuMJgGJUsD9hKEU2lB4aiTNy4WwChxRnjfYFuBQsULzsJw== integrity sha512-Xnub7w57uvcBqFdIGoRg1KhNOeEj0vB6ykUM7uFWyxvbdE89GFyqgmUcanAriMr4YOxNFZBAWkfcWIb4WBPt3g==
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/range-parser" "*" "@types/range-parser" "*"
"@types/express@^4.16.0": "@types/express@*", "@types/express@^4.17.2":
version "4.17.1" version "4.17.2"
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.1.tgz#4cf7849ae3b47125a567dfee18bfca4254b88c5c" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.2.tgz#a0fb7a23d8855bac31bc01d5a58cadd9b2173e6c"
integrity sha512-VfH/XCP0QbQk5B5puLqTLEeFgR8lfCJHZJKkInZ9mkYd+u8byX0kztXEQxEk4wZXJs8HI+7km2ALXjn4YKcX9w== integrity sha512-5mHFNyavtLoJmnusB8OKJ5bshSzw+qkMIBAobLrIM48HJvunFva9mOa6aBwh64lBFyNwBbs0xiEFuj4eU/NjCA==
dependencies: dependencies:
"@types/body-parser" "*" "@types/body-parser" "*"
"@types/express-serve-static-core" "*" "@types/express-serve-static-core" "*"
@ -60,20 +67,10 @@
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d"
integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw== integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==
"@types/mysql2@github:types/mysql2":
version "1.0.0"
resolved "https://codeload.github.com/types/mysql2/tar.gz/217efd4ccf9eccc0797522aa745d8a9e264f6a75"
dependencies:
"@types/mysql" types/mysql#v2.0.0
"@types/mysql@types/mysql#v2.0.0":
version "2.0.0"
resolved "https://codeload.github.com/types/mysql/tar.gz/da645a82afd66419ed439dddf174648aa68ba1f9"
"@types/node@*": "@types/node@*":
version "12.11.1" version "12.12.17"
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.11.1.tgz#1fd7b821f798b7fa29f667a1be8f3442bb8922a3" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.17.tgz#191b71e7f4c325ee0fb23bc4a996477d92b8c39b"
integrity sha512-TJtwsqZ39pqcljJpajeoofYRfeZ7/I/OMUQ5pR4q5wOKf2ocrUvBAZUMhWsOvKx3dVc/aaV5GluBivt0sWqA5A== integrity sha512-Is+l3mcHvs47sKy+afn2O1rV4ldZFU7W8101cNlOd+MRbjM4Onida8jSZnJdTe/0Pcf25g9BNIUsuugmE6puHA==
"@types/range-parser@*": "@types/range-parser@*":
version "1.2.3" version "1.2.3"
@ -99,14 +96,14 @@
"@types/mime" "*" "@types/mime" "*"
"@types/tough-cookie@*": "@types/tough-cookie@*":
version "2.3.5" version "2.3.6"
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-2.3.5.tgz#9da44ed75571999b65c37b60c9b2b88db54c585d" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-2.3.6.tgz#c880579e087d7a0db13777ff8af689f4ffc7b0d5"
integrity sha512-SCcK7mvGi3+ZNz833RRjFIxrn4gI1PPR3NtuIS+6vMkvmsGjosqTJwRt5bAEFLRz+wtJMWv8+uOnZf2hi2QXTg== integrity sha512-wHNBMnkoEBiRAd3s8KTKwIuO9biFtTf0LehITzBhSco+HQI0xkXZbLOD55SW3Aqw3oUkHstkm5SPv58yaAdFPQ==
"@types/ws@^6.0.1": "@types/ws@^6.0.4":
version "6.0.3" version "6.0.4"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-6.0.3.tgz#b772375ba59d79066561c8d87500144d674ba6b3" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-6.0.4.tgz#7797707c8acce8f76d8c34b370d4645b70421ff1"
integrity sha512-yBTM0P05Tx9iXGq00BbJPo37ox68R5vaGTXivs6RGh/BQ6QP5zqZDGWdAO6JbRE/iR1l80xeGAwCQS2nMV9S/w== integrity sha512-PpPrX7SZW9re6+Ha8ojZG4Se8AZXgf0GK6zmfqEuCsY49LFDNXO3SByp44X3dFEqtB73lkCDAdUazhAjVPiNwg==
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
@ -159,7 +156,7 @@ assert-plus@1.0.0, assert-plus@^1.0.0:
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
async-limiter@~1.0.0: async-limiter@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
@ -175,17 +172,9 @@ aws-sign2@~0.7.0:
integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
aws4@^1.8.0: aws4@^1.8.0:
version "1.8.0" version "1.9.0"
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.0.tgz#24390e6ad61386b0a747265754d2a17219de862c"
integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ== integrity sha512-Uvq6hVe90D0B2WEnUqtdgY1bATGz3mw33nH9Y+dmA+w5DHvUmBgkr5rM/KCHpCsiFNRUfokW/szpPPgMK2hm4A==
axios@^0.19.0:
version "0.19.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.0.tgz#8e09bff3d9122e133f7b8101c8fbdd00ed3d2ab8"
integrity sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==
dependencies:
follow-redirects "1.5.10"
is-buffer "^2.0.2"
balanced-match@^1.0.0: balanced-match@^1.0.0:
version "1.0.0" version "1.0.0"
@ -199,11 +188,6 @@ bcrypt-pbkdf@^1.0.0:
dependencies: dependencies:
tweetnacl "^0.14.3" tweetnacl "^0.14.3"
bitcoin@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/bitcoin/-/bitcoin-3.0.1.tgz#ff9e0b62a71bbb8adddb34ee2e427dac21c1096f"
integrity sha1-/54LYqcbu4rd2zTuLkJ9rCHBCW8=
body-parser@1.19.0: body-parser@1.19.0:
version "1.19.0" version "1.19.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
@ -288,7 +272,7 @@ compressible@~2.0.16:
dependencies: dependencies:
mime-db ">= 1.40.0 < 2" mime-db ">= 1.40.0 < 2"
compression@^1.7.3: compression@^1.7.4:
version "1.7.4" version "1.7.4"
resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f" resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f"
integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ== integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==
@ -347,13 +331,6 @@ debug@2.6.9:
dependencies: dependencies:
ms "2.0.0" ms "2.0.0"
debug@=3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
dependencies:
ms "2.0.0"
delayed-stream@~1.0.0: delayed-stream@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
@ -422,7 +399,7 @@ etag@~1.8.1:
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
express@^4.16.3: express@^4.17.1:
version "4.17.1" version "4.17.1"
resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g== integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
@ -496,13 +473,6 @@ finalhandler@~1.1.2:
statuses "~1.5.0" statuses "~1.5.0"
unpipe "~1.0.0" unpipe "~1.0.0"
follow-redirects@1.5.10:
version "1.5.10"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a"
integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==
dependencies:
debug "=3.1.0"
forever-agent@~0.6.1: forever-agent@~0.6.1:
version "0.6.1" version "0.6.1"
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
@ -556,9 +526,9 @@ getpass@^0.1.1:
assert-plus "^1.0.0" assert-plus "^1.0.0"
glob@^7.1.1: glob@^7.1.1:
version "7.1.4" version "7.1.6"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A== integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
dependencies: dependencies:
fs.realpath "^1.0.0" fs.realpath "^1.0.0"
inflight "^1.0.4" inflight "^1.0.4"
@ -624,9 +594,9 @@ iconv-lite@0.4.24:
safer-buffer ">= 2.1.2 < 3" safer-buffer ">= 2.1.2 < 3"
iconv-lite@^0.5.0: iconv-lite@^0.5.0:
version "0.5.0" version "0.5.1"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.5.0.tgz#59cdde0a2a297cc2aeb0c6445a195ee89f127550" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.5.1.tgz#b2425d3c7b18f7219f2ca663d103bddb91718d64"
integrity sha512-NnEhI9hIEKHOzJ4f697DMz9IQEXr/MMJ5w64vN2/4Ai+wRnvV7SBrL0KLoRlwaKVghOc7LQ5YkPLuX146b6Ydw== integrity sha512-ONHr16SQvKZNSqjQT9gy5z24Jw+uqfO02/ngBSBoqChZ+W8qXX7GPRa1RoUnzGADw8K63R1BXUMzarCVQBpY8Q==
dependencies: dependencies:
safer-buffer ">= 2.1.2 < 3" safer-buffer ">= 2.1.2 < 3"
@ -653,11 +623,6 @@ ipaddr.js@1.9.0:
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65"
integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA== integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==
is-buffer@^2.0.2:
version "2.0.4"
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623"
integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==
is-property@^1.0.2: is-property@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84"
@ -751,22 +716,17 @@ methods@~1.1.2:
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
mime-db@1.40.0: mime-db@1.42.0, "mime-db@>= 1.40.0 < 2":
version "1.40.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32"
integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==
"mime-db@>= 1.40.0 < 2":
version "1.42.0" version "1.42.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.42.0.tgz#3e252907b4c7adb906597b4b65636272cf9e7bac" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.42.0.tgz#3e252907b4c7adb906597b4b65636272cf9e7bac"
integrity sha512-UbfJCR4UAVRNgMpfImz05smAXK7+c+ZntjaA26ANtkXLlOe947Aag5zdIcKQULAiF9Cq4WxBi9jUs5zkA84bYQ== integrity sha512-UbfJCR4UAVRNgMpfImz05smAXK7+c+ZntjaA26ANtkXLlOe947Aag5zdIcKQULAiF9Cq4WxBi9jUs5zkA84bYQ==
mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24: mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24:
version "2.1.24" version "2.1.25"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.25.tgz#39772d46621f93e2a80a856c53b86a62156a6437"
integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ== integrity sha512-5KhStqB5xpTAeGqKBAMgwaYMnQik7teQN4IAzC7npDv6kzeU6prfkR67bc87J1kWMPGkoaZSq1npmexMgkmEVg==
dependencies: dependencies:
mime-db "1.40.0" mime-db "1.42.0"
mime@1.6.0: mime@1.6.0:
version "1.6.0" version "1.6.0"
@ -891,9 +851,9 @@ pseudomap@^1.0.2:
integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
psl@^1.1.24: psl@^1.1.24:
version "1.4.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/psl/-/psl-1.4.0.tgz#5dd26156cdb69fa1fdb8ab1991667d3f80ced7c2" resolved "https://registry.yarnpkg.com/psl/-/psl-1.6.0.tgz#60557582ee23b6c43719d9890fb4170ecd91e110"
integrity sha512-HZzqCGPecFLyoRj5HLfuDSKYTJkAfB5thKBIkRHtGjWwY7p1dAyveIbXIq4tO0KYfDF2tHqPUgY9SDnGm00uFw== integrity sha512-SYKKmVel98NCOYXpkwUqZqh0ahZeeKfmisiLIcEZdsb+WbLv02g/dI5BUmZnIyOe7RzZtLax81nnb2HbvC2tzA==
punycode@^1.4.1: punycode@^1.4.1:
version "1.4.1" version "1.4.1"
@ -957,9 +917,9 @@ request@^2.88.0:
uuid "^3.3.2" uuid "^3.3.2"
resolve@^1.3.2: resolve@^1.3.2:
version "1.12.0" version "1.13.1"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.12.0.tgz#3fc644a35c84a48554609ff26ec52b66fa577df6" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.13.1.tgz#be0aa4c06acd53083505abb35f4d66932ab35d16"
integrity sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w== integrity sha512-CxqObCX8K8YtAhOBRg+lrcdn+LK+WYOS8tSjqSFbjtrI5PnS63QPhZl4+yKfrU9tdsbMu9Anr/amegT87M9Z6w==
dependencies: dependencies:
path-parse "^1.0.6" path-parse "^1.0.6"
@ -1078,9 +1038,9 @@ tslib@^1.8.0, tslib@^1.8.1:
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
tslint@^5.11.0: tslint@^5.11.0:
version "5.20.0" version "5.20.1"
resolved "https://registry.yarnpkg.com/tslint/-/tslint-5.20.0.tgz#fac93bfa79568a5a24e7be9cdde5e02b02d00ec1" resolved "https://registry.yarnpkg.com/tslint/-/tslint-5.20.1.tgz#e401e8aeda0152bc44dd07e614034f3f80c67b7d"
integrity sha512-2vqIvkMHbnx8acMogAERQ/IuINOq6DFqgF8/VDvhEkBqQh/x6SP0Y+OHnKth9/ZcHQSroOZwUQSN18v8KKF0/g== integrity sha512-EcMxhzCFt8k+/UP5r8waCf/lzmeSyVlqxqMEDQE7rWYiQky8KpIBz1JAoYXfROHrPZ1XXd43q8yQnULOLiBRQg==
dependencies: dependencies:
"@babel/code-frame" "^7.0.0" "@babel/code-frame" "^7.0.0"
builtin-modules "^1.1.1" builtin-modules "^1.1.1"
@ -1123,7 +1083,7 @@ type-is@~1.6.17, type-is@~1.6.18:
media-typer "0.3.0" media-typer "0.3.0"
mime-types "~2.1.24" mime-types "~2.1.24"
typescript@^3.1.1: typescript@~3.6.4:
version "3.6.4" version "3.6.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.6.4.tgz#b18752bb3792bc1a0281335f7f6ebf1bbfc5b91d" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.6.4.tgz#b18752bb3792bc1a0281335f7f6ebf1bbfc5b91d"
integrity sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg== integrity sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg==
@ -1169,12 +1129,12 @@ wrappy@1:
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
ws@^6.0.0: ws@^7.2.0:
version "6.2.1" version "7.2.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.0.tgz#422eda8c02a4b5dba7744ba66eebbd84bcef0ec7"
integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA== integrity sha512-+SqNqFbwTm/0DC18KYzIsMTnEWpLwJsiasW/O17la4iDRRIO9uaHbvKiAS3AHgTiuuWerK/brj4O6MYZkei9xg==
dependencies: dependencies:
async-limiter "~1.0.0" async-limiter "^1.0.0"
yallist@^2.1.2: yallist@^2.1.2:
version "2.1.2" version "2.1.2"

View file

@ -1,51 +1,10 @@
#!/bin/bash #!/bin/sh
## Start SQL
mysqld_safe& mysqld_safe&
sleep 5 sleep 5
## http server:
nginx nginx
## Set up some files:
cd /mempool.space/backend cd /mempool.space/backend
rm -f mempool-config.json
rm -f cache.json rm -f cache.json
touch cache.json touch cache.json
jq -n env > mempool-config.json
## Build mempool-config.json file ourseleves.
## We used to use jq for this but that produced output which caused bugs,
## specifically numbers were surrounded by quotes, which breaks things.
## Old command was jq -n env > mempool-config.json
## This way is more complex, but more compatible with the backend functions.
## Define a function to allow us to easily get indexes of the = string in from the env output:
strindex() {
x="${1%%$2*}"
[[ "$x" = "$1" ]] && echo -1 || echo "${#x}"
}
## Regex to check if we have a number or not:
NumberRegEx='^[0-9]+$'
## Delete the old file, and start a new one:
rm -f mempool-config.json
echo "{" >> mempool-config.json
## For each env we add into the mempool-config.json file in one of two ways.
## Either:
## "Variable": "Value",
## if a string, or
## "Variable": Value,
## if a integer
for e in `env`; do
if [[ ${e:`strindex "$e" "="`+1} =~ $NumberRegEx ]] ; then
## Integer add:
echo "\""${e:0:`strindex "$e" "="`}"\": "${e:`strindex "$e" "="`+1}"," >> mempool-config.json
else
## String add:
echo "\""${e:0:`strindex "$e" "="`}"\": \""${e:`strindex "$e" "="`+1}$"\"," >> mempool-config.json
fi
done
## Take out the trailing , from the last entry.
## This means replacing the file with one that is missing the last character
echo `sed '$ s/.$//' mempool-config.json` > mempool-config.json
## And finally finish off:
echo "}" >> mempool-config.json
## Start mempoolspace:
node dist/index.js node dist/index.js

View file

@ -1,4 +1,4 @@
# Editor configuration, see http://editorconfig.org # Editor configuration, see https://editorconfig.org
root = true root = true
[*] [*]

7
frontend/.gitignore vendored
View file

@ -4,10 +4,16 @@
/dist /dist
/tmp /tmp
/out-tsc /out-tsc
# Only exists if Bazel was run
/bazel-out
# dependencies # dependencies
/node_modules /node_modules
# profiling files
chrome-profiler-events.json
speed-measure-plugin.json
# IDEs and editors # IDEs and editors
/.idea /.idea
.project .project
@ -23,6 +29,7 @@
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
.history/*
# misc # misc
/.sass-cache /.sass-cache

27
frontend/README.md Normal file
View file

@ -0,0 +1,27 @@
# Mempool Space
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 8.1.2.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

View file

@ -3,25 +3,26 @@
"version": 1, "version": 1,
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
"mempool": { "mempoolspace": {
"root": "",
"sourceRoot": "src",
"projectType": "application", "projectType": "application",
"prefix": "app",
"schematics": { "schematics": {
"@schematics/angular:component": { "@schematics/angular:component": {
"styleext": "scss" "style": "scss"
} }
}, },
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": { "architect": {
"build": { "build": {
"builder": "@angular-devkit/build-angular:browser", "builder": "@angular-devkit/build-angular:browser",
"options": { "options": {
"outputPath": "dist/mempool", "outputPath": "dist/mempoolspace",
"index": "src/index.html", "index": "src/index.html",
"main": "src/main.ts", "main": "src/main.ts",
"polyfills": "src/polyfills.ts", "polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"aot": true,
"assets": [ "assets": [
"src/favicon.ico", "src/favicon.ico",
"src/assets" "src/assets"
@ -44,45 +45,38 @@
"sourceMap": false, "sourceMap": false,
"extractCss": true, "extractCss": true,
"namedChunks": false, "namedChunks": false,
"aot": true,
"extractLicenses": true, "extractLicenses": true,
"vendorChunk": false, "vendorChunk": false,
"buildOptimizer": true "buildOptimizer": true,
}, "budgets": [
"electrs": {
"fileReplacements": [
{ {
"replace": "src/environments/environment.ts", "type": "initial",
"with": "src/environments/environment-electrs.prod.ts" "maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb"
} }
], ]
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true
} }
} }
}, },
"serve": { "serve": {
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
"options": { "options": {
"browserTarget": "mempool:build" "browserTarget": "mempoolspace:build"
}, },
"configurations": { "configurations": {
"production": { "production": {
"browserTarget": "mempool:build:production" "browserTarget": "mempoolspace:build:production"
} }
} }
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n", "builder": "@angular-devkit/build-angular:extract-i18n",
"options": { "options": {
"browserTarget": "mempool:build" "browserTarget": "mempoolspace:build"
} }
}, },
"test": { "test": {
@ -90,54 +84,44 @@
"options": { "options": {
"main": "src/test.ts", "main": "src/test.ts",
"polyfills": "src/polyfills.ts", "polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"karmaConfig": "src/karma.conf.js", "karmaConfig": "karma.conf.js",
"styles": [
"src/styles.scss"
],
"scripts": [],
"assets": [ "assets": [
"src/favicon.ico", "src/favicon.ico",
"src/assets" "src/assets"
] ],
"styles": [
"src/styles.scss"
],
"scripts": []
} }
}, },
"lint": { "lint": {
"builder": "@angular-devkit/build-angular:tslint", "builder": "@angular-devkit/build-angular:tslint",
"options": { "options": {
"tsConfig": [ "tsConfig": [
"src/tsconfig.app.json", "tsconfig.app.json",
"src/tsconfig.spec.json" "tsconfig.spec.json",
"e2e/tsconfig.json"
], ],
"exclude": [ "exclude": [
"**/node_modules/**" "**/node_modules/**"
] ]
} }
} },
}
},
"mempool-e2e": {
"root": "e2e/",
"projectType": "application",
"architect": {
"e2e": { "e2e": {
"builder": "@angular-devkit/build-angular:protractor", "builder": "@angular-devkit/build-angular:protractor",
"options": { "options": {
"protractorConfig": "e2e/protractor.conf.js", "protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "mempool:serve" "devServerTarget": "mempoolspace:serve"
} },
}, "configurations": {
"lint": { "production": {
"builder": "@angular-devkit/build-angular:tslint", "devServerTarget": "mempoolspace:serve:production"
"options": { }
"tsConfig": "e2e/tsconfig.e2e.json",
"exclude": [
"**/node_modules/**"
]
} }
} }
} }
} }},
}, "defaultProject": "mempoolspace"
"defaultProject": "mempool" }
}

12
frontend/browserslist Normal file
View file

@ -0,0 +1,12 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# You can see what browsers were selected by your queries by running:
# npx browserslist
> 0.5%
last 2 versions
Firefox ESR
not dead
not IE 9-11 # For IE 9-11 support, remove 'not'.

View file

@ -1,8 +1,12 @@
// @ts-check
// Protractor configuration file, see link for more information // Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts // https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter'); const { SpecReporter } = require('jasmine-spec-reporter');
/**
* @type { import("protractor").Config }
*/
exports.config = { exports.config = {
allScriptsTimeout: 11000, allScriptsTimeout: 11000,
specs: [ specs: [
@ -21,7 +25,7 @@ exports.config = {
}, },
onPrepare() { onPrepare() {
require('ts-node').register({ require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.e2e.json') project: require('path').join(__dirname, './tsconfig.json')
}); });
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
} }

View file

@ -1,4 +1,5 @@
import { AppPage } from './app.po'; import { AppPage } from './app.po';
import { browser, logging } from 'protractor';
describe('workspace-project App', () => { describe('workspace-project App', () => {
let page: AppPage; let page: AppPage;
@ -9,6 +10,14 @@ describe('workspace-project App', () => {
it('should display welcome message', () => { it('should display welcome message', () => {
page.navigateTo(); page.navigateTo();
expect(page.getParagraphText()).toEqual('Welcome to app!'); expect(page.getTitleText()).toEqual('Welcome to mempoolspace!');
});
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
expect(logs).not.toContain(jasmine.objectContaining({
level: logging.Level.SEVERE,
} as logging.Entry));
}); });
}); });

View file

@ -2,10 +2,10 @@ import { browser, by, element } from 'protractor';
export class AppPage { export class AppPage {
navigateTo() { navigateTo() {
return browser.get('/'); return browser.get(browser.baseUrl) as Promise<any>;
} }
getParagraphText() { getTitleText() {
return element(by.css('app-root h1')).getText(); return element(by.css('app-root h1')).getText() as Promise<string>;
} }
} }

View file

@ -1,7 +1,7 @@
{ {
"extends": "../tsconfig.json", "extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "../out-tsc/app", "outDir": "../out-tsc/e2e",
"module": "commonjs", "module": "commonjs",
"target": "es5", "target": "es5",
"types": [ "types": [
@ -10,4 +10,4 @@
"node" "node"
] ]
} }
} }

View file

@ -16,8 +16,8 @@ module.exports = function (config) {
clearContext: false // leave Jasmine Spec Runner output visible in browser clearContext: false // leave Jasmine Spec Runner output visible in browser
}, },
coverageIstanbulReporter: { coverageIstanbulReporter: {
dir: require('path').join(__dirname, '../coverage'), dir: require('path').join(__dirname, './coverage/mempoolspace'),
reports: ['html', 'lcovonly'], reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true fixWebpackSourcePaths: true
}, },
reporters: ['progress', 'kjhtml'], reporters: ['progress', 'kjhtml'],
@ -26,6 +26,7 @@ module.exports = function (config) {
logLevel: config.LOG_INFO, logLevel: config.LOG_INFO,
autoWatch: true, autoWatch: true,
browsers: ['Chrome'], browsers: ['Chrome'],
singleRun: false singleRun: false,
restartOnFileChange: true
}); });
}; };

File diff suppressed because it is too large Load diff

View file

@ -1,50 +1,55 @@
{ {
"name": "mempool-frontend", "name": "mempoolspace",
"version": "1.0.0", "version": "0.0.0",
"description": "Bitcoin Mempool Visualizer",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --aot --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build --prod", "build": "ng build --prod",
"build-electrs": "ng build --prod --configuration=electrs",
"test": "ng test", "test": "ng test",
"lint": "ng lint", "lint": "ng lint",
"e2e": "ng e2e" "e2e": "ng e2e"
}, },
"author": { "private": true,
"name": "Simon Lindh",
"url": "https://github.com/mempool-space/mempool.space"
},
"license": "MIT",
"dependencies": { "dependencies": {
"@angular/animations": "^8.2.11", "@angular/animations": "~9.0.0",
"@angular/common": "^8.2.11", "@angular/common": "~9.0.0",
"@angular/compiler": "^8.2.11", "@angular/compiler": "~9.0.0",
"@angular/core": "^8.2.11", "@angular/core": "~9.0.0",
"@angular/forms": "^8.2.11", "@angular/forms": "~9.0.0",
"@angular/platform-browser": "^8.2.11", "@angular/localize": "^9.0.1",
"@angular/platform-browser-dynamic": "^8.2.11", "@angular/platform-browser": "~9.0.0",
"@angular/router": "^8.2.11", "@angular/platform-browser-dynamic": "~9.0.0",
"@ng-bootstrap/ng-bootstrap": "^5.1.1", "@angular/router": "~9.0.0",
"angularx-qrcode": "^1.7.0-beta.5", "@ng-bootstrap/ng-bootstrap": "^5.3.0",
"bootstrap": "^4.3.1", "@types/qrcode": "^1.3.4",
"chartist": "^0.11.2", "bootstrap": "^4.4.1",
"core-js": "^3.4.1", "chartist": "^0.11.4",
"ng-chartist": "^2.0.0-beta.1", "clipboard": "^2.0.4",
"rxjs": "^6.5.3", "qrcode": "^1.4.4",
"tslib": "^1.9.0", "rxjs": "~6.5.3",
"tlite": "^0.1.9",
"tslib": "^1.10.0",
"zone.js": "~0.10.2" "zone.js": "~0.10.2"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "~0.800.0", "@angular-devkit/build-angular": "~0.900.1",
"@angular/cli": "~8.3.12", "@angular/cli": "~9.0.1",
"@angular/compiler-cli": "^8.2.11", "@angular/compiler-cli": "~9.0.0",
"@angular/language-service": "^8.2.11", "@angular/language-service": "~9.0.0",
"@types/chartist": "^0.9.46", "@types/jasmine": "~3.3.8",
"@types/node": "~8.9.4", "@types/jasminewd2": "~2.0.3",
"codelyzer": "~5.1.0", "@types/node": "^12.11.1",
"codelyzer": "^5.1.2",
"jasmine-core": "~3.4.0",
"jasmine-spec-reporter": "~4.2.1",
"karma": "~4.1.0",
"karma-chrome-launcher": "~2.2.0",
"karma-coverage-istanbul-reporter": "~2.0.1",
"karma-jasmine": "~2.0.1",
"karma-jasmine-html-reporter": "^1.4.0",
"protractor": "~5.4.0",
"ts-node": "~7.0.0", "ts-node": "~7.0.0",
"tslint": "~5.15.0", "tslint": "~5.15.0",
"typescript": "~3.4.3" "typescript": "~3.6.4"
} }
} }

View file

@ -7,5 +7,12 @@
"target": "http://localhost:8999/", "target": "http://localhost:8999/",
"secure": false, "secure": false,
"ws": true "ws": true
},
"/electrs": {
"target": "https://www.blockstream.info/api/",
"secure": false,
"pathRewrite": {
"^/electrs": ""
}
} }
} }

View file

@ -1,42 +0,0 @@
<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>.
<br />Hosted by <a href="https://twitter.com/wiz">@wiz</a></p>
<h2>Fee API</h2>
<div class="col-4 mx-auto">
<input class="form-control" type="text" value="https://mempool.space/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

@ -1,10 +1,14 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router'; import { Routes, RouterModule } from '@angular/router';
import { BlockchainComponent } from './blockchain/blockchain.component'; import { StartComponent } from './components/start/start.component';
import { AboutComponent } from './about/about.component'; import { TransactionComponent } from './components/transaction/transaction.component';
import { StatisticsComponent } from './statistics/statistics.component'; import { BlockComponent } from './components/block/block.component';
import { TelevisionComponent } from './television/television.component'; import { AddressComponent } from './components/address/address.component';
import { MasterPageComponent } from './master-page/master-page.component'; import { MasterPageComponent } from './components/master-page/master-page.component';
import { AboutComponent } from './components/about/about.component';
import { TelevisionComponent } from './components/television/television.component';
import { StatisticsComponent } from './components/statistics/statistics.component';
import { ExplorerComponent } from './components/explorer/explorer.component';
const routes: Routes = [ const routes: Routes = [
{ {
@ -13,30 +17,30 @@ const routes: Routes = [
children: [ children: [
{ {
path: '', path: '',
children: [], component: StartComponent,
component: BlockchainComponent
},
{
path: 'tx/:id',
children: [],
component: BlockchainComponent
},
{
path: 'about',
children: [],
component: AboutComponent
},
{
path: 'statistics',
component: StatisticsComponent,
}, },
{ {
path: 'graphs', path: 'graphs',
component: StatisticsComponent, component: StatisticsComponent,
}, },
{ {
path: 'explorer', path: 'contributors',
loadChildren: './explorer/explorer.module#ExplorerModule', component: AboutComponent,
},
{
path: 'tx/:id',
children: [],
component: TransactionComponent
},
{
path: 'block/:id',
children: [],
component: BlockComponent
},
{
path: 'address/:id',
children: [],
component: AddressComponent
}, },
], ],
}, },
@ -49,6 +53,7 @@ const routes: Routes = [
redirectTo: '' redirectTo: ''
} }
]; ];
@NgModule({ @NgModule({
imports: [RouterModule.forRoot(routes)], imports: [RouterModule.forRoot(routes)],
exports: [RouterModule] exports: [RouterModule]

View file

@ -1 +0,0 @@
<router-outlet></router-outlet>

View file

@ -1,10 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
constructor() { }
}

View file

@ -1,56 +1,92 @@
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core'; 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 { 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 { ReactiveFormsModule } from '@angular/forms';
import { BlockModalComponent } from './blockchain-blocks/block-modal/block-modal.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { StatisticsComponent } from './statistics/statistics.component'; import { NgbButtonsModule } from '@ng-bootstrap/ng-bootstrap';
import { ProjectedBlockModalComponent } from './blockchain-projected-blocks/projected-block-modal/projected-block-modal.component';
import { TelevisionComponent } from './television/television.component'; import { AppRoutingModule } from './app-routing.module';
import { BlockchainBlocksComponent } from './blockchain-blocks/blockchain-blocks.component'; import { AppComponent } from './components/app/app.component';
import { BlockchainProjectedBlocksComponent } from './blockchain-projected-blocks/blockchain-projected-blocks.component';
import { ApiService } from './services/api.service'; import { StartComponent } from './components/start/start.component';
import { MasterPageComponent } from './master-page/master-page.component'; import { ElectrsApiService } from './services/electrs-api.service';
import { FeeDistributionGraphComponent } from './fee-distribution-graph/fee-distribution-graph.component'; import { TimeSincePipe } from './pipes/time-since/time-since.pipe';
import { BytesPipe } from './pipes/bytes-pipe/bytes.pipe';
import { VbytesPipe } from './pipes/bytes-pipe/vbytes.pipe';
import { WuBytesPipe } from './pipes/bytes-pipe/wubytes.pipe';
import { TransactionComponent } from './components/transaction/transaction.component';
import { TransactionsListComponent } from './components/transactions-list/transactions-list.component';
import { AmountComponent } from './components/amount/amount.component';
import { StateService } from './services/state.service';
import { BlockComponent } from './components/block/block.component';
import { ShortenStringPipe } from './pipes/shorten-string-pipe/shorten-string.pipe';
import { AddressComponent } from './components/address/address.component';
import { SearchFormComponent } from './components/search-form/search-form.component';
import { LatestBlocksComponent } from './components/latest-blocks/latest-blocks.component';
import { WebsocketService } from './services/websocket.service';
import { TimeSinceComponent } from './components/time-since/time-since.component';
import { AddressLabelsComponent } from './components/address-labels/address-labels.component';
import { MempoolBlocksComponent } from './components/mempool-blocks/mempool-blocks.component';
import { CeilPipe } from './pipes/math-ceil/math-ceil.pipe';
import { LatestTransactionsComponent } from './components/latest-transactions/latest-transactions.component';
import { QrcodeComponent } from './components/qrcode/qrcode.component';
import { ClipboardComponent } from './components/clipboard/clipboard.component';
import { MasterPageComponent } from './components/master-page/master-page.component';
import { AboutComponent } from './components/about/about.component';
import { TelevisionComponent } from './components/television/television.component';
import { StatisticsComponent } from './components/statistics/statistics.component';
import { ChartistComponent } from './components/statistics/chartist.component';
import { BlockchainBlocksComponent } from './components/blockchain-blocks/blockchain-blocks.component';
import { BlockchainComponent } from './components/blockchain/blockchain.component';
import { FooterComponent } from './components/footer/footer.component';
import { ExplorerComponent } from './components/explorer/explorer.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
AppComponent, AppComponent,
BlockchainComponent,
FooterComponent,
StatisticsComponent,
AboutComponent, AboutComponent,
TxBubbleComponent,
BlockModalComponent,
ProjectedBlockModalComponent,
TelevisionComponent,
BlockchainBlocksComponent,
BlockchainProjectedBlocksComponent,
MasterPageComponent, MasterPageComponent,
FeeDistributionGraphComponent, TelevisionComponent,
BlockchainComponent,
StartComponent,
BlockchainBlocksComponent,
StatisticsComponent,
TransactionComponent,
BlockComponent,
TransactionsListComponent,
TimeSincePipe,
BytesPipe,
VbytesPipe,
WuBytesPipe,
CeilPipe,
ShortenStringPipe,
AddressComponent,
AmountComponent,
SearchFormComponent,
LatestBlocksComponent,
TimeSinceComponent,
AddressLabelsComponent,
MempoolBlocksComponent,
LatestTransactionsComponent,
QrcodeComponent,
ClipboardComponent,
ExplorerComponent,
ChartistComponent,
FooterComponent,
], ],
imports: [ imports: [
ReactiveFormsModule,
BrowserModule, BrowserModule,
HttpClientModule,
AppRoutingModule, AppRoutingModule,
SharedModule, HttpClientModule,
ReactiveFormsModule,
BrowserAnimationsModule,
NgbButtonsModule,
], ],
providers: [ providers: [
ApiService, ElectrsApiService,
MemPoolService, StateService,
], WebsocketService,
entryComponents: [ VbytesPipe,
BlockModalComponent,
ProjectedBlockModalComponent,
], ],
bootstrap: [AppComponent] bootstrap: [AppComponent]
}) })

View file

@ -1,37 +0,0 @@
<div class="modal-header">
<h4 class="modal-title">Fee distribution for block
<a *ngIf="!isElectrsEnabled" href="https://www.blockstream.info/block-height/{{ block.height }}" target="_blank">#{{ block.height }}</a>
<a *ngIf="isElectrsEnabled" (click)="activeModal.dismiss()" [routerLink]="['/explorer/block/', block.hash]">#{{ 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 - blockSubsidy) | number: '1.2-2' }} BTC <span *ngIf="conversions">(<span class="green-color">{{ conversions.USD * (block.fees - blockSubsidy) | 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>
<app-fee-distribution-graph [blockHeight]="block.height"></app-fee-distribution-graph>
</div>

View file

@ -1,35 +0,0 @@
import { Component, OnInit, Input } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { IBlock } from '../../blockchain/interfaces';
import { MemPoolService } from '../../services/mem-pool.service';
import { environment } from '../../../environments/environment';
@Component({
selector: 'app-block-modal',
templateUrl: './block-modal.component.html',
styleUrls: ['./block-modal.component.scss']
})
export class BlockModalComponent implements OnInit {
@Input() block: IBlock;
blockSubsidy = 50;
isElectrsEnabled = !!environment.electrs;
conversions: any;
constructor(
public activeModal: NgbActiveModal,
private memPoolService: MemPoolService,
) { }
ngOnInit() {
this.memPoolService.conversions$
.subscribe((conversions) => {
this.conversions = conversions;
});
let halvenings = Math.floor(this.block.height / 210000);
while (halvenings > 0) {
this.blockSubsidy = this.blockSubsidy / 2;
halvenings--;
}
}
}

View file

@ -1,21 +0,0 @@
<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 *ngIf="!isElectrsEnabled" href="https://www.blockstream.info/block-height/{{ block.height }}" target="_blank">#{{ block.height }}</a>
<a *ngIf="isElectrsEnabled" [routerLink]="['/explorer/block/', block.hash]">#{{ 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">{{ block.time | timeSince : trigger }} ago</div>
</div>
</div>
</div>
</div>

View file

@ -1,70 +0,0 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { IBlock } from '../blockchain/interfaces';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { BlockModalComponent } from './block-modal/block-modal.component';
import { MemPoolService } from '../services/mem-pool.service';
import { Subscription } from 'rxjs';
import { environment } from '../../environments/environment';
@Component({
selector: 'app-blockchain-blocks',
templateUrl: './blockchain-blocks.component.html',
styleUrls: ['./blockchain-blocks.component.scss']
})
export class BlockchainBlocksComponent implements OnInit, OnDestroy {
blocks: IBlock[] = [];
blocksSubscription: Subscription;
interval: any;
trigger = 0;
isElectrsEnabled = !!environment.electrs;
constructor(
private modalService: NgbModal,
private memPoolService: MemPoolService,
) { }
ngOnInit() {
this.blocksSubscription = this.memPoolService.blocks$
.subscribe((block) => {
if (this.blocks.some((b) => b.height === block.height)) {
return;
}
this.blocks.unshift(block);
this.blocks = this.blocks.slice(0, 8);
});
this.interval = setInterval(() => this.trigger++, 10 * 1000);
}
ngOnDestroy() {
this.blocksSubscription.unsubscribe();
clearInterval(this.interval);
}
trackByBlocksFn(index: number, item: IBlock) {
return item.height;
}
openBlockModal(block: IBlock) {
const modalRef = this.modalService.open(BlockModalComponent, { size: 'lg' });
modalRef.componentInstance.block = block;
}
getStyleForBlock(block: IBlock) {
const greenBackgroundHeight = 100 - (block.weight / 4000000) * 100;
if (window.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%)`,
};
}
}
}

View file

@ -1,20 +0,0 @@
<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>

View file

@ -1,58 +0,0 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { IProjectedBlock, IBlock } from '../blockchain/interfaces';
import { ProjectedBlockModalComponent } from './projected-block-modal/projected-block-modal.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { MemPoolService } from '../services/mem-pool.service';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-blockchain-projected-blocks',
templateUrl: './blockchain-projected-blocks.component.html',
styleUrls: ['./blockchain-projected-blocks.component.scss']
})
export class BlockchainProjectedBlocksComponent implements OnInit, OnDestroy {
projectedBlocks: IProjectedBlock[];
subscription: Subscription;
constructor(
private modalService: NgbModal,
private memPoolService: MemPoolService,
) { }
ngOnInit() {
this.subscription = this.memPoolService.projectedBlocks$
.subscribe((projectedblocks) => this.projectedBlocks = projectedblocks);
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
trackByProjectedFn(index: number) {
return index;
}
openProjectedBlockModal(block: IBlock, index: number) {
const modalRef = this.modalService.open(ProjectedBlockModalComponent, { size: 'lg' });
modalRef.componentInstance.block = block;
modalRef.componentInstance.index = index;
}
getStyleForProjectedBlockAtIndex(index: number) {
const greenBackgroundHeight = 100 - (this.projectedBlocks[index].blockWeight / 4000000) * 100;
if (window.innerWidth <= 768) {
return {
'top': 40 + index * 155 + 'px',
'background': `repeating-linear-gradient(#554b45, #554b45 ${greenBackgroundHeight}%,
#bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`,
};
} else {
return {
'right': 40 + index * 155 + 'px',
'background': `repeating-linear-gradient(#554b45, #554b45 ${greenBackgroundHeight}%,
#bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`,
};
}
}
}

View file

@ -1,30 +0,0 @@
<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>
<app-fee-distribution-graph [projectedBlockIndex]="index"></app-fee-distribution-graph>
</div>

View file

@ -1,29 +0,0 @@
import { Component, OnInit, Input } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { MemPoolService } from '../../services/mem-pool.service';
import { IBlock } from 'src/app/blockchain/interfaces';
@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;
conversions: any;
constructor(
public activeModal: NgbActiveModal,
private memPoolService: MemPoolService,
) { }
ngOnInit() {
this.memPoolService.conversions$
.subscribe((conversions) => {
this.conversions = conversions;
});
}
}

View file

@ -1,24 +0,0 @@
<div *ngIf="isLoading" class="text-center">
<h3>Loading blocks...</h3>
<br>
<div class="spinner-border text-light"></div>
</div>
<div *ngIf="!isLoading && 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">
<app-blockchain-projected-blocks></app-blockchain-projected-blocks>
<app-blockchain-blocks></app-blockchain-blocks>
<div id="divider" *ngIf="!isLoading"></div>
</div>
</div>
<app-tx-bubble></app-tx-bubble>
<app-footer></app-footer>

View file

@ -1,83 +0,0 @@
import { Component, OnInit, OnDestroy, Renderer2 } from '@angular/core';
import { MemPoolService, ITxTracking } from '../services/mem-pool.service';
import { ApiService } from '../services/api.service';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { Subscription } from 'rxjs';
import { take } from 'rxjs/operators';
@Component({
selector: 'app-blockchain',
templateUrl: './blockchain.component.html',
styleUrls: ['./blockchain.component.scss']
})
export class BlockchainComponent implements OnInit, OnDestroy {
txTrackingSubscription: Subscription;
blocksSubscription: Subscription;
txTrackingLoading = false;
txShowTxNotFound = false;
isLoading = true;
constructor(
private memPoolService: MemPoolService,
private apiService: ApiService,
private renderer: Renderer2,
private route: ActivatedRoute,
) {}
ngOnInit() {
this.apiService.webSocketWant(['stats', 'blocks', 'projected-blocks']);
this.txTrackingSubscription = this.memPoolService.txTracking$
.subscribe((response: ITxTracking) => {
this.txTrackingLoading = false;
this.txShowTxNotFound = response.notFound;
if (this.txShowTxNotFound) {
setTimeout(() => { this.txShowTxNotFound = false; }, 2000);
}
});
this.renderer.addClass(document.body, 'disable-scroll');
this.route.paramMap
.subscribe((params: ParamMap) => {
if (this.memPoolService.txTracking$.value.enabled) {
return;
}
const txId: string | null = params.get('id');
if (!txId) {
return;
}
this.txTrackingLoading = true;
this.apiService.webSocketStartTrackTx(txId);
});
this.memPoolService.txIdSearch$
.subscribe((txId) => {
if (txId) {
if (this.memPoolService.txTracking$.value.enabled
&& this.memPoolService.txTracking$.value.tx
&& this.memPoolService.txTracking$.value.tx.txid === txId) {
return;
}
console.log('enabling tracking loading from idSearch!');
this.txTrackingLoading = true;
this.apiService.webSocketStartTrackTx(txId);
}
});
this.blocksSubscription = this.memPoolService.blocks$
.pipe(
take(1)
)
.subscribe((block) => this.isLoading = false);
}
ngOnDestroy() {
this.blocksSubscription.unsubscribe();
this.txTrackingSubscription.unsubscribe();
this.renderer.removeClass(document.body, 'disable-scroll');
}
}

View file

@ -1,177 +0,0 @@
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[];
'live-2h-chart'?: IMempoolStats;
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,61 @@
<div class="container">
<div class="text-center">
<br />
<img src="./assets/mempool-tube.png" width="63" height="63" />
<br /><br />
<h1>Contributors</h1>
<p>Mempool.Space is a realtime Bitcoin blockchain explorer and mempool visualizer.</p>
<p>Development <a href="https://twitter.com/softbtc">@softbtc</a>
<br />Operations <a href="https://twitter.com/wiz">@wiz</a>
<br />Design <a href="https://instagram.com/markjborg">@markjborg</a>
</div>
<h2>HTTP API</h2>
<table class="table">
<tr>
<td style="width: 50%;">Fee API</td>
<td>
<div class="mx-auto">
<input class="form-control" type="text" value="https://mempool.space/api/v1/fees/recommended" readonly>
</div>
</td>
</tr>
<tr>
<td>Mempool blocks</td>
<td>
<div class="mx-auto">
<input class="form-control" type="text" value="https://mempool.space/api/v1/fees/mempool-blocks" readonly>
</div>
</td>
</tr>
</table>
<h2>WebSocket API</h2>
<table class="table">
<tr>
<td style="width: 50%;">
<span class="text-small">
Default push: <span class="code">{{ '{' }} action: 'want', data: ['blocks', ...] {{ '}' }}</span>
to express what you want pushed. Available: 'blocks', 'mempool-blocks', 'live-2h-chart' and 'stats'.
</span>
<br><br>
<span class="text-small">
Push transactions related to address: <span class="code">{{ '{' }} 'track-address': '3PbJ...bF9B' {{ '}' }}</span>
to receive all new transactions containing that address as input or output. Returns an array of transactions. 'address-transactions' for new mempool transactions and 'address-block-transactions' for new block confirmed transactions.
</span>
</td>
<td>
<div class="mx-auto">
<input class="form-control" type="text" value="wss://mempool.space/ws" readonly>
</div>
</td>
</tr>
</table>
<br> <br>
</div>

View file

@ -0,0 +1,8 @@
.text-small {
font-size: 12px;
}
.code {
background-color: #1d1f31;
font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New;
}

View file

@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ApiService } from '../services/api.service'; import { WebsocketService } from '../../services/websocket.service';
@Component({ @Component({
selector: 'app-about', selector: 'app-about',
@ -9,11 +9,11 @@ import { ApiService } from '../services/api.service';
export class AboutComponent implements OnInit { export class AboutComponent implements OnInit {
constructor( constructor(
private apiService: ApiService, private websocketService: WebsocketService,
) { } ) { }
ngOnInit() { ngOnInit() {
this.apiService.webSocketWant([]); this.websocketService.want(['blocks']);
} }
} }

View file

@ -0,0 +1,2 @@
<span *ngIf="multisig" class="badge badge-pill badge-warning">multisig {{ multisigM }} of {{ multisigN }}</span>
<span *ngIf="lnChannelClose" class="badge badge-pill badge-warning">Lightning Channel Force Close</span>

View file

@ -0,0 +1,3 @@
.badge {
margin-right: 2px;
}

View file

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AddressLabelsComponent } from './address-labels.component';
describe('AddressLabelsComponent', () => {
let component: AddressLabelsComponent;
let fixture: ComponentFixture<AddressLabelsComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AddressLabelsComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AddressLabelsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,68 @@
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
import { Vin, Vout } from '../../interfaces/electrs.interface';
@Component({
selector: 'app-address-labels',
templateUrl: './address-labels.component.html',
styleUrls: ['./address-labels.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddressLabelsComponent implements OnInit {
@Input() vin: Vin;
@Input() vout: Vout;
multisig = false;
multisigM: number;
multisigN: number;
lnChannelClose = false;
constructor() { }
ngOnInit() {
if (this.vin) {
this.handleVin();
} else if (this.vout) {
this.handleVout();
}
}
handleVin() {
if (this.vin.inner_witnessscript_asm) {
if (this.vin.inner_witnessscript_asm.indexOf('OP_CHECKMULTISIG') > -1) {
const matches = this.getMatches(this.vin.inner_witnessscript_asm, /OP_PUSHNUM_([0-9])/g, 1);
this.multisig = true;
this.multisigM = matches[0];
this.multisigN = matches[1];
}
if (/OP_IF (.+) OP_ELSE (.+) OP_CSV OP_DROP/.test(this.vin.inner_witnessscript_asm)) {
this.lnChannelClose = true;
}
}
if (this.vin.inner_redeemscript_asm && this.vin.inner_redeemscript_asm.indexOf('OP_CHECKMULTISIG') > -1) {
const matches = this.getMatches(this.vin.inner_redeemscript_asm, /OP_PUSHNUM_([0-9])/g, 1);
this.multisig = true;
this.multisigM = matches[0];
this.multisigN = matches[1];
}
}
handleVout() {
}
getMatches(str: string, regex: RegExp, index: number) {
if (!index) {
index = 1;
}
const matches = [];
let match;
while (match = regex.exec(str)) {
matches.push(match[index]);
}
return matches;
}
}

View file

@ -0,0 +1,100 @@
<div class="container">
<h1 style="float: left;">Address</h1>
<a [routerLink]="['/address/', addressString]" style="line-height: 56px; margin-left: 10px;">{{ addressString }}</a>
<app-clipboard [text]="addressString"></app-clipboard>
<br>
<div class="clearfix"></div>
<ng-template [ngIf]="!isLoadingAddress && !error">
<div class="box">
<div class="row">
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td>Number of transactions</td>
<td>{{ address.chain_stats.tx_count + address.mempool_stats.tx_count }}</td>
</tr>
<tr>
<td>Total received</td>
<td>{{ (address.chain_stats.funded_txo_sum + address.mempool_stats.funded_txo_sum) / 100000000 | number: '1.2-2' }} BTC</td>
</tr>
<tr>
<td>Total sent</td>
<td>{{ (address.chain_stats.spent_txo_sum + address.mempool_stats.spent_txo_sum) / 100000000 | number: '1.2-2' }} BTC</td>
</tr>
</tbody>
</table>
</div>
<div class="col text-right">
<div class="qr-wrapper">
<app-qrcode [data]="address.address"></app-qrcode>
<!--qrcode id="qrCode" [qrdata]="address.address" [size]="128" [level]="'M'"></qrcode>-->
</div>
</div>
</div>
</div>
<br>
<h2><ng-template [ngIf]="transactions?.length">{{ transactions?.length || '?' }} of </ng-template>{{ address.chain_stats.tx_count + address.mempool_stats.tx_count + addedTransactions }} transactions</h2>
<app-transactions-list [transactions]="transactions" [showConfirmations]="true"></app-transactions-list>
<div class="text-center">
<ng-template [ngIf]="isLoadingTransactions">
<div class="spinner-border"></div>
<br><br>
</ng-template>
<button *ngIf="transactions?.length && transactions?.length !== (address.chain_stats.tx_count + address.mempool_stats.tx_count)" type="button" class="btn btn-primary" (click)="loadMore()">Load more</button>
</div>
</ng-template>
<ng-template [ngIf]="isLoadingAddress && !error">
<div class="box">
<div class="row">
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
</tbody>
</table>
</div>
<div class="col">
</div>
</div>
</div>
<br>
<div class="text-center">
<div class="spinner-border"></div>
<br><br>
</div>
</ng-template>
<ng-template [ngIf]="error">
<div class="text-center">
Error loading address data.
<br>
<i>{{ error.error }}</i>
</div>
</ng-template>
</div>
<br>

View file

@ -1,15 +1,11 @@
.header-bg { .header-bg {
background-color:#653b9c;
font-size: 14px; font-size: 14px;
} }
.header-bg a {
color: #FFF;
text-decoration: underline;
}
.qr-wrapper { .qr-wrapper {
background-color: #FFF; background-color: #FFF;
padding: 10px; padding: 10px;
padding-bottom: 5px;
display: inline-block; display: inline-block;
margin-right: 25px; margin-right: 25px;
} }

View file

@ -0,0 +1,99 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { switchMap } from 'rxjs/operators';
import { Address, Transaction } from '../../interfaces/electrs.interface';
import { WebsocketService } from 'src/app/services/websocket.service';
import { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-address',
templateUrl: './address.component.html',
styleUrls: ['./address.component.scss']
})
export class AddressComponent implements OnInit, OnDestroy {
address: Address;
addressString: string;
isLoadingAddress = true;
transactions: Transaction[];
isLoadingTransactions = true;
error: any;
addedTransactions = 0;
constructor(
private route: ActivatedRoute,
private electrsApiService: ElectrsApiService,
private websocketService: WebsocketService,
private stateService: StateService,
) { }
ngOnInit() {
this.websocketService.want(['blocks', 'stats', 'mempool-blocks']);
this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
this.error = undefined;
this.isLoadingAddress = true;
this.isLoadingTransactions = true;
this.transactions = null;
document.body.scrollTo(0, 0);
this.addressString = params.get('id') || '';
return this.electrsApiService.getAddress$(this.addressString);
})
)
.subscribe((address) => {
this.address = address;
this.websocketService.startTrackAddress(address.address);
this.isLoadingAddress = false;
this.getAddressTransactions(address.address);
},
(error) => {
console.log(error);
this.error = error;
this.isLoadingAddress = false;
});
this.stateService.mempoolTransactions$
.subscribe((transaction) => {
this.transactions.unshift(transaction);
this.addedTransactions++;
});
this.stateService.blockTransactions$
.subscribe((transaction) => {
const tx = this.transactions.find((t) => t.txid === transaction.txid);
if (tx) {
tx.status = transaction.status;
}
});
this.stateService.isOffline$
.subscribe((state) => {
if (!state && this.transactions && this.transactions.length) {
this.isLoadingTransactions = true;
this.getAddressTransactions(this.address.address);
}
});
}
getAddressTransactions(address: string) {
this.electrsApiService.getAddressTransactions$(address)
.subscribe((transactions: any) => {
this.transactions = transactions;
this.isLoadingTransactions = false;
});
}
loadMore() {
this.isLoadingTransactions = true;
this.electrsApiService.getAddressTransactionsFromHash$(this.address.address, this.transactions[this.transactions.length - 1].txid)
.subscribe((transactions) => {
this.transactions = this.transactions.concat(transactions);
this.isLoadingTransactions = false;
});
}
ngOnDestroy() {
this.websocketService.startTrackAddress('stop');
}
}

View file

@ -0,0 +1,6 @@
<ng-container *ngIf="(viewFiat$ | async) && (conversions$ | async) as conversions; else viewFiatVin">
<span>{{ conversions.USD * (satoshis / 100000000) | currency:'USD':'symbol':'1.2-2' }}</span>
</ng-container>
<ng-template #viewFiatVin>
{{ satoshis / 100000000 }} BTC
</ng-template>

View file

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

View file

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AmountComponent } from './amount.component';
describe('AmountComponent', () => {
let component: AmountComponent;
let fixture: ComponentFixture<AmountComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AmountComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AmountComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,26 @@
import { Component, OnInit, Input, ChangeDetectionStrategy } from '@angular/core';
import { StateService } from '../../services/state.service';
import { Observable } from 'rxjs';
@Component({
selector: 'app-amount',
templateUrl: './amount.component.html',
styleUrls: ['./amount.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AmountComponent implements OnInit {
conversions$: Observable<any>;
viewFiat$: Observable<boolean>;
@Input() satoshis: number;
constructor(
private stateService: StateService,
) { }
ngOnInit() {
this.viewFiat$ = this.stateService.viewFiat$.asObservable();
this.conversions$ = this.stateService.conversions$.asObservable();
}
}

View file

@ -0,0 +1 @@
<router-outlet></router-outlet>

View file

@ -0,0 +1,7 @@
footer {
max-width: 960px;
}
.logo {
height: 40px;
}

View file

@ -0,0 +1,35 @@
import { TestBed, async } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule
],
declarations: [
AppComponent
],
}).compileComponents();
}));
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'mempoolspace'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app.title).toEqual('mempoolspace');
});
it('should render title in a h1 tag', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('h1').textContent).toContain('Welcome to mempoolspace!');
});
});

View file

@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { WebsocketService } from '../../services/websocket.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
constructor(
public router: Router,
private websocketService: WebsocketService,
) { }
}

View file

@ -0,0 +1,140 @@
<div class="container">
<app-blockchain position="top" [markHeight]="blockHeight"></app-blockchain>
<div class="title-block">
<h1>Block <ng-template [ngIf]="blockHeight"><a [routerLink]="['/block/', blockHash]">#{{ blockHeight }}</a></ng-template></h1>
</div>
<ng-template [ngIf]="!isLoadingBlock && !error">
<br>
<div class="box">
<div class="row">
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td>Timestamp</td>
<td>{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} <i>(<app-time-since [time]="block.timestamp"></app-time-since> ago)</i></td>
</tr>
<tr>
<td>Number of transactions</td>
<td>{{ block.tx_count }}</td>
</tr>
<tr>
<td>Size</td>
<td>{{ block.size | bytes: 2 }}</td>
</tr>
<tr>
<td>Weight</td>
<td>{{ block.weight | wuBytes: 2 }}</td>
</tr>
</tbody>
</table>
</div>
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td>Hash</td>
<td><a [routerLink]="['/block/', block.id]" title="{{ block.id }}">{{ block.id | shortenString : 32 }}</a> <app-clipboard [text]="block.id"></app-clipboard></td>
</tr>
<tr>
<td>Previous Block</td>
<td><a [routerLink]="['/block/', block.previousblockhash]" [state]="{ data: { blockHeight: blockHeight - 1 } }" title="{{ block.previousblockhash }}">{{ block.previousblockhash | shortenString : 32 }}</a></td>
</tr>
<tr>
<td>Block subsidy</td>
<td>{{ blockSubsidy | number: '1.2-2' }} BTC <span *ngIf="conversions">(<span class="green-color">{{ conversions.USD * blockSubsidy | currency:'USD':'symbol':'1.0-0' }}</span>)</span></td>
</tr>
<tr>
<td>Status</td>
<td><ng-template [ngIf]="latestBlock">{{ (latestBlock.height - block.height + 1) }} confirmation{{ (latestBlock.height - block.height + 1) === 1 ? '' : 's' }}</ng-template></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<br>
<h2><ng-template [ngIf]="transactions?.length">{{ transactions?.length || '?' }} of </ng-template>{{ block.tx_count }} transactions</h2>
<br>
<app-transactions-list [transactions]="transactions"></app-transactions-list>
<div class="text-center">
<ng-template [ngIf]="isLoadingTransactions">
<div class="spinner-border"></div>
<br><br>
</ng-template>
<button *ngIf="transactions?.length && transactions?.length !== block.tx_count" type="button" class="btn btn-primary" (click)="loadMore()">Load more</button>
</div>
</ng-template>
<ng-template [ngIf]="isLoadingBlock && !error">
<br>
<div class="box">
<div class="row">
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
</tbody>
</table>
</div>
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<br>
<div class="text-center">
<div class="spinner-border"></div>
<br><br>
</div>
</ng-template>
<ng-template [ngIf]="error">
<div class="text-center">
Error loading block data.
<br>
<i>{{ error.error }}</i>
</div>
</ng-template>
</div>
<br>

View file

@ -0,0 +1,11 @@
.title-block {
color: #FFF;
padding-left: 10px;
padding-top: 20px;
padding-bottom: 3px;
border-top: 5px solid #FFF;
}
.title-block > h1 {
margin: 0;
}

View file

@ -0,0 +1,101 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { switchMap } from 'rxjs/operators';
import { Block, Transaction } from '../../interfaces/electrs.interface';
import { of } from 'rxjs';
import { StateService } from '../../services/state.service';
import { WebsocketService } from 'src/app/services/websocket.service';
@Component({
selector: 'app-block',
templateUrl: './block.component.html',
styleUrls: ['./block.component.scss']
})
export class BlockComponent implements OnInit {
block: Block;
blockHeight: number;
blockHash: string;
isLoadingBlock = true;
latestBlock: Block;
transactions: Transaction[];
isLoadingTransactions = true;
error: any;
blockSubsidy = 50;
conversions: any;
constructor(
private route: ActivatedRoute,
private electrsApiService: ElectrsApiService,
private stateService: StateService,
private websocketService: WebsocketService,
) { }
ngOnInit() {
this.websocketService.want(['blocks', 'stats', 'mempool-blocks']);
this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
const blockHash: string = params.get('id') || '';
this.error = undefined;
if (history.state.data && history.state.data.blockHeight) {
this.blockHeight = history.state.data.blockHeight;
}
this.blockHash = blockHash;
document.body.scrollTo(0, 0);
if (history.state.data && history.state.data.block) {
this.blockHeight = history.state.data.block.height;
return of(history.state.data.block);
} else {
this.isLoadingBlock = true;
return this.electrsApiService.getBlock$(blockHash);
}
})
)
.subscribe((block: Block) => {
this.block = block;
this.blockHeight = block.height;
this.isLoadingBlock = false;
this.getBlockTransactions(block.id);
},
(error) => {
this.error = error;
this.isLoadingBlock = false;
});
this.stateService.blocks$
.subscribe((block) => this.latestBlock = block);
this.stateService.conversions$
.subscribe((conversions) => {
this.conversions = conversions;
});
let halvenings = Math.floor(this.block.height / 210000);
while (halvenings > 0) {
this.blockSubsidy = this.blockSubsidy / 2;
halvenings--;
}
}
getBlockTransactions(hash: string) {
this.electrsApiService.getBlockTransactions$(hash)
.subscribe((transactions: any) => {
this.transactions = transactions;
this.isLoadingTransactions = false;
});
}
loadMore() {
this.isLoadingTransactions = true;
this.electrsApiService.getBlockTransactions$(this.block.id, this.transactions.length)
.subscribe((transactions) => {
this.transactions = this.transactions.concat(transactions);
this.isLoadingTransactions = false;
});
}
}

View file

@ -0,0 +1,22 @@
<div class="blocks-container" *ngIf="blocks.length">
<div *ngFor="let block of blocks; let i = index; trackBy: trackByBlocksFn" >
<div class="text-center bitcoin-block mined-block" id="bitcoin-block-{{ block.height }}" [ngStyle]="getStyleForBlock(block)">
<a [routerLink]="['/block/', block.id]" [state]="{ data: { block: block } }" class="blockLink">&nbsp;</a>
<div class="block-height">
<a [routerLink]="['/block/', block.id]" [state]="{ data: { block: block } }">#{{ block.height }}</a>
</div>
<div class="block-body">
<div class="fees">
~{{ block.medianFee | ceil }} sat/vB
<br/>
<span class="yellow-color">{{ block.feeRange[0] | ceil }} - {{ block.feeRange[block.feeRange.length - 1] | ceil }} sat/vB</span>
</div>
<div class="block-size">{{ block.size | bytes: 2 }}</div>
<div class="transaction-count">{{ block.tx_count }} transactions</div>
<br /><br />
<div class="time-difference">{{ block.timestamp | timeSince : trigger }} ago</div>
</div>
</div>
</div>
<div [hidden]="!arrowVisible" id="arrow-up" [ngStyle]="{'left': arrowLeftPx + 'px' }"></div>
</div>

View file

@ -1,7 +1,14 @@
.bitcoin-block { .bitcoin-block {
width: 125px; width: 125px;
height: 125px; height: 125px;
cursor: pointer; }
.blockLink {
width: 100%;
height: 100%;
position: absolute;
left: 0;
z-index: 10;
} }
.mined-block { .mined-block {
@ -94,3 +101,15 @@
z-index: 100; z-index: 100;
position: relative; position: relative;
} }
#arrow-up {
position: relative;
left: 30px;
top: 140px;
transition: 1s;
width: 0;
height: 0;
border-left: 35px solid transparent;
border-right: 35px solid transparent;
border-bottom: 35px solid #FFF;
}

View file

@ -0,0 +1,84 @@
import { Component, OnInit, OnDestroy, Input, OnChanges } from '@angular/core';
import { Subscription } from 'rxjs';
import { Block } from 'src/app/interfaces/electrs.interface';
import { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-blockchain-blocks',
templateUrl: './blockchain-blocks.component.html',
styleUrls: ['./blockchain-blocks.component.scss']
})
export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
@Input() markHeight = 0;
blocks: Block[] = [];
blocksSubscription: Subscription;
interval: any;
trigger = 0;
arrowVisible = false;
arrowLeftPx = 30;
constructor(
private stateService: StateService,
) { }
ngOnInit() {
this.blocksSubscription = this.stateService.blocks$
.subscribe((block) => {
if (this.blocks.some((b) => b.height === block.height)) {
return;
}
this.blocks.unshift(block);
this.blocks = this.blocks.slice(0, 8);
this.moveArrowToPosition();
});
this.interval = setInterval(() => this.trigger++, 10 * 1000);
}
ngOnChanges() {
this.moveArrowToPosition();
}
ngOnDestroy() {
this.blocksSubscription.unsubscribe();
clearInterval(this.interval);
}
moveArrowToPosition() {
if (!this.markHeight) {
this.arrowVisible = false;
return;
}
const blockindex = this.blocks.findIndex((b) => b.height === this.markHeight);
if (blockindex !== -1) {
this.arrowVisible = true;
this.arrowLeftPx = blockindex * 155 + 30;
}
}
trackByBlocksFn(index: number, item: Block) {
return item.height;
}
getStyleForBlock(block: Block) {
const greenBackgroundHeight = 100 - (block.weight / 4000000) * 100;
if (window.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%)`,
};
}
}
}

View file

@ -0,0 +1,17 @@
<div class="text-center" class="blockchain-wrapper">
<div class="position-container">
<app-mempool-blocks [txFeePerVSize]="markHeight ? 0 : txFeePerVSize"></app-mempool-blocks>
<app-blockchain-blocks [markHeight]="markHeight"></app-blockchain-blocks>
<div id="divider" *ngIf="!isLoading; else loadingTmpl"></div>
<ng-template #loadingTmpl>
<div class="loading-block">
<h3>Waiting for blocks...</h3>
<br>
<div class="spinner-border text-light"></div>
</div>
</ng-template>
</div>
</div>

View file

@ -1,8 +1,8 @@
#divider { #divider {
width: 3px; width: 3px;
height: 3000px; height: 200px;
left: 0; left: 0;
top: -1000px; top: -50px;
background-image: url('/assets/divider-new.png'); background-image: url('/assets/divider-new.png');
background-repeat: repeat-y; background-repeat: repeat-y;
position: absolute; position: absolute;
@ -17,12 +17,13 @@
.blockchain-wrapper { .blockchain-wrapper {
overflow: hidden; overflow: hidden;
height: 250px;
} }
.position-container { .position-container {
position: absolute; position: absolute;
left: 50%; left: 50%;
top: calc(50% - 60px); top: 75px;
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
@ -46,3 +47,12 @@
z-index: 100; z-index: 100;
position: relative; position: relative;
} }
.loading-block {
position: absolute;
text-align: center;
margin: auto;
width: 300px;
left: -150px;
top: 0px;
}

View file

@ -0,0 +1,38 @@
import { Component, OnInit, OnDestroy, Input } from '@angular/core';
import { Subscription } from 'rxjs';
import { take } from 'rxjs/operators';
import { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-blockchain',
templateUrl: './blockchain.component.html',
styleUrls: ['./blockchain.component.scss']
})
export class BlockchainComponent implements OnInit, OnDestroy {
@Input() position: 'middle' | 'top' = 'middle';
@Input() markHeight: number;
@Input() txFeePerVSize: number;
txTrackingSubscription: Subscription;
blocksSubscription: Subscription;
txTrackingLoading = false;
txShowTxNotFound = false;
isLoading = true;
constructor(
private stateService: StateService,
) {}
ngOnInit() {
this.blocksSubscription = this.stateService.blocks$
.pipe(
take(1)
)
.subscribe((block) => this.isLoading = false);
}
ngOnDestroy() {
this.blocksSubscription.unsubscribe();
}
}

View file

@ -0,0 +1,5 @@
<span #buttonWrapper [attr.data-tlite]="'Copied!'" style="position: relative;">
<button #btn class="btn btn-sm btn-link pt-0" style="line-height: 1;" [attr.data-clipboard-text]="text">
<img src="./assets/clippy.svg" width="13">
</button>
</span>

View file

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ClipboardComponent } from './clipboard.component';
describe('ClipboardComponent', () => {
let component: ClipboardComponent;
let fixture: ComponentFixture<ClipboardComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ ClipboardComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ClipboardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,33 @@
import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, Input } from '@angular/core';
import * as ClipboardJS from 'clipboard';
import * as tlite from 'tlite';
@Component({
selector: 'app-clipboard',
templateUrl: './clipboard.component.html',
styleUrls: ['./clipboard.component.scss']
})
export class ClipboardComponent implements AfterViewInit {
@ViewChild('btn') btn: ElementRef;
@ViewChild('buttonWrapper') buttonWrapper: ElementRef;
@Input() text: string;
clipboard: any;
constructor() { }
ngAfterViewInit() {
this.clipboard = new ClipboardJS(this.btn.nativeElement);
this.clipboard.on('success', (e) => {
tlite.show(this.buttonWrapper.nativeElement);
setTimeout(() => {
tlite.hide(this.buttonWrapper.nativeElement);
}, 1000);
});
}
onDestroy() {
this.clipboard.destroy();
}
}

View file

@ -0,0 +1,14 @@
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<a class="nav-link" [class.active]="view === 'blocks'" routerLink="/explorer" (click)="view = 'blocks'">Blocks</a>
</li>
<li class="nav-item">
<a class="nav-link" [class.active]="view === 'transactions'" routerLink="/explorer" fragment="transactions" (click)="view = 'transactions'">Transactions</a>
</li>
</ul>
<app-latest-blocks *ngIf="view === 'blocks'; else latestTransactions"></app-latest-blocks>
<ng-template #latestTransactions>
<app-latest-transactions></app-latest-transactions>
</ng-template>
<br>

View file

@ -0,0 +1,3 @@
.search-container {
padding-top: 50px;
}

View file

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ExplorerComponent } from './explorer.component';
describe('ExplorerComponent', () => {
let component: ExplorerComponent;
let fixture: ComponentFixture<ExplorerComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ ExplorerComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ExplorerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,25 @@
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-explorer',
templateUrl: './explorer.component.html',
styleUrls: ['./explorer.component.scss'],
})
export class ExplorerComponent implements OnInit {
view: 'blocks' | 'transactions' = 'blocks';
constructor(
private route: ActivatedRoute,
) {}
ngOnInit() {
this.route.fragment
.subscribe((fragment: string) => {
if (fragment === 'transactions' ) {
this.view = 'transactions';
}
});
}
}

View file

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

View file

@ -2,12 +2,13 @@
position: fixed; position: fixed;
bottom: 0; bottom: 0;
width: 100%; width: 100%;
height: 120px; height: 60px;
background-color: #1d1f31; background-color: #1d1f31;
box-shadow: 15px 15px 15px 15px #000;
} }
.footer > .container { .footer > .container {
margin-top: 25px; margin-top: 17px;
} }
.txPerSecond { .txPerSecond {

View file

@ -1,5 +1,6 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { MemPoolService, IMemPoolState } from '../services/mem-pool.service'; import { StateService } from 'src/app/services/state.service';
import { MemPoolState } from 'src/app/interfaces/websocket.interface';
@Component({ @Component({
selector: 'app-footer', selector: 'app-footer',
@ -7,30 +8,30 @@ import { MemPoolService, IMemPoolState } from '../services/mem-pool.service';
styleUrls: ['./footer.component.scss'] styleUrls: ['./footer.component.scss']
}) })
export class FooterComponent implements OnInit { export class FooterComponent implements OnInit {
memPoolInfo: IMemPoolState | undefined; memPoolInfo: MemPoolState | undefined;
mempoolBlocks = 0; mempoolBlocks = 0;
progressWidth = ''; progressWidth = '';
progressClass: string; progressClass: string;
mempoolSize = 0; mempoolSize = 0;
constructor( constructor(
private memPoolService: MemPoolService private stateService: StateService,
) { } ) { }
ngOnInit() { ngOnInit() {
this.memPoolService.mempoolStats$ this.stateService.mempoolStats$
.subscribe((mempoolState) => { .subscribe((mempoolState) => {
this.memPoolInfo = mempoolState; this.memPoolInfo = mempoolState;
this.updateProgress(); this.updateProgress();
}); });
this.memPoolService.projectedBlocks$ this.stateService.mempoolBlocks$
.subscribe((projectedblocks) => { .subscribe((mempoolBlocks) => {
if (!projectedblocks.length) { return; } if (!mempoolBlocks.length) { return; }
const size = projectedblocks.map((m) => m.blockSize).reduce((a, b) => a + b); const size = mempoolBlocks.map((m) => m.blockSize).reduce((a, b) => a + b);
const weight = projectedblocks.map((m) => m.blockWeight).reduce((a, b) => a + b); const vsize = mempoolBlocks.map((m) => m.blockVSize).reduce((a, b) => a + b);
this.mempoolSize = size; this.mempoolSize = size;
this.mempoolBlocks = Math.ceil(weight / 4000000); this.mempoolBlocks = Math.ceil(vsize / 1000000);
}); });
} }

View file

@ -0,0 +1,39 @@
<table class="table table-borderless">
<thead>
<th style="width: 120px;">Height</th>
<th class="d-none d-md-block" style="width: 300px;">Timestamp</th>
<th style="width: 200px;">Mined</th>
<th style="width: 150px;">Transactions</th>
<th style="width: 175px;">Size</th>
<th class="d-none d-md-block">Filled</th>
</thead>
<tbody>
<tr *ngFor="let block of blocks; let i= index; trackBy: trackByBlock">
<td><a [routerLink]="['/block', block.id]" [state]="{ data: { block: block } }">#{{ block.height }}</a></td>
<td class="d-none d-md-block">{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}</td>
<td>{{ block.timestamp | timeSince : trigger }} ago</td>
<td>{{ block.tx_count }}</td>
<td>{{ block.size | bytes: 2 }}</td>
<td class="d-none d-md-block">
<div class="progress position-relative">
<div class="progress-bar bg-success" role="progressbar" [ngStyle]="{'width': (block.weight / 4000000)*100 + '%' }"></div>
</div>
</td>
</tr>
<ng-template [ngIf]="isLoading">
<tr *ngFor="let item of [1,2,3,4,5,6,7,8,9,10]">
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
</tr>
</ng-template>
</tbody>
</table>
<div class="text-center">
<br>
<button *ngIf="blocks.length" [disabled]="isLoading" type="button" class="btn btn-primary" (click)="loadMore()">Load more</button>
</div>

View file

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { LatestBlocksComponent } from './latest-blocks.component';
describe('LatestBlocksComponent', () => {
let component: LatestBlocksComponent;
let fixture: ComponentFixture<LatestBlocksComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ LatestBlocksComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LatestBlocksComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,78 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { StateService } from '../../services/state.service';
import { Block } from '../../interfaces/electrs.interface';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-latest-blocks',
templateUrl: './latest-blocks.component.html',
styleUrls: ['./latest-blocks.component.scss'],
})
export class LatestBlocksComponent implements OnInit, OnDestroy {
blocks: any[] = [];
blockSubscription: Subscription;
isLoading = true;
interval: any;
trigger = 0;
constructor(
private electrsApiService: ElectrsApiService,
private stateService: StateService,
) { }
ngOnInit() {
this.blockSubscription = this.stateService.blocks$
.subscribe((block) => {
if (block === null || !this.blocks.length) {
return;
}
if (block.height === this.blocks[0].height) {
return;
}
// If we are out of sync, reload the blocks instead
if (block.height > this.blocks[0].height + 1) {
this.loadInitialBlocks();
return;
}
if (block.height === this.blocks[0].height) {
return;
}
this.blocks.pop();
this.blocks.unshift(block);
});
this.loadInitialBlocks();
this.interval = window.setInterval(() => this.trigger++, 1000 * 60);
}
ngOnDestroy() {
clearInterval(this.interval);
this.blockSubscription.unsubscribe();
}
loadInitialBlocks() {
this.electrsApiService.listBlocks$()
.subscribe((blocks) => {
this.blocks = blocks;
this.isLoading = false;
});
}
loadMore() {
this.isLoading = true;
this.electrsApiService.listBlocks$(this.blocks[this.blocks.length - 1].height - 1)
.subscribe((blocks) => {
this.blocks = this.blocks.concat(blocks);
this.isLoading = false;
});
}
trackByBlock(index: number, block: Block) {
return block.height;
}
}

View file

@ -0,0 +1,28 @@
<table class="table table-borderless">
<thead>
<th>Transaction ID</th>
<th style="width: 200px;">Value</th>
<th style="width: 125px;">Size</th>
<th style="width: 150px;">Fee</th>
</thead>
<tbody>
<ng-container *ngIf="(transactions$ | async) as transactions">
<ng-template [ngIf]="!isLoading">
<tr *ngFor="let transaction of transactions">
<td><a [routerLink]="['/tx/', transaction.txid]">{{ transaction.txid }}</a></td>
<td>{{ transaction.value / 100000000 }} BTC</td>
<td>{{ transaction.vsize | vbytes: 2 }}</td>
<td>{{ transaction.fee / transaction.vsize | number : '1.2-2'}} sats/vB</td>
</tr>
</ng-template>
</ng-container>
<ng-template [ngIf]="isLoading">
<tr *ngFor="let item of [1,2,3,4,5,6,7,8,9,10]">
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
</tr>
</ng-template>
</tbody>
</table>

View file

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { LatestTransactionsComponent } from './latest-transactions.component';
describe('LatestTransactionsComponent', () => {
let component: LatestTransactionsComponent;
let fixture: ComponentFixture<LatestTransactionsComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ LatestTransactionsComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LatestTransactionsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,31 @@
import { Component, OnInit } from '@angular/core';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { Observable, timer } from 'rxjs';
import { Recent } from '../../interfaces/electrs.interface';
import { flatMap, tap } from 'rxjs/operators';
@Component({
selector: 'app-latest-transactions',
templateUrl: './latest-transactions.component.html',
styleUrls: ['./latest-transactions.component.scss']
})
export class LatestTransactionsComponent implements OnInit {
transactions$: Observable<Recent[]>;
isLoading = true;
constructor(
private electrsApiService: ElectrsApiService,
) { }
ngOnInit() {
this.transactions$ = timer(0, 10000)
.pipe(
flatMap(() => {
return this.electrsApiService.getRecentTransaction$()
.pipe(
tap(() => this.isLoading = false)
);
})
);
}
}

View file

@ -0,0 +1,35 @@
<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" [routerLinkActiveOptions]="{exact: true}">
<a class="nav-link" routerLink="/" (click)="collapse()">Blockchain</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="/tv" (click)="collapse()">TV view &nbsp;<img src="./assets/expand.png" width="15"/></a>
</li>
<li class="nav-item" routerLinkActive="active">
<a class="nav-link" routerLink="/contributors" (click)="collapse()">Contributors</a>
</li>
</ul>
<app-search-form location="top"></app-search-form>
</div>
</nav>
</header>
<br />
<router-outlet></router-outlet>
<br><br><br>
<app-footer></app-footer>

View file

@ -14,15 +14,17 @@ li.nav-item {
.navbar { .navbar {
padding: 0rem 1rem; padding: 0rem 1rem;
} }
li.nav-item {
padding: 20px;
}
} }
.logo { .logo {
margin-left: 40px; margin-left: 30px;
} }
li.nav-item a { li.nav-item a {
color: #ffffff; color: #ffffff;
} }
nav {
box-shadow: 0px 0px 15px 0px #000;
}

View file

@ -0,0 +1,27 @@
import { Component, OnInit } from '@angular/core';
import { StateService } from '../../services/state.service';
@Component({
selector: 'app-master-page',
templateUrl: './master-page.component.html',
styleUrls: ['./master-page.component.scss']
})
export class MasterPageComponent implements OnInit {
navCollapsed = false;
isOffline = false;
constructor(
private stateService: StateService,
) { }
ngOnInit() {
this.stateService.isOffline$
.subscribe((state) => {
this.isOffline = state;
});
}
collapse(): void {
this.navCollapsed = !this.navCollapsed;
}
}

View file

@ -0,0 +1,23 @@
<div class="mempool-blocks-container">
<div class="flashing">
<div *ngFor="let projectedBlock of mempoolBlocks; let i = index; trackBy: trackByFn">
<div class="bitcoin-block text-center mempool-block" id="mempool-block-{{ i }}" [ngStyle]="getStyleForMempoolBlockAtIndex(i)">
<div class="block-body" *ngIf="mempoolBlocks?.length">
<div class="fees">
~{{ projectedBlock.medianFee | ceil }} sat/vB
<br/>
<span class="yellow-color">{{ projectedBlock.feeRange[0] | ceil }} - {{ projectedBlock.feeRange[projectedBlock.feeRange.length - 1] | 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 && mempoolBlocks?.length >= 4 && (projectedBlock.blockVSize / 1000000 | ceil) > 1">
<div class="time-difference">+{{ projectedBlock.blockVSize / 1000000 | ceil }} blocks</div>
</ng-template>
</div>
<span class="animated-border"></span>
</div>
</div>
</div>
<div *ngIf="arrowVisible" id="arrow-up" [ngStyle]="{'right': rightPosition + 75 + 'px' }"></div>
</div>

View file

@ -1,7 +1,6 @@
.bitcoin-block { .bitcoin-block {
width: 125px; width: 125px;
height: 125px; height: 125px;
cursor: pointer;
} }
.block-size { .block-size {
@ -9,18 +8,20 @@
font-weight: bold; font-weight: bold;
} }
.projected-blocks-container { .mempool-blocks-container {
position: absolute; position: absolute;
top: 0px; top: 0px;
right: 0px; right: 0px;
left: 0px; left: 0px;
}
.flashing {
animation: opacityPulse 2s ease-out; animation: opacityPulse 2s ease-out;
animation-iteration-count: infinite; animation-iteration-count: infinite;
opacity: 1; opacity: 1;
} }
.projected-block { .mempool-block {
position: absolute; position: absolute;
top: 0; top: 0;
} }
@ -54,7 +55,7 @@
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.projected-blocks-container { .mempool-blocks-container {
position: absolute; position: absolute;
left: -165px; left: -165px;
top: -40px; top: -40px;
@ -87,11 +88,11 @@
transform-origin: top; transform-origin: top;
} }
.projected-block.bitcoin-block::after { .mempool-block.bitcoin-block::after {
background-color: #403834; background-color: #403834;
} }
.projected-block.bitcoin-block::before { .mempool-block.bitcoin-block::before {
background-color: #2d2825; background-color: #2d2825;
} }
} }
@ -101,3 +102,15 @@
z-index: 100; z-index: 100;
position: relative; position: relative;
} }
#arrow-up {
position: relative;
right: 75px;
top: 140px;
transition: 1s;
width: 0;
height: 0;
border-left: 35px solid transparent;
border-right: 35px solid transparent;
border-bottom: 35px solid #FFF;
}

View file

@ -0,0 +1,89 @@
import { Component, OnInit, OnDestroy, Input, EventEmitter, Output, OnChanges } from '@angular/core';
import { Subscription } from 'rxjs';
import { MempoolBlock } from 'src/app/interfaces/websocket.interface';
import { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-mempool-blocks',
templateUrl: './mempool-blocks.component.html',
styleUrls: ['./mempool-blocks.component.scss']
})
export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
mempoolBlocks: MempoolBlock[];
mempoolBlocksSubscription: Subscription;
blockWidth = 125;
blockPadding = 30;
arrowVisible = false;
rightPosition = 0;
@Input() txFeePerVSize: number;
constructor(
private stateService: StateService,
) { }
ngOnInit() {
this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$
.subscribe((blocks) => {
this.mempoolBlocks = blocks;
this.calculateTransactionPosition();
});
}
ngOnChanges() {
this.calculateTransactionPosition();
}
ngOnDestroy() {
this.mempoolBlocksSubscription.unsubscribe();
}
trackByFn(index: number) {
return index;
}
getStyleForMempoolBlockAtIndex(index: number) {
const greenBackgroundHeight = 100 - this.mempoolBlocks[index].blockVSize / 1000000 * 100;
return {
'right': 40 + index * 155 + 'px',
'background': `repeating-linear-gradient(to right, #554b45, #554b45 ${greenBackgroundHeight}%,
#bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`,
};
}
calculateTransactionPosition() {
if (!this.txFeePerVSize || !this.mempoolBlocks) {
this.arrowVisible = false;
return;
}
this.arrowVisible = true;
for (const block of this.mempoolBlocks) {
for (let i = 0; i < block.feeRange.length - 1; i++) {
if (this.txFeePerVSize < block.feeRange[i + 1] && this.txFeePerVSize >= block.feeRange[i]) {
const txInBlockIndex = this.mempoolBlocks.indexOf(block);
const feeRangeIndex = block.feeRange.findIndex((val, index) => this.txFeePerVSize < block.feeRange[index + 1]);
const feeRangeChunkSize = 1 / (block.feeRange.length - 1);
const txFee = this.txFeePerVSize - block.feeRange[i];
const max = block.feeRange[i + 1] - block.feeRange[i];
const blockLocation = txFee / max;
const chunkPositionOffset = blockLocation * feeRangeChunkSize;
const feePosition = feeRangeChunkSize * feeRangeIndex + chunkPositionOffset;
const blockedFilledPercentage = (block.blockVSize > 1000000 ? 1000000 : block.blockVSize) / 1000000;
const arrowRightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding)
+ ((1 - feePosition) * blockedFilledPercentage * this.blockWidth);
this.rightPosition = arrowRightPosition;
break;
}
}
}
}
}

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