New base code for mempool blockchain explorerer

This commit is contained in:
Simon Lindh 2020-02-16 22:15:07 +07:00 committed by wiz
parent ca40fc7045
commit ac95c09ea6
No known key found for this signature in database
GPG Key ID: A394E332255A6173
204 changed files with 6959 additions and 14341 deletions

View File

@ -1,57 +0,0 @@
FROM alpine:latest
RUN mkdir /mempool.space/
COPY ./backend /mempool.space/backend/
COPY ./frontend /mempool.space/frontend/
COPY ./mariadb-structure.sql /mempool.space/mariadb-structure.sql
RUN apk add mariadb mariadb-client git nginx npm rsync bash
RUN mysql_install_db --user=mysql --datadir=/var/lib/mysql/
RUN /usr/bin/mysqld_safe --datadir='/var/lib/mysql/'& \
sleep 60 && \
mysql -e "create database mempool" && \
mysql -e "grant all privileges on mempool.* to 'mempool'@'localhost' identified by 'mempool'" && \
mysql mempool < /mempool.space/mariadb-structure.sql
RUN sed -i "/^skip-networking/ c#skip-networking" /etc/my.cnf.d/mariadb-server.cnf
RUN export NG_CLI_ANALYTICS=ci && \
npm install -g typescript && \
cd /mempool.space/frontend && \
npm install && \
cd /mempool.space/backend && \
npm install && \
tsc
COPY ./nginx-nossl-docker.conf /etc/nginx/nginx.conf
ENV ENV dev
ENV DB_HOST localhost
ENV DB_PORT 3306
ENV DB_USER mempool
ENV DB_PASSWORD mempool
ENV DB_DATABASE mempool
ENV API_ENDPOINT /api/v1/
ENV CHAT_SSL_ENABLED false
ENV MEMPOOL_REFRESH_RATE_MS 500
ENV INITIAL_BLOCK_AMOUNT 8
ENV DEFAULT_PROJECTED_BLOCKS_AMOUNT 3
ENV KEEP_BLOCK_AMOUNT 24
ENV BITCOIN_NODE_HOST bitcoinhost
ENV BITCOIN_NODE_PORT 8332
ENV BITCOIN_NODE_USER bitcoinuser
ENV BITCOIN_NODE_PASS bitcoinpass
ENV TX_PER_SECOND_SPAN_SECONDS 150
ENV BACKEND_API bitcoind
ENV ELECTRS_API_URL https://www.blockstream.info/api
RUN cd /mempool.space/frontend/ && \
npm run build && \
rsync -av --delete dist/mempool/ /var/www/html/
EXPOSE 80
COPY ./entrypoint.sh /mempool.space/entrypoint.sh
RUN chmod +x /mempool.space/entrypoint.sh
WORKDIR /mempool.space
CMD ["/mempool.space/entrypoint.sh"]

View File

@ -1,4 +1,5 @@
# mempool.space # mempool.space
🚨This is beta software, and may have issues!🚨
Please help us test and report bugs to our GitHub issue tracker. Please help us test and report bugs to our GitHub issue tracker.
Mempool visualizer for the Bitcoin blockchain. Live demo: https://mempool.space/ Mempool visualizer for the Bitcoin blockchain. Live demo: https://mempool.space/
@ -12,13 +13,6 @@ Mempool visualizer for the Bitcoin blockchain. Live demo: https://mempool.space/
* MySQL or MariaDB (default config) * MySQL or MariaDB (default config)
* Nginx (use supplied nginx.conf) * Nginx (use supplied nginx.conf)
## Checking out release tag
```bash
git clone https://github.com/mempool-space/mempool.space
cd mempool.space
git checkout v1.0.0 # put latest release tag here
```
## Bitcoin Core (bitcoind) ## Bitcoin Core (bitcoind)
Enable RPC and txindex in bitcoin.conf Enable RPC and txindex in bitcoin.conf
@ -34,6 +28,8 @@ Enable RPC and txindex in bitcoin.conf
Install dependencies and build code: Install dependencies and build code:
```bash ```bash
cd mempool.space
# Install TypeScript Globally # Install TypeScript Globally
npm install -g typescript npm install -g typescript

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": 10000,
"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,33 +1,15 @@
const config = require('../../../mempool-config.json'); const config = require('../../../mempool-config.json');
import { ITransaction, IMempoolInfo, IBlock } from '../../interfaces'; import { Transaction, Block } 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> { getRawMempool(): Promise<Transaction['txid'][]> {
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/txids', { json: true, timeout: 10000, forever: true }, (err, res, response) => {
if (err) {
reject(err);
} else if (res.statusCode !== 200) {
reject(response);
} else {
resolve({
size: response.count,
bytes: response.vsize,
});
}
});
});
}
getRawMempool(): Promise<ITransaction['txid'][]> {
return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/mempool/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) {
@ -39,24 +21,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 +49,15 @@ class ElectrsApi implements AbstractBitcoinApi {
}); });
} }
getBlockAndTransactions(hash: string): Promise<IBlock> { getTxIdsForBlock(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 + '/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 +77,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 +91,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 +104,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,61 @@
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 { Block } from '../interfaces';
import { IBlock, ITransaction } from '../interfaces';
import memPool from './mempool';
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);
block.medianFee = 2;
block.feeRange = [1, 3];
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);
} }
} 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 +66,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, SimpleTransaction } from '../interfaces';
class MempoolBlocks {
private mempoolBlocks: MempoolBlock[] = [];
constructor() {}
public getMempoolBlocks(): MempoolBlock[] {
return this.mempoolBlocks;
}
public updateMempoolBlocks(memPool: { [txid: string]: SimpleTransaction }): void {
const latestMempool = memPool;
const memPoolArray: SimpleTransaction[] = [];
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: SimpleTransaction[]): MempoolBlock[] {
const mempoolBlocks: MempoolBlock[] = [];
let blockWeight = 0;
let blockSize = 0;
let transactions: SimpleTransaction[] = [];
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: SimpleTransaction[], 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: SimpleTransaction[], 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, SimpleTransaction, 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,18 @@ class Mempool {
this.mempoolChangedCallback = fn; this.mempoolChangedCallback = fn;
} }
public getMempool(): { [txid: string]: ITransaction } { public getMempool(): { [txid: string]: SimpleTransaction } {
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 getMempoolInfo(): MempoolInfo | undefined {
return this.mempoolInfo; return this.mempoolInfo;
} }
@ -41,52 +44,16 @@ class Mempool {
return this.vBytesPerSecond; return this.vBytesPerSecond;
} }
public async updateMemPoolInfo() { public async getRawTransaction(txId: string): Promise<SimpleTransaction | false> {
try { try {
this.mempoolInfo = await bitcoinApi.getMempoolInfo(); const transaction: Transaction = await bitcoinApi.getRawTransaction(txId);
} catch (err) { return {
console.log('Error getMempoolInfo', err); txid: transaction.txid,
} fee: transaction.fee,
} size: transaction.size,
vsize: transaction.weight / 4,
public async getRawTransaction(txId: string, isCoinbase = false): Promise<ITransaction | false> { feePerVsize: transaction.fee / (transaction.weight / 4)
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 +67,13 @@ 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) {
if (!this.mempool[tx]) { for (const txid of transactions) {
const transaction = await this.getRawTransaction(tx); if (!this.mempoolCache[txid]) {
const transaction = await this.getRawTransaction(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 +82,34 @@ 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);
} }
} 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: any = {};
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; this.mempoolCache = newMempool;
if (hasChange && this.mempoolChangedCallback) { if (hasChange && this.mempoolChangedCallback) {
this.mempoolChangedCallback(this.mempool); this.mempoolChangedCallback(this.mempoolCache);
} }
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, SimpleTransaction } 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: SimpleTransaction[] = [];
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,7 +278,7 @@ 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<Statistic | 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 = ?`;
@ -307,7 +290,7 @@ class Statistics {
} }
} }
public async $list2H(): Promise<IMempoolStats[]> { public async $list2H(): Promise<Statistic[]> {
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`;
@ -320,7 +303,7 @@ class Statistics {
} }
} }
public async $list24H(): Promise<IMempoolStats[]> { public async $list24H(): Promise<Statistic[]> {
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);
@ -332,7 +315,7 @@ class Statistics {
} }
} }
public async $list1W(): Promise<IMempoolStats[]> { public async $list1W(): Promise<Statistic[]> {
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);
@ -345,7 +328,7 @@ class Statistics {
} }
} }
public async $list1M(): Promise<IMempoolStats[]> { public async $list1M(): Promise<Statistic[]> {
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);
@ -358,7 +341,7 @@ class Statistics {
} }
} }
public async $list3M(): Promise<IMempoolStats[]> { public async $list3M(): Promise<Statistic[]> {
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);
@ -371,7 +354,7 @@ class Statistics {
} }
} }
public async $list6M(): Promise<IMempoolStats[]> { public async $list6M(): Promise<Statistic[]> {
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);

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, SimpleTransaction, 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,15 @@ 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 blocks.updateBlocks();
await memPool.updateMemPoolInfo();
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,161 +78,36 @@ 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-stats'] = parsedMessage.data.indexOf('stats') > -1;
client['want-blocks'] = parsedMessage.data.indexOf('blocks') > -1;
client['want-projected-blocks'] = parsedMessage.data.indexOf('projected-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;
} }
if (parsedMessage.action === 'track-tx' && parsedMessage.txId && /^[a-fA-F0-9]{64}$/.test(parsedMessage.txId)) { if (parsedMessage && parsedMessage.txId && /^[a-fA-F0-9]{64}$/.test(parsedMessage.txId)) {
const tx = await memPool.getRawTransaction(parsedMessage.txId); client['txId'] = parsedMessage.txId;
if (tx) {
console.log('Now tracking: ' + parsedMessage.txId);
client['trackingTx'] = true;
client['txId'] = parsedMessage.txId;
client['tx'] = tx;
if (tx.blockhash) {
const currentBlocks = blocks.getBlocks();
const foundBlock = currentBlocks.find((block) => block.tx && block.tx.some((i: string) => i === parsedMessage.txId));
if (foundBlock) {
console.log('Found block by looking in local cache');
client['blockHeight'] = foundBlock.height;
} else {
const theBlock = await bitcoinApi.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 {
console.log('TX NOT FOUND, NOT TRACKING');
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');
client['trackingTx'] = false;
client.send(JSON.stringify({
'track-tx': {
tracking: false,
blockHeight: 0,
message: 'not-found',
}
}));
} }
} catch (e) { } catch (e) {
console.log(e); console.log(e);
} }
}); });
client.on('close', () => { const _blocks = blocks.getBlocks();
client['trackingTx'] = false; if (!_blocks) {
}); return;
}
client.send(JSON.stringify({
'blocks': _blocks,
'conversions': fiatConversion.getTickers()['BTCUSD'],
'mempool-blocks': mempoolBlocks.getMempoolBlocks(),
}));
}); });
blocks.setNewBlockCallback((block: IBlock) => { statistics.setNewStatisticsEntryCallback((stats: Statistic) => {
const formattedBlocks = blocks.formatBlock(block);
this.wss.clients.forEach((client) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
const response = {};
if (client['trackingTx'] === true && client['blockHeight'] === 0) {
if (block.tx.some((tx: ITransaction) => tx === client['txId'])) {
client['blockHeight'] = block.height;
}
}
response['track-tx'] = {
tracking: client['trackingTx'] || false,
blockHeight: client['blockHeight'],
};
response['block'] = formattedBlocks;
client.send(JSON.stringify(response));
});
});
memPool.setMempoolChangedCallback((newMempool: IMempool) => {
projectedBlocks.updateProjectedBlocks(newMempool);
const pBlocks = projectedBlocks.getProjectedBlocks();
const mempoolInfo = memPool.getMempoolInfo();
const txPerSecond = memPool.getTxPerSecond();
const vBytesPerSecond = memPool.getVBytesPerSecond();
this.wss.clients.forEach((client: WebSocket) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
const response = {};
if (client['want-stats']) {
response['mempoolInfo'] = mempoolInfo;
response['txPerSecond'] = txPerSecond;
response['vBytesPerSecond'] = vBytesPerSecond;
response['track-tx'] = {
tracking: client['trackingTx'] || false,
blockHeight: client['blockHeight'],
};
}
if (client['want-projected-blocks'] && client['trackingTx'] && client['blockHeight'] === 0) {
response['projectedBlocks'] = projectedBlocks.getProjectedBlocks(client['txId']);
} else if (client['want-projected-blocks']) {
response['projectedBlocks'] = pBlocks;
}
if (Object.keys(response).length) {
client.send(JSON.stringify(response));
}
});
});
statistics.setNewStatisticsEntryCallback((stats: IMempoolStats) => {
this.wss.clients.forEach((client: WebSocket) => { this.wss.clients.forEach((client: WebSocket) => {
if (client.readyState !== WebSocket.OPEN) { if (client.readyState !== WebSocket.OPEN) {
return; return;
@ -248,14 +120,47 @@ class MempoolSpace {
} }
}); });
}); });
blocks.setNewBlockCallback((block: Block, txIds: string[]) => {
this.wss.clients.forEach((client) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
if (client['txId'] && txIds.indexOf(client['txId']) > -1) {
client['txId'] = null;
client.send(JSON.stringify({
'block': block,
'txConfirmed': true,
}));
} else {
client.send(JSON.stringify({
'block': block,
}));
}
});
});
memPool.setMempoolChangedCallback((newMempool: { [txid: string]: SimpleTransaction }) => {
mempoolBlocks.updateMempoolBlocks(newMempool);
const pBlocks = mempoolBlocks.getMempoolBlocks();
this.wss.clients.forEach((client: WebSocket) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
client.send(JSON.stringify({
'mempool-blocks': pBlocks
}));
});
});
} }
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))
@ -263,22 +168,7 @@ class MempoolSpace {
.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))
; ;
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 SimpleTransaction {
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,10 @@ export interface IMempoolStats {
vsize_2000: number; vsize_2000: number;
} }
export interface IProjectedBlockInternal extends IProjectedBlock { export interface Outspend {
txIds: string[]; spent: boolean;
txFeePerVsizes: number[]; txid: string;
vin: number;
status: Status;
} }
export interface IProjectedBlock {
blockSize: number;
blockWeight: number;
maxFee: number;
maxWeightFee: number;
medianFee: number;
minFee: number;
minWeightFee: number;
nTx: number;
fees: number;
hasMyTxId?: boolean;
}
export interface IMempool { [txid: string]: ITransaction; }

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 = {};
@ -50,149 +49,14 @@ class Routes {
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 +0,0 @@
#!/bin/bash
## Start SQL
mysqld_safe&
sleep 5
## http server:
nginx
## Set up some files:
cd /mempool.space/backend
rm -f cache.json
touch cache.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

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

@ -2,10 +2,5 @@
"/api": { "/api": {
"target": "http://localhost:8999/", "target": "http://localhost:8999/",
"secure": false "secure": false
},
"/ws": {
"target": "http://localhost:8999/",
"secure": false,
"ws": true
} }
} }

View File

@ -1,10 +1,13 @@
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';
const routes: Routes = [ const routes: Routes = [
{ {
@ -13,30 +16,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: 'about',
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 +52,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,88 @@
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';
@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,
ChartistComponent,
], ],
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,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,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

@ -4,10 +4,10 @@
<h2>About</h2> <h2>About</h2>
<p>Mempool.Space is a realtime Bitcoin blockchain visualizer and statistics website focused on SegWit.</p> <p>Mempool.Space is a realtime Bitcoin blockchain explorer and mempool visualizer.</p>
<p>Created by <a href="http://t.me/softcrypto">@softcrypto</a> (Telegram). <a href="https://twitter.com/softcrypt0">@softcrypt0</a> (Twitter). <p>Created by <a href="https://twitter.com/softbtc">@softbtc</a>
<br />Designed by <a href="https://emeraldo.io">emeraldo.io</a>. <br />Hosted by <a href="https://twitter.com/wiz">@wiz</a>
<br />Hosted by <a href="https://twitter.com/wiz">@wiz</a></p> <br />Designed by <a href="https://instagram.com/markjborg">@markjborg</a>
<h2>Fee API</h2> <h2>Fee API</h2>
@ -19,18 +19,10 @@
<br /> <br />
<h1>Donate</h1> <h1>Donate</h1>
<h3>Segwit native</h3>
<img src="./assets/btc-qr-code-segwit.png" width="200" height="200" /> <img src="./assets/btc-qr-code-segwit.png" width="200" height="200" />
<br /> <br />
bc1qqrmgr60uetlmrpylhtllawyha9z5gw6hwdmk2t bc1qqrmgr60uetlmrpylhtllawyha9z5gw6hwdmk2t
<br /><br />
<h3>Segwit compatibility</h3>
<img src="./assets/btc-qr-code.png" width="200" height="200" />
<br />
3Ccig4G4u8hbExnxBJHeE5ZmxxWxvEQ65f
<br /><br /> <br /><br />
<h3>PayNym</h3> <h3>PayNym</h3>

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([]);
} }
} }

View File

@ -0,0 +1 @@
<span *ngIf="multisig" class="badge badge-pill badge-warning">multisig {{ multisigM }} of {{ multisigN }}</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,60 @@
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;
constructor() { }
ngOnInit() {
if (this.vin) {
this.handleVin();
} else if (this.vout) {
this.handleVout();
}
}
handleVin() {
if (this.vin.inner_witnessscript_asm && 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 (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: 55px; 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 }} 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,11 +1,6 @@
.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;

View File

@ -1,7 +1,8 @@
import { Component, OnInit, ChangeDetectorRef } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router'; import { ActivatedRoute, ParamMap } from '@angular/router';
import { ApiService } from 'src/app/services/api.service'; import { ElectrsApiService } from '../../services/electrs-api.service';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { Address, Transaction } from '../../interfaces/electrs.interface';
@Component({ @Component({
selector: 'app-address', selector: 'app-address',
@ -9,17 +10,16 @@ import { switchMap } from 'rxjs/operators';
styleUrls: ['./address.component.scss'] styleUrls: ['./address.component.scss']
}) })
export class AddressComponent implements OnInit { export class AddressComponent implements OnInit {
address: any; address: Address;
addressString: string;
isLoadingAddress = true; isLoadingAddress = true;
latestBlockHeight: number; transactions: Transaction[];
transactions: any[];
isLoadingTransactions = true; isLoadingTransactions = true;
error: any; error: any;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private apiService: ApiService, private electrsApiService: ElectrsApiService,
private ref: ChangeDetectorRef,
) { } ) { }
ngOnInit() { ngOnInit() {
@ -27,15 +27,17 @@ export class AddressComponent implements OnInit {
switchMap((params: ParamMap) => { switchMap((params: ParamMap) => {
this.error = undefined; this.error = undefined;
this.isLoadingAddress = true; this.isLoadingAddress = true;
const address: string = params.get('id') || ''; this.isLoadingTransactions = true;
return this.apiService.getAddress$(address); this.transactions = null;
this.addressString = params.get('id') || '';
return this.electrsApiService.getAddress$(this.addressString);
}) })
) )
.subscribe((address) => { .subscribe((address) => {
this.address = address; this.address = address;
this.isLoadingAddress = false; this.isLoadingAddress = false;
window.scrollTo(0, 0);
this.getAddressTransactions(address.address); this.getAddressTransactions(address.address);
this.ref.markForCheck();
}, },
(error) => { (error) => {
console.log(error); console.log(error);
@ -45,7 +47,7 @@ export class AddressComponent implements OnInit {
} }
getAddressTransactions(address: string) { getAddressTransactions(address: string) {
this.apiService.getAddressTransactions$(address) this.electrsApiService.getAddressTransactions$(address)
.subscribe((transactions: any) => { .subscribe((transactions: any) => {
this.transactions = transactions; this.transactions = transactions;
this.isLoadingTransactions = false; this.isLoadingTransactions = false;
@ -54,7 +56,7 @@ export class AddressComponent implements OnInit {
loadMore() { loadMore() {
this.isLoadingTransactions = true; this.isLoadingTransactions = true;
this.apiService.getAddressTransactionsFromHash$(this.address.id, this.transactions[this.transactions.length - 1].txid) this.electrsApiService.getAddressTransactionsFromHash$(this.address.address, this.transactions[this.transactions.length - 1].txid)
.subscribe((transactions) => { .subscribe((transactions) => {
this.transactions = this.transactions.concat(transactions); this.transactions = this.transactions.concat(transactions);
this.isLoadingTransactions = false; this.isLoadingTransactions = false;

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,134 @@
<div class="container">
<app-blockchain></app-blockchain>
<h1>Block <ng-template [ngIf]="blockHeight"><a [routerLink]="['/block/', blockHash]">#{{ blockHeight }}</a></ng-template></h1>
<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>
<tr>
<td>Status</td>
<td><button *ngIf="latestBlock" class="btn btn-sm btn-success">{{ (latestBlock.height - block.height + 1) }} confirmation{{ (latestBlock.height - block.height + 1) === 1 ? '' : 's' }}</button></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></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>
</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

@ -1,9 +1,10 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router'; import { ActivatedRoute, ParamMap } from '@angular/router';
import { ApiService } from 'src/app/services/api.service'; import { ElectrsApiService } from '../../services/electrs-api.service';
import { MemPoolService } from 'src/app/services/mem-pool.service';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { ChangeDetectorRef } from '@angular/core'; import { Block, Transaction } from '../../interfaces/electrs.interface';
import { of } from 'rxjs';
import { StateService } from '../../services/state.service';
@Component({ @Component({
selector: 'app-block', selector: 'app-block',
@ -11,48 +12,59 @@ import { ChangeDetectorRef } from '@angular/core';
styleUrls: ['./block.component.scss'] styleUrls: ['./block.component.scss']
}) })
export class BlockComponent implements OnInit { export class BlockComponent implements OnInit {
block: any; block: Block;
blockHeight: number;
blockHash: string;
isLoadingBlock = true; isLoadingBlock = true;
latestBlockHeight: number; latestBlock: Block;
transactions: any[]; transactions: Transaction[];
isLoadingTransactions = true; isLoadingTransactions = true;
error: any; error: any;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private apiService: ApiService, private electrsApiService: ElectrsApiService,
private memPoolService: MemPoolService, private stateService: StateService,
private ref: ChangeDetectorRef,
) { } ) { }
ngOnInit() { ngOnInit() {
this.route.paramMap.pipe( this.route.paramMap.pipe(
switchMap((params: ParamMap) => { switchMap((params: ParamMap) => {
this.error = undefined;
this.isLoadingBlock = true;
const blockHash: string = params.get('id') || ''; const blockHash: string = params.get('id') || '';
return this.apiService.getBlock$(blockHash); this.error = undefined;
if (history.state.data && history.state.data.blockHeight) {
this.blockHeight = history.state.data.blockHeight;
}
this.blockHash = blockHash;
if (history.state.data && history.state.data.block) {
return of(history.state.data.block);
} else {
this.isLoadingBlock = true;
return this.electrsApiService.getBlock$(blockHash);
}
}) })
) )
.subscribe((block) => { .subscribe((block: Block) => {
this.block = block; this.block = block;
this.blockHeight = block.height;
this.isLoadingBlock = false; this.isLoadingBlock = false;
this.getBlockTransactions(block.id); this.getBlockTransactions(block.id);
this.ref.markForCheck(); window.scrollTo(0, 0);
}, },
(error) => { (error) => {
this.error = error; this.error = error;
this.isLoadingBlock = false; this.isLoadingBlock = false;
}); });
this.memPoolService.blocks$ this.stateService.blocks$
.subscribe((block) => { .subscribe((block) => this.latestBlock = block);
this.latestBlockHeight = block.height;
});
} }
getBlockTransactions(hash: string) { getBlockTransactions(hash: string) {
this.apiService.getBlockTransactions$(hash) this.electrsApiService.getBlockTransactions$(hash)
.subscribe((transactions: any) => { .subscribe((transactions: any) => {
this.transactions = transactions; this.transactions = transactions;
this.isLoadingTransactions = false; this.isLoadingTransactions = false;
@ -61,7 +73,7 @@ export class BlockComponent implements OnInit {
loadMore() { loadMore() {
this.isLoadingTransactions = true; this.isLoadingTransactions = true;
this.apiService.getBlockTransactions$(this.block.id, this.transactions.length) this.electrsApiService.getBlockTransactions$(this.block.id, this.transactions.length)
.subscribe((transactions) => { .subscribe((transactions) => {
this.transactions = this.transactions.concat(transactions); this.transactions = this.transactions.concat(transactions);
this.isLoadingTransactions = false; this.isLoadingTransactions = false;

View File

@ -0,0 +1,20 @@
<div class="blocks-container" *ngIf="blocks.length">
<div *ngFor="let block of blocks; let i = index; trackBy: trackByBlocksFn" >
<div [routerLink]="['/block/', block.id]" [state]="{ data: { block: block } }" class="text-center bitcoin-block mined-block" id="bitcoin-block-{{ block.height }}" [ngStyle]="getStyleForBlock(block)">
<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>

View File

@ -1,10 +1,7 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; 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 { Subscription } from 'rxjs';
import { environment } from '../../environments/environment'; import { Block } from 'src/app/interfaces/electrs.interface';
import { StateService } from 'src/app/services/state.service';
@Component({ @Component({
selector: 'app-blockchain-blocks', selector: 'app-blockchain-blocks',
@ -12,19 +9,17 @@ import { environment } from '../../environments/environment';
styleUrls: ['./blockchain-blocks.component.scss'] styleUrls: ['./blockchain-blocks.component.scss']
}) })
export class BlockchainBlocksComponent implements OnInit, OnDestroy { export class BlockchainBlocksComponent implements OnInit, OnDestroy {
blocks: IBlock[] = []; blocks: Block[] = [];
blocksSubscription: Subscription; blocksSubscription: Subscription;
interval: any; interval: any;
trigger = 0; trigger = 0;
isElectrsEnabled = !!environment.electrs;
constructor( constructor(
private modalService: NgbModal, private stateService: StateService,
private memPoolService: MemPoolService,
) { } ) { }
ngOnInit() { ngOnInit() {
this.blocksSubscription = this.memPoolService.blocks$ this.blocksSubscription = this.stateService.blocks$
.subscribe((block) => { .subscribe((block) => {
if (this.blocks.some((b) => b.height === block.height)) { if (this.blocks.some((b) => b.height === block.height)) {
return; return;
@ -41,27 +36,22 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy {
clearInterval(this.interval); clearInterval(this.interval);
} }
trackByBlocksFn(index: number, item: IBlock) { trackByBlocksFn(index: number, item: Block) {
return item.height; return item.height;
} }
openBlockModal(block: IBlock) { getStyleForBlock(block: Block) {
const modalRef = this.modalService.open(BlockModalComponent, { size: 'lg' });
modalRef.componentInstance.block = block;
}
getStyleForBlock(block: IBlock) {
const greenBackgroundHeight = 100 - (block.weight / 4000000) * 100; const greenBackgroundHeight = 100 - (block.weight / 4000000) * 100;
if (window.innerWidth <= 768) { if (window.innerWidth <= 768) {
return { return {
'top': 155 * this.blocks.indexOf(block) + 'px', top: 155 * this.blocks.indexOf(block) + 'px',
'background': `repeating-linear-gradient(#2d3348, #2d3348 ${greenBackgroundHeight}%, background: `repeating-linear-gradient(#2d3348, #2d3348 ${greenBackgroundHeight}%,
#9339f4 ${Math.max(greenBackgroundHeight, 0)}%, #105fb0 100%)`, #9339f4 ${Math.max(greenBackgroundHeight, 0)}%, #105fb0 100%)`,
}; };
} else { } else {
return { return {
'left': 155 * this.blocks.indexOf(block) + 'px', left: 155 * this.blocks.indexOf(block) + 'px',
'background': `repeating-linear-gradient(#2d3348, #2d3348 ${greenBackgroundHeight}%, background: `repeating-linear-gradient(#2d3348, #2d3348 ${greenBackgroundHeight}%,
#9339f4 ${Math.max(greenBackgroundHeight, 0)}%, #105fb0 100%)`, #9339f4 ${Math.max(greenBackgroundHeight, 0)}%, #105fb0 100%)`,
}; };
} }

View File

@ -11,14 +11,10 @@
</div> </div>
<div class="text-center" class="blockchain-wrapper"> <div class="text-center" class="blockchain-wrapper">
<div class="position-container"> <div class="position-container">
<app-blockchain-projected-blocks></app-blockchain-projected-blocks> <app-mempool-blocks></app-mempool-blocks>
<app-blockchain-blocks></app-blockchain-blocks> <app-blockchain-blocks></app-blockchain-blocks>
<div id="divider" *ngIf="!isLoading"></div> <div id="divider" *ngIf="!isLoading"></div>
</div> </div>
</div> </div>
<app-tx-bubble></app-tx-bubble>
<app-footer></app-footer>

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,14 @@
.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: calc(50% - 60px); */
top: 180px;
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {

View File

@ -1,9 +1,9 @@
import { Component, OnInit, OnDestroy, Renderer2 } from '@angular/core'; 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 { ActivatedRoute, ParamMap } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { WebsocketService } from 'src/app/services/websocket.service';
import { StateService } from 'src/app/services/state.service';
@Component({ @Component({
selector: 'app-blockchain', selector: 'app-blockchain',
@ -19,14 +19,14 @@ export class BlockchainComponent implements OnInit, OnDestroy {
isLoading = true; isLoading = true;
constructor( constructor(
private memPoolService: MemPoolService,
private apiService: ApiService,
private renderer: Renderer2,
private route: ActivatedRoute, private route: ActivatedRoute,
private websocketService: WebsocketService,
private stateService: StateService,
) {} ) {}
ngOnInit() { ngOnInit() {
this.apiService.webSocketWant(['stats', 'blocks', 'projected-blocks']); /*
this.apiService.webSocketWant(['stats', 'blocks', 'mempool-blocks']);
this.txTrackingSubscription = this.memPoolService.txTracking$ this.txTrackingSubscription = this.memPoolService.txTracking$
.subscribe((response: ITxTracking) => { .subscribe((response: ITxTracking) => {
@ -36,9 +36,9 @@ export class BlockchainComponent implements OnInit, OnDestroy {
setTimeout(() => { this.txShowTxNotFound = false; }, 2000); setTimeout(() => { this.txShowTxNotFound = false; }, 2000);
} }
}); });
*/
this.renderer.addClass(document.body, 'disable-scroll'); /*
this.route.paramMap this.route.paramMap
.subscribe((params: ParamMap) => { .subscribe((params: ParamMap) => {
if (this.memPoolService.txTracking$.value.enabled) { if (this.memPoolService.txTracking$.value.enabled) {
@ -53,6 +53,9 @@ export class BlockchainComponent implements OnInit, OnDestroy {
this.apiService.webSocketStartTrackTx(txId); this.apiService.webSocketStartTrackTx(txId);
}); });
*/
/*
this.memPoolService.txIdSearch$ this.memPoolService.txIdSearch$
.subscribe((txId) => { .subscribe((txId) => {
if (txId) { if (txId) {
@ -64,11 +67,12 @@ export class BlockchainComponent implements OnInit, OnDestroy {
} }
console.log('enabling tracking loading from idSearch!'); console.log('enabling tracking loading from idSearch!');
this.txTrackingLoading = true; this.txTrackingLoading = true;
this.apiService.webSocketStartTrackTx(txId); this.websocketService.startTrackTx(txId);
} }
}); });
*/
this.blocksSubscription = this.memPoolService.blocks$ this.blocksSubscription = this.stateService.blocks$
.pipe( .pipe(
take(1) take(1)
) )
@ -77,7 +81,6 @@ export class BlockchainComponent implements OnInit, OnDestroy {
ngOnDestroy() { ngOnDestroy() {
this.blocksSubscription.unsubscribe(); this.blocksSubscription.unsubscribe();
this.txTrackingSubscription.unsubscribe(); // this.txTrackingSubscription.unsubscribe();
this.renderer.removeClass(document.body, 'disable-scroll');
} }
} }

View File

@ -0,0 +1,5 @@
<span #buttonWrapper [attr.data-tlite]="'Copied!'">
<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,43 @@
<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">
<ng-template [ngIf]="isLoading">
<div class="spinner-border"></div>
<br><br>
</ng-template>
<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,31 @@
<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()">Explorer</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="/about" (click)="collapse()">About</a>
</li>
</ul>
<app-search-form location="top"></app-search-form>
</div>
</nav>
</header>
<br />
<router-outlet></router-outlet>

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,20 @@
<div class="mempool-blocks-container">
<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>

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,7 +8,7 @@
font-weight: bold; font-weight: bold;
} }
.projected-blocks-container { .mempool-blocks-container {
position: absolute; position: absolute;
top: 0px; top: 0px;
right: 0px; right: 0px;
@ -20,7 +19,7 @@
opacity: 1; opacity: 1;
} }
.projected-block { .mempool-block {
position: absolute; position: absolute;
top: 0; top: 0;
} }
@ -54,7 +53,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 +86,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;
} }
} }

View File

@ -0,0 +1,83 @@
import { Component, OnInit, OnDestroy, Input, EventEmitter, Output } 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, OnDestroy {
mempoolBlocks: MempoolBlock[];
mempoolBlocksSubscription: Subscription;
blockWidth = 125;
blockMarginLeft = 20;
@Input() txFeePerVSize: number;
@Output() rightPosition: EventEmitter<number> = new EventEmitter<number>(true);
@Output() blockDepth: EventEmitter<number> = new EventEmitter<number>(true);
constructor(
private stateService: StateService,
) { }
ngOnInit() {
this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$
.subscribe((blocks) => {
this.mempoolBlocks = blocks;
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) {
return;
}
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.blockMarginLeft + this.blockWidth)
+ ((1 - feePosition) * blockedFilledPercentage * this.blockWidth);
this.rightPosition.next(arrowRightPosition);
this.blockDepth.next(txInBlockIndex);
break;
}
}
}
}
}

View File

@ -0,0 +1 @@
<canvas #canvas></canvas>

View File

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

View File

@ -0,0 +1,43 @@
import { Component, Input, AfterViewInit, OnDestroy, ViewChild, ElementRef } from '@angular/core';
import * as QRCode from 'qrcode/build/qrcode.js';
@Component({
selector: 'app-qrcode',
templateUrl: './qrcode.component.html',
styleUrls: ['./qrcode.component.scss']
})
export class QrcodeComponent implements AfterViewInit, OnDestroy {
@Input() data: string;
@ViewChild('canvas') canvas: ElementRef;
qrcodeObject: any;
constructor() { }
ngAfterViewInit() {
const opts = {
errorCorrectionLevel: 'H',
margin: 0,
color: {
dark: '#000',
light: '#fff'
},
width: 125,
height: 125,
};
if (!this.data) {
return;
}
QRCode.toCanvas(this.canvas.nativeElement, this.data.toUpperCase(), opts, (error: any) => {
if (error) {
console.error(error);
}
});
}
ngOnDestroy() {
}
}

View File

@ -0,0 +1,29 @@
<ng-template [ngIf]="location === 'start'" [ngIfElse]="top">
<form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate>
<div class="form-row">
<div class="col-12 col-md-10 mb-2 mb-md-0">
<input formControlName="searchText" type="text" class="form-control form-control-lg" [placeholder]="searchBoxPlaceholderText">
</div>
<div class="col-12 col-md-2">
<button type="submit" class="btn btn-block btn-lg btn-primary">{{ searchButtonText }}</button>
</div>
</div>
</form>
</ng-template>
<ng-template #top>
<form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate>
<div class="form-row">
<div style="width: 350px;" class="mr-2">
<input formControlName="searchText" type="text" class="form-control" [placeholder]="searchBoxPlaceholderText">
</div>
<div>
<button type="submit" class="btn btn-block btn-primary">{{ searchButtonText }}</button>
</div>
</div>
</form>
</ng-template>

View File

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

View File

@ -0,0 +1,44 @@
import { Component, OnInit, Input, ChangeDetectionStrategy } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
@Component({
selector: 'app-search-form',
templateUrl: './search-form.component.html',
styleUrls: ['./search-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SearchFormComponent implements OnInit {
@Input() location: string;
searchForm: FormGroup;
searchButtonText = 'Search';
searchBoxPlaceholderText = 'Transaction, address, block hash...';
regexAddress = /^([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})$/;
constructor(
private formBuilder: FormBuilder,
private router: Router,
) { }
ngOnInit() {
this.searchForm = this.formBuilder.group({
searchText: ['', Validators.required],
});
}
search() {
const searchText = this.searchForm.value.searchText.trim();
if (searchText) {
if (this.regexAddress.test(searchText)) {
this.router.navigate(['/address/', searchText]);
} else {
this.router.navigate(['/tx/', searchText]);
}
this.searchForm.setValue({
searchText: '',
});
}
}
}

View File

@ -0,0 +1,20 @@
<app-blockchain></app-blockchain>
<div class="box">
<div class="container">
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<a class="nav-link" [class.active]="view === 'blocks'" href="#" (click)="view = 'blocks'">Blocks</a>
</li>
<li class="nav-item">
<a class="nav-link" [class.active]="view === 'transactions'" href="#" (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>
</div>
</div>

View File

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

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