From fd2209e75a6a95c87a5a6c168ba6025e362a5644 Mon Sep 17 00:00:00 2001 From: Simon Lindh Date: Sun, 21 Jul 2019 17:59:47 +0300 Subject: [PATCH] Initial code commit. --- backend/.gitignore | 42 ++ backend/mempool-config.json | 22 + backend/package.json | 27 + backend/src/api/bitcoin-api-wrapper.ts | 84 +++ backend/src/api/blocks.ts | 197 ++++++ backend/src/api/disk-cache.ts | 16 + backend/src/api/fee-api.ts | 47 ++ backend/src/api/fiat-conversion.ts | 31 + backend/src/api/mempool.ts | 156 +++++ backend/src/api/projected-blocks.ts | 104 +++ backend/src/api/statistics.ts | 379 ++++++++++ backend/src/database.ts | 26 + backend/src/index.ts | 231 ++++++ backend/src/interfaces.ts | 151 ++++ backend/src/routes.ts | 63 ++ backend/tsconfig.json | 20 + backend/tslint.json | 137 ++++ frontend/.editorconfig | 13 + frontend/.gitignore | 39 ++ frontend/angular.json | 127 ++++ frontend/e2e/protractor.conf.js | 28 + frontend/e2e/src/app.e2e-spec.ts | 14 + frontend/e2e/src/app.po.ts | 11 + frontend/e2e/tsconfig.e2e.json | 13 + frontend/package.json | 53 ++ frontend/proxy.conf.json | 6 + frontend/src/.htaccess | 7 + frontend/src/app/about/about.component.html | 41 ++ frontend/src/app/about/about.component.scss | 0 frontend/src/app/about/about.component.ts | 15 + frontend/src/app/app-routing.module.ts | 40 ++ frontend/src/app/app.component.html | 32 + frontend/src/app/app.component.scss | 28 + frontend/src/app/app.component.ts | 52 ++ frontend/src/app/app.module.ts | 45 ++ .../block-modal/block-modal.component.html | 45 ++ .../block-modal/block-modal.component.scss | 7 + .../app/block-modal/block-modal.component.ts | 73 ++ .../app/blockchain/blockchain.component.html | 69 ++ .../app/blockchain/blockchain.component.scss | 195 ++++++ .../app/blockchain/blockchain.component.ts | 272 ++++++++ frontend/src/app/blockchain/interfaces.ts | 176 +++++ frontend/src/app/footer/footer.component.html | 18 + frontend/src/app/footer/footer.component.scss | 42 ++ frontend/src/app/footer/footer.component.ts | 54 ++ .../projected-block-modal.component.html | 41 ++ .../projected-block-modal.component.scss | 7 + .../projected-block-modal.component.ts | 74 ++ frontend/src/app/services/api.service.ts | 68 ++ frontend/src/app/services/mem-pool.service.ts | 20 + .../app/shared/pipes/bytes-pipe/bytes.pipe.ts | 63 ++ .../src/app/shared/pipes/bytes-pipe/utils.ts | 311 +++++++++ .../shared/pipes/math-ceil/math-ceil.pipe.ts | 8 + .../pipes/math-round-pipe/math-round.pipe.ts | 8 + frontend/src/app/shared/shared.module.ts | 34 + .../app/statistics/chartist.component.scss | 72 ++ .../src/app/statistics/chartist.component.ts | 657 ++++++++++++++++++ .../app/statistics/statistics.component.html | 108 +++ .../app/statistics/statistics.component.scss | 16 + .../app/statistics/statistics.component.ts | 274 ++++++++ .../app/tx-bubble/tx-bubble.component.html | 26 + .../app/tx-bubble/tx-bubble.component.scss | 65 ++ .../src/app/tx-bubble/tx-bubble.component.ts | 26 + frontend/src/assets/.gitkeep | 0 frontend/src/assets/btc-qr-code-segwit.png | Bin 0 -> 25307 bytes frontend/src/assets/btc-qr-code.png | Bin 0 -> 26617 bytes frontend/src/assets/divider-new.png | Bin 0 -> 81 bytes .../assets/favicon/android-icon-144x144.png | Bin 0 -> 4053 bytes .../assets/favicon/android-icon-192x192.png | Bin 0 -> 4519 bytes .../src/assets/favicon/android-icon-36x36.png | Bin 0 -> 1588 bytes .../src/assets/favicon/android-icon-48x48.png | Bin 0 -> 1614 bytes .../src/assets/favicon/android-icon-72x72.png | Bin 0 -> 2064 bytes .../src/assets/favicon/android-icon-96x96.png | Bin 0 -> 2680 bytes .../src/assets/favicon/apple-icon-114x114.png | Bin 0 -> 3009 bytes .../src/assets/favicon/apple-icon-120x120.png | Bin 0 -> 3254 bytes .../src/assets/favicon/apple-icon-144x144.png | Bin 0 -> 4053 bytes .../src/assets/favicon/apple-icon-152x152.png | Bin 0 -> 4122 bytes .../src/assets/favicon/apple-icon-180x180.png | Bin 0 -> 5009 bytes .../src/assets/favicon/apple-icon-57x57.png | Bin 0 -> 1753 bytes .../src/assets/favicon/apple-icon-60x60.png | Bin 0 -> 1807 bytes .../src/assets/favicon/apple-icon-72x72.png | Bin 0 -> 2064 bytes .../src/assets/favicon/apple-icon-76x76.png | Bin 0 -> 2199 bytes .../assets/favicon/apple-icon-precomposed.png | Bin 0 -> 5008 bytes frontend/src/assets/favicon/apple-icon.png | Bin 0 -> 5008 bytes frontend/src/assets/favicon/browserconfig.xml | 2 + frontend/src/assets/favicon/favicon-16x16.png | Bin 0 -> 1137 bytes frontend/src/assets/favicon/favicon-32x32.png | Bin 0 -> 1586 bytes frontend/src/assets/favicon/favicon-96x96.png | Bin 0 -> 2680 bytes frontend/src/assets/favicon/favicon.ico | Bin 0 -> 1150 bytes frontend/src/assets/favicon/manifest.json | 41 ++ .../src/assets/favicon/ms-icon-144x144.png | Bin 0 -> 4053 bytes .../src/assets/favicon/ms-icon-150x150.png | Bin 0 -> 4045 bytes .../src/assets/favicon/ms-icon-310x310.png | Bin 0 -> 10605 bytes frontend/src/assets/favicon/ms-icon-70x70.png | Bin 0 -> 1997 bytes frontend/src/assets/mempool-space-logo.png | Bin 0 -> 3524 bytes frontend/src/assets/mempool-tube.png | Bin 0 -> 1642 bytes frontend/src/assets/paynym-code.png | Bin 0 -> 63010 bytes frontend/src/browserslist | 9 + frontend/src/environments/environment.prod.ts | 3 + frontend/src/environments/environment.ts | 15 + frontend/src/favicon.ico | Bin 0 -> 1150 bytes frontend/src/index.html | 35 + frontend/src/karma.conf.js | 31 + frontend/src/main.ts | 12 + frontend/src/polyfills.ts | 80 +++ frontend/src/styles.scss | 131 ++++ frontend/src/test.ts | 20 + frontend/src/tsconfig.app.json | 12 + frontend/src/tsconfig.spec.json | 19 + frontend/src/tslint.json | 17 + frontend/tsconfig.json | 22 + frontend/tslint.json | 130 ++++ mariadb-structure.sql | 86 +++ 113 files changed, 5791 insertions(+) create mode 100644 backend/.gitignore create mode 100644 backend/mempool-config.json create mode 100644 backend/package.json create mode 100644 backend/src/api/bitcoin-api-wrapper.ts create mode 100644 backend/src/api/blocks.ts create mode 100644 backend/src/api/disk-cache.ts create mode 100644 backend/src/api/fee-api.ts create mode 100644 backend/src/api/fiat-conversion.ts create mode 100644 backend/src/api/mempool.ts create mode 100644 backend/src/api/projected-blocks.ts create mode 100644 backend/src/api/statistics.ts create mode 100644 backend/src/database.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/interfaces.ts create mode 100644 backend/src/routes.ts create mode 100644 backend/tsconfig.json create mode 100644 backend/tslint.json create mode 100644 frontend/.editorconfig create mode 100644 frontend/.gitignore create mode 100644 frontend/angular.json create mode 100644 frontend/e2e/protractor.conf.js create mode 100644 frontend/e2e/src/app.e2e-spec.ts create mode 100644 frontend/e2e/src/app.po.ts create mode 100644 frontend/e2e/tsconfig.e2e.json create mode 100644 frontend/package.json create mode 100644 frontend/proxy.conf.json create mode 100644 frontend/src/.htaccess create mode 100644 frontend/src/app/about/about.component.html create mode 100644 frontend/src/app/about/about.component.scss create mode 100644 frontend/src/app/about/about.component.ts create mode 100644 frontend/src/app/app-routing.module.ts create mode 100644 frontend/src/app/app.component.html create mode 100644 frontend/src/app/app.component.scss create mode 100644 frontend/src/app/app.component.ts create mode 100644 frontend/src/app/app.module.ts create mode 100644 frontend/src/app/block-modal/block-modal.component.html create mode 100644 frontend/src/app/block-modal/block-modal.component.scss create mode 100644 frontend/src/app/block-modal/block-modal.component.ts create mode 100644 frontend/src/app/blockchain/blockchain.component.html create mode 100644 frontend/src/app/blockchain/blockchain.component.scss create mode 100644 frontend/src/app/blockchain/blockchain.component.ts create mode 100644 frontend/src/app/blockchain/interfaces.ts create mode 100644 frontend/src/app/footer/footer.component.html create mode 100644 frontend/src/app/footer/footer.component.scss create mode 100644 frontend/src/app/footer/footer.component.ts create mode 100644 frontend/src/app/projected-block-modal/projected-block-modal.component.html create mode 100644 frontend/src/app/projected-block-modal/projected-block-modal.component.scss create mode 100644 frontend/src/app/projected-block-modal/projected-block-modal.component.ts create mode 100644 frontend/src/app/services/api.service.ts create mode 100644 frontend/src/app/services/mem-pool.service.ts create mode 100644 frontend/src/app/shared/pipes/bytes-pipe/bytes.pipe.ts create mode 100644 frontend/src/app/shared/pipes/bytes-pipe/utils.ts create mode 100644 frontend/src/app/shared/pipes/math-ceil/math-ceil.pipe.ts create mode 100644 frontend/src/app/shared/pipes/math-round-pipe/math-round.pipe.ts create mode 100644 frontend/src/app/shared/shared.module.ts create mode 100644 frontend/src/app/statistics/chartist.component.scss create mode 100644 frontend/src/app/statistics/chartist.component.ts create mode 100644 frontend/src/app/statistics/statistics.component.html create mode 100644 frontend/src/app/statistics/statistics.component.scss create mode 100644 frontend/src/app/statistics/statistics.component.ts create mode 100644 frontend/src/app/tx-bubble/tx-bubble.component.html create mode 100644 frontend/src/app/tx-bubble/tx-bubble.component.scss create mode 100644 frontend/src/app/tx-bubble/tx-bubble.component.ts create mode 100644 frontend/src/assets/.gitkeep create mode 100644 frontend/src/assets/btc-qr-code-segwit.png create mode 100644 frontend/src/assets/btc-qr-code.png create mode 100644 frontend/src/assets/divider-new.png create mode 100644 frontend/src/assets/favicon/android-icon-144x144.png create mode 100644 frontend/src/assets/favicon/android-icon-192x192.png create mode 100644 frontend/src/assets/favicon/android-icon-36x36.png create mode 100644 frontend/src/assets/favicon/android-icon-48x48.png create mode 100644 frontend/src/assets/favicon/android-icon-72x72.png create mode 100644 frontend/src/assets/favicon/android-icon-96x96.png create mode 100644 frontend/src/assets/favicon/apple-icon-114x114.png create mode 100644 frontend/src/assets/favicon/apple-icon-120x120.png create mode 100644 frontend/src/assets/favicon/apple-icon-144x144.png create mode 100644 frontend/src/assets/favicon/apple-icon-152x152.png create mode 100644 frontend/src/assets/favicon/apple-icon-180x180.png create mode 100644 frontend/src/assets/favicon/apple-icon-57x57.png create mode 100644 frontend/src/assets/favicon/apple-icon-60x60.png create mode 100644 frontend/src/assets/favicon/apple-icon-72x72.png create mode 100644 frontend/src/assets/favicon/apple-icon-76x76.png create mode 100644 frontend/src/assets/favicon/apple-icon-precomposed.png create mode 100644 frontend/src/assets/favicon/apple-icon.png create mode 100644 frontend/src/assets/favicon/browserconfig.xml create mode 100644 frontend/src/assets/favicon/favicon-16x16.png create mode 100644 frontend/src/assets/favicon/favicon-32x32.png create mode 100644 frontend/src/assets/favicon/favicon-96x96.png create mode 100644 frontend/src/assets/favicon/favicon.ico create mode 100644 frontend/src/assets/favicon/manifest.json create mode 100644 frontend/src/assets/favicon/ms-icon-144x144.png create mode 100644 frontend/src/assets/favicon/ms-icon-150x150.png create mode 100644 frontend/src/assets/favicon/ms-icon-310x310.png create mode 100644 frontend/src/assets/favicon/ms-icon-70x70.png create mode 100644 frontend/src/assets/mempool-space-logo.png create mode 100644 frontend/src/assets/mempool-tube.png create mode 100644 frontend/src/assets/paynym-code.png create mode 100644 frontend/src/browserslist create mode 100644 frontend/src/environments/environment.prod.ts create mode 100644 frontend/src/environments/environment.ts create mode 100644 frontend/src/favicon.ico create mode 100644 frontend/src/index.html create mode 100644 frontend/src/karma.conf.js create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/polyfills.ts create mode 100644 frontend/src/styles.scss create mode 100644 frontend/src/test.ts create mode 100644 frontend/src/tsconfig.app.json create mode 100644 frontend/src/tsconfig.spec.json create mode 100644 frontend/src/tslint.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tslint.json create mode 100644 mariadb-structure.sql diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 000000000..381e034e0 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,42 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +/dist +/tmp + +# dependencies +/node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.sass-cache +/connect.lock +/coverage/* +/libpeerconnection.log +npm-debug.log +testem.log +/typings + +# e2e +/e2e/*.js +/e2e/*.map + +#System Files +.DS_Store +Thumbs.db + +cache.json diff --git a/backend/mempool-config.json b/backend/mempool-config.json new file mode 100644 index 000000000..44f926bdf --- /dev/null +++ b/backend/mempool-config.json @@ -0,0 +1,22 @@ +{ + "ENV": "dev", + "DB_HOST": "localhost", + "DB_PORT": 8889, + "DB_USER": "", + "DB_PASSWORD": "", + "DB_DATABASE": "", + "HTTP_PORT": 3000, + "API_ENDPOINT": "/api/v1/", + "CHAT_SSL_ENABLED": false, + "CHAT_SSL_PRIVKEY": "", + "CHAT_SSL_CERT": "", + "CHAT_SSL_CHAIN": "", + "MEMPOOL_REFRESH_RATE_MS": 500, + "INITIAL_BLOCK_AMOUNT": 8, + "KEEP_BLOCK_AMOUNT": 24, + "BITCOIN_NODE_HOST": "localhost", + "BITCOIN_NODE_PORT": 18332, + "BITCOIN_NODE_USER": "", + "BITCOIN_NODE_PASS": "", + "TX_PER_SECOND_SPAN_SECONDS": 150 +} \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 000000000..2a3e39e6a --- /dev/null +++ b/backend/package.json @@ -0,0 +1,27 @@ +{ + "name": "mempool-backend", + "version": "1.0.0", + "description": "Bitcoin mempool visualizer", + "main": "index.ts", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "bitcoin": "^3.0.1", + "compression": "^1.7.3", + "express": "^4.16.3", + "mysql2": "^1.6.1", + "request": "^2.88.0", + "ws": "^6.0.0" + }, + "devDependencies": { + "@types/express": "^4.16.0", + "@types/mysql2": "github:types/mysql2", + "@types/request": "^2.48.2", + "@types/ws": "^6.0.1", + "tslint": "^5.11.0", + "typescript": "^3.1.1" + } +} diff --git a/backend/src/api/bitcoin-api-wrapper.ts b/backend/src/api/bitcoin-api-wrapper.ts new file mode 100644 index 000000000..970dd20c9 --- /dev/null +++ b/backend/src/api/bitcoin-api-wrapper.ts @@ -0,0 +1,84 @@ +const config = require('../../mempool-config.json'); +import * as bitcoin from 'bitcoin'; +import { ITransaction, IMempoolInfo, IBlock } from '../interfaces'; + +class BitcoinApi { + client: any; + + constructor() { + this.client = new bitcoin.Client({ + host: config.BITCOIN_NODE_HOST, + port: config.BITCOIN_NODE_PORT, + user: config.BITCOIN_NODE_USER, + pass: config.BITCOIN_NODE_PASS, + }); + } + + getMempoolInfo(): Promise { + return new Promise((resolve, reject) => { + this.client.getMempoolInfo((err: Error, mempoolInfo: any) => { + if (err) { + return reject(err); + } + resolve(mempoolInfo); + }); + }); + } + + getRawMempool(): Promise { + return new Promise((resolve, reject) => { + this.client.getRawMemPool((err: Error, transactions: ITransaction['txid'][]) => { + if (err) { + return reject(err); + } + resolve(transactions); + }); + }); + } + + getRawTransaction(txId: string): Promise { + return new Promise((resolve, reject) => { + this.client.getRawTransaction(txId, true, (err: Error, txData: ITransaction) => { + if (err) { + return reject(err); + } + resolve(txData); + }); + }); + } + + getBlockCount(): Promise { + return new Promise((resolve, reject) => { + this.client.getBlockCount((err: Error, response: number) => { + if (err) { + return reject(err); + } + resolve(response); + }); + }); + } + + getBlock(hash: string, verbosity: 1 | 2 = 1): Promise { + 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 { + return new Promise((resolve, reject) => { + this.client.getBlockHash(height, (err: Error, response: string) => { + if (err) { + return reject(err); + } + resolve(response); + }); + }); + } +} + +export default new BitcoinApi(); diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts new file mode 100644 index 000000000..633e37b95 --- /dev/null +++ b/backend/src/api/blocks.ts @@ -0,0 +1,197 @@ +const config = require('../../mempool-config.json'); +import bitcoinApi from './bitcoin-api-wrapper'; +import { DB } from '../database'; +import { IBlock, ITransaction } from '../interfaces'; +import memPool from './mempool'; + +class Blocks { + private blocks: IBlock[] = []; + private newBlockCallback: Function | undefined; + + public setNewBlockCallback(fn: Function) { + this.newBlockCallback = fn; + } + + public getBlocks(): IBlock[] { + return this.blocks; + } + + public formatBlock(block: IBlock) { + return { + hash: block.hash, + height: block.height, + nTx: block.nTx - 1, + size: block.size, + time: block.time, + weight: block.weight, + fees: block.fees, + minFee: block.minFee, + maxFee: block.maxFee, + medianFee: block.medianFee, + }; + } + + public async updateBlocks() { + try { + const blockCount = await bitcoinApi.getBlockCount(); + + let currentBlockHeight = 0; + if (this.blocks.length === 0) { + currentBlockHeight = blockCount - config.INITIAL_BLOCK_AMOUNT; + } else { + currentBlockHeight = this.blocks[this.blocks.length - 1].height; + } + + while (currentBlockHeight < blockCount) { + currentBlockHeight++; + + let block: IBlock | undefined; + + const storedBlock = await this.$getBlockFromDatabase(currentBlockHeight); + if (storedBlock) { + block = storedBlock; + } else { + const blockHash = await bitcoinApi.getBlockHash(currentBlockHeight); + block = await bitcoinApi.getBlock(blockHash, 1); + + const coinbase = await memPool.getRawTransaction(block.tx[0], true); + if (coinbase && coinbase.totalOut) { + block.fees = coinbase.totalOut; + } + + const mempool = memPool.getMempool(); + let found = 0; + let notFound = 0; + + let transactions: ITransaction[] = []; + + for (let i = 1; i < block.tx.length; i++) { + if (mempool[block.tx[i]]) { + transactions.push(mempool[block.tx[i]]); + found++; + } else { + const tx = await memPool.getRawTransaction(block.tx[i]); + if (tx) { + transactions.push(tx); + } + notFound++; + } + } + + transactions.sort((a, b) => b.feePerVsize - a.feePerVsize); + transactions = transactions.filter((tx: ITransaction) => tx.feePerVsize); + + block.minFee = transactions[transactions.length - 1] ? transactions[transactions.length - 1].feePerVsize : 0; + block.maxFee = transactions[0] ? transactions[0].feePerVsize : 0; + block.medianFee = this.median(transactions.map((tx) => tx.feePerVsize)); + + if (this.newBlockCallback) { + this.newBlockCallback(block); + } + + await this.$saveBlockToDatabase(block); + await this.$saveTransactionsToDatabase(block.height, transactions); + console.log(`New block found (#${currentBlockHeight})! ${found} of ${block.tx.length} found in mempool. ${notFound} not found.`); + } + + this.blocks.push(block); + if (this.blocks.length > config.KEEP_BLOCK_AMOUNT) { + this.blocks.shift(); + } + + } + } catch (err) { + console.log('Error getBlockCount', err); + } + } + + private async $getBlockFromDatabase(height: number): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = ` + SELECT * FROM blocks WHERE height = ? + `; + + const [rows] = await connection.query(query, [height]); + connection.release(); + + if (rows[0]) { + return rows[0]; + } + } catch (e) { + console.log('$get() block error', e); + } + } + + private async $saveBlockToDatabase(block: IBlock) { + try { + const connection = await DB.pool.getConnection(); + const query = ` + INSERT IGNORE INTO blocks + (height, hash, size, weight, minFee, maxFee, time, fees, nTx, medianFee) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + + const params: (any)[] = [ + block.height, + block.hash, + block.size, + block.weight, + block.minFee, + block.maxFee, + block.time, + block.fees, + block.nTx - 1, + block.medianFee, + ]; + + await connection.query(query, params); + connection.release(); + } catch (e) { + console.log('$create() block error', e); + } + } + + private async $saveTransactionsToDatabase(blockheight: number, transactions: ITransaction[]) { + try { + const connection = await DB.pool.getConnection(); + + for (let i = 0; i < transactions.length; i++) { + const query = ` + INSERT IGNORE INTO transactions + (blockheight, txid, fee, feePerVsize) + VALUES(?, ?, ?, ?) + `; + + const params: (any)[] = [ + blockheight, + transactions[i].txid, + transactions[i].fee, + transactions[i].feePerVsize, + ]; + + await connection.query(query, params); + } + + + connection.release(); + } catch (e) { + console.log('$create() transaction error', e); + } + } + + private median(numbers: number[]) { + if (!numbers.length) { return 0; } + let medianNr = 0; + const numsLen = numbers.length; + numbers.sort(); + if (numsLen % 2 === 0) { + medianNr = (numbers[numsLen / 2 - 1] + numbers[numsLen / 2]) / 2; + } else { + medianNr = numbers[(numsLen - 1) / 2]; + } + return medianNr; + } +} + +export default new Blocks(); diff --git a/backend/src/api/disk-cache.ts b/backend/src/api/disk-cache.ts new file mode 100644 index 000000000..f35b9dd6d --- /dev/null +++ b/backend/src/api/disk-cache.ts @@ -0,0 +1,16 @@ +import * as fs from 'fs'; + +class DiskCache { + static FILE_NAME = './cache.json'; + constructor() { } + + saveData(dataBlob: string) { + fs.writeFileSync(DiskCache.FILE_NAME, dataBlob, 'utf8'); + } + + loadData(): string { + return fs.readFileSync(DiskCache.FILE_NAME, 'utf8'); + } +} + +export default new DiskCache(); diff --git a/backend/src/api/fee-api.ts b/backend/src/api/fee-api.ts new file mode 100644 index 000000000..4226b9089 --- /dev/null +++ b/backend/src/api/fee-api.ts @@ -0,0 +1,47 @@ +import projectedBlocks from './projected-blocks'; +import { DB } from '../database'; + +class FeeApi { + constructor() { } + + public getRecommendedFee() { + const pBlocks = projectedBlocks.getProjectedBlocks(); + if (!pBlocks.length) { + return { + 'fastestFee': 0, + 'halfHourFee': 0, + 'hourFee': 0, + }; + } + let firstMedianFee = Math.ceil(pBlocks[0].medianFee); + + if (pBlocks.length === 1 && pBlocks[0].blockWeight <= 2000000) { + firstMedianFee = 1; + } + + const secondMedianFee = pBlocks[1] ? Math.ceil(pBlocks[1].medianFee) : firstMedianFee; + const thirdMedianFee = pBlocks[2] ? Math.ceil(pBlocks[2].medianFee) : secondMedianFee; + + return { + 'fastestFee': firstMedianFee, + 'halfHourFee': secondMedianFee, + 'hourFee': thirdMedianFee, + }; + } + + public async $getTransactionsForBlock(blockHeight: number): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = `SELECT feePerVsize AS fpv FROM transactions WHERE blockheight = ? ORDER BY feePerVsize ASC`; + const [rows] = await connection.query(query, [blockHeight]); + connection.release(); + return rows; + } catch (e) { + console.log('$getTransactionsForBlock() error', e); + return []; + } + } + +} + +export default new FeeApi(); diff --git a/backend/src/api/fiat-conversion.ts b/backend/src/api/fiat-conversion.ts new file mode 100644 index 000000000..279246a9a --- /dev/null +++ b/backend/src/api/fiat-conversion.ts @@ -0,0 +1,31 @@ +import * as request from 'request'; + +class FiatConversion { + private tickers = { + 'BTCUSD': { + 'USD': 4110.78 + }, + }; + + constructor() { } + + public startService() { + setInterval(this.updateCurrency.bind(this), 1000 * 60 * 60); + this.updateCurrency(); + } + + public getTickers() { + return this.tickers; + } + + private updateCurrency() { + request('https://api.opennode.co/v1/rates', { json: true }, (err, res, body) => { + if (err) { return console.log(err); } + if (body && body.data) { + this.tickers = body.data; + } + }); + } +} + +export default new FiatConversion(); diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts new file mode 100644 index 000000000..7c8dd0d9f --- /dev/null +++ b/backend/src/api/mempool.ts @@ -0,0 +1,156 @@ +const config = require('../../mempool-config.json'); +import bitcoinApi from './bitcoin-api-wrapper'; +import { ITransaction, IMempoolInfo, IMempool } from '../interfaces'; + +class Mempool { + private mempool: IMempool = {}; + private mempoolInfo: IMempoolInfo | undefined; + private mempoolChangedCallback: Function | undefined; + + private txPerSecondArray: number[] = []; + private txPerSecond: number = 0; + + private vBytesPerSecondArray: any[] = []; + private vBytesPerSecond: number = 0; + + constructor() { + setInterval(this.updateTxPerSecond.bind(this), 1000); + } + + public setMempoolChangedCallback(fn: Function) { + this.mempoolChangedCallback = fn; + } + + public getMempool(): { [txid: string]: ITransaction } { + return this.mempool; + } + + public setMempool(mempoolData: any) { + this.mempool = mempoolData; + } + + public getMempoolInfo(): IMempoolInfo | undefined { + return this.mempoolInfo; + } + + public getTxPerSecond(): number { + return this.txPerSecond; + } + + public getVBytesPerSecond(): number { + return this.vBytesPerSecond; + } + + public async getMemPoolInfo() { + try { + this.mempoolInfo = await bitcoinApi.getMempoolInfo(); + } catch (err) { + console.log('Error getMempoolInfo', err); + } + } + + public async getRawTransaction(txId: string, isCoinbase = false): Promise { + try { + const transaction = await bitcoinApi.getRawTransaction(txId); + let totalIn = 0; + if (!isCoinbase) { + for (let i = 0; i < transaction.vin.length; i++) { + try { + const result = await bitcoinApi.getRawTransaction(transaction.vin[i].txid); + transaction.vin[i]['value'] = result.vout[transaction.vin[i].vout].value; + totalIn += result.vout[transaction.vin[i].vout].value; + } catch (err) { + console.log('Locating historical tx error'); + } + } + } + let totalOut = 0; + transaction.vout.forEach((output) => totalOut += output.value); + + if (totalIn > totalOut) { + transaction.fee = parseFloat((totalIn - totalOut).toFixed(8)); + transaction.feePerWeightUnit = (transaction.fee * 100000000) / (transaction.vsize * 4) || 0; + transaction.feePerVsize = (transaction.fee * 100000000) / (transaction.vsize) || 0; + } else if (!isCoinbase) { + transaction.fee = 0; + transaction.feePerVsize = 0; + transaction.feePerWeightUnit = 0; + console.log('Minus fee error!'); + } + transaction.totalOut = totalOut; + return transaction; + } catch (e) { + console.log(txId + ' not found'); + return false; + } + } + + public async updateMempool() { + console.log('Updating mempool'); + const start = new Date().getTime(); + let hasChange: boolean = false; + let txCount = 0; + try { + const transactions = await bitcoinApi.getRawMempool(); + const diff = transactions.length - Object.keys(this.mempool).length; + for (const tx of transactions) { + if (!this.mempool[tx]) { + const transaction = await this.getRawTransaction(tx); + if (transaction) { + this.mempool[tx] = transaction; + txCount++; + this.txPerSecondArray.push(new Date().getTime()); + this.vBytesPerSecondArray.push({ + unixTime: new Date().getTime(), + vSize: transaction.vsize, + }); + hasChange = true; + if (diff > 0) { + console.log('Calculated fee for transaction ' + txCount + ' / ' + diff); + } else { + console.log('Calculated fee for transaction ' + txCount); + } + } else { + console.log('Error finding transaction in mempool.'); + } + } + } + + const newMempool: IMempool = {}; + transactions.forEach((tx) => { + if (this.mempool[tx]) { + newMempool[tx] = this.mempool[tx]; + } else { + hasChange = true; + } + }); + + this.mempool = newMempool; + + if (hasChange && this.mempoolChangedCallback) { + this.mempoolChangedCallback(this.mempool); + } + + const end = new Date().getTime(); + const time = end - start; + console.log('Mempool updated in ' + time / 1000 + ' seconds'); + } catch (err) { + console.log('getRawMempool error.', err); + } + } + + private updateTxPerSecond() { + const nowMinusTimeSpan = new Date().getTime() - (1000 * config.TX_PER_SECOND_SPAN_SECONDS); + this.txPerSecondArray = this.txPerSecondArray.filter((unixTime) => unixTime > nowMinusTimeSpan); + this.txPerSecond = this.txPerSecondArray.length / config.TX_PER_SECOND_SPAN_SECONDS || 0; + + this.vBytesPerSecondArray = this.vBytesPerSecondArray.filter((data) => data.unixTime > nowMinusTimeSpan); + if (this.vBytesPerSecondArray.length) { + this.vBytesPerSecond = Math.round( + this.vBytesPerSecondArray.map((data) => data.vSize).reduce((a, b) => a + b) / config.TX_PER_SECOND_SPAN_SECONDS + ); + } + } +} + +export default new Mempool(); diff --git a/backend/src/api/projected-blocks.ts b/backend/src/api/projected-blocks.ts new file mode 100644 index 000000000..54b2d894b --- /dev/null +++ b/backend/src/api/projected-blocks.ts @@ -0,0 +1,104 @@ +import { ITransaction, IProjectedBlock, IMempool, IProjectedBlockInternal } from '../interfaces'; + +class ProjectedBlocks { + private projectedBlocks: IProjectedBlockInternal[] = []; + + constructor() {} + + public getProjectedBlocks(txId?: string): IProjectedBlock[] { + return this.projectedBlocks.map((projectedBlock) => { + return { + blockSize: projectedBlock.blockSize, + blockWeight: projectedBlock.blockWeight, + nTx: projectedBlock.nTx, + minFee: projectedBlock.minFee, + maxFee: projectedBlock.maxFee, + minWeightFee: projectedBlock.minWeightFee, + maxWeightFee: projectedBlock.maxWeightFee, + medianFee: projectedBlock.medianFee, + fees: projectedBlock.fees, + hasMytx: txId ? projectedBlock.txIds.some((tx) => tx === txId) : false + }; + }); + } + + public getProjectedBlockFeesForBlock(index: number) { + const projectedBlock = this.projectedBlocks[index]; + + if (!projectedBlock) { + throw new Error('No projected block for that index'); + } + + return projectedBlock.txFeePerVsizes.map((fpv) => { + return {'fpv': fpv}; + }); + } + + public updateProjectedBlocks(memPool: IMempool): void { + const latestMempool = memPool; + const memPoolArray: ITransaction[] = []; + for (const i in latestMempool) { + if (latestMempool.hasOwnProperty(i)) { + memPoolArray.push(latestMempool[i]); + } + } + + if (!memPoolArray.length) { + this.projectedBlocks = []; + } + + memPoolArray.sort((a, b) => b.feePerWeightUnit - a.feePerWeightUnit); + const memPoolArrayFiltered = memPoolArray.filter((tx) => tx.feePerWeightUnit); + const projectedBlocks: any = []; + + let blockWeight = 0; + let blockSize = 0; + let transactions: ITransaction[] = []; + memPoolArrayFiltered.forEach((tx) => { + if (blockWeight + tx.vsize * 4 < 4000000 || projectedBlocks.length === 3) { + blockWeight += tx.vsize * 4; + blockSize += tx.size; + transactions.push(tx); + } else { + projectedBlocks.push(this.dataToProjectedBlock(transactions, blockSize, blockWeight)); + blockWeight = 0; + blockSize = 0; + transactions = []; + } + }); + if (transactions.length) { + projectedBlocks.push(this.dataToProjectedBlock(transactions, blockSize, blockWeight)); + } + this.projectedBlocks = projectedBlocks; + } + + private dataToProjectedBlock(transactions: ITransaction[], blockSize: number, blockWeight: number): IProjectedBlockInternal { + return { + blockSize: blockSize, + blockWeight: blockWeight, + nTx: transactions.length - 1, + minFee: transactions[transactions.length - 1].feePerVsize, + maxFee: transactions[0].feePerVsize, + minWeightFee: transactions[transactions.length - 1].feePerWeightUnit, + maxWeightFee: transactions[0].feePerWeightUnit, + medianFee: this.median(transactions.map((tx) => tx.feePerVsize)), + txIds: transactions.map((tx) => tx.txid), + txFeePerVsizes: transactions.map((tx) => tx.feePerVsize).reverse(), + fees: transactions.map((tx) => tx.fee).reduce((acc, currValue) => acc + currValue), + }; + } + + private median(numbers: number[]) { + let medianNr = 0; + const numsLen = numbers.length; + numbers.sort(); + if (numsLen % 2 === 0) { + medianNr = (numbers[numsLen / 2 - 1] + numbers[numsLen / 2]) / 2; + } else { + medianNr = numbers[(numsLen - 1) / 2]; + } + return medianNr; + } +} + +export default new ProjectedBlocks(); diff --git a/backend/src/api/statistics.ts b/backend/src/api/statistics.ts new file mode 100644 index 000000000..19ab2ac19 --- /dev/null +++ b/backend/src/api/statistics.ts @@ -0,0 +1,379 @@ +import memPool from './mempool'; +import { DB } from '../database'; + +import { ITransaction, IMempoolStats } from '../interfaces'; + +class Statistics { + protected intervalTimer: NodeJS.Timer | undefined; + + constructor() { + } + + public startStatistics(): void { + const now = new Date(); + const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), + Math.floor(now.getMinutes() / 1) * 1 + 1, 0, 0); + const difference = nextInterval.getTime() - now.getTime(); + + setTimeout(() => { + this.runStatistics(); + this.intervalTimer = setInterval(() => { this.runStatistics(); }, 1 * 60 * 1000); + }, difference); + } + + private runStatistics(): void { + const currentMempool = memPool.getMempool(); + const txPerSecond = memPool.getTxPerSecond(); + const vBytesPerSecond = memPool.getVBytesPerSecond(); + + if (txPerSecond === 0) { + return; + } + + console.log('Running statistics'); + + let memPoolArray: ITransaction[] = []; + for (const i in currentMempool) { + if (currentMempool.hasOwnProperty(i)) { + memPoolArray.push(currentMempool[i]); + } + } + // Remove 0 and undefined + memPoolArray = memPoolArray.filter((tx) => tx.feePerWeightUnit); + + if (!memPoolArray.length) { + return; + } + + memPoolArray.sort((a, b) => a.feePerWeightUnit - b.feePerWeightUnit); + const totalWeight = memPoolArray.map((tx) => tx.vsize).reduce((acc, curr) => acc + curr) * 4; + const totalFee = memPoolArray.map((tx) => tx.fee).reduce((acc, curr) => acc + curr); + + const logFees = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, + 250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000]; + + const weightUnitFees: { [feePerWU: number]: number } = {}; + const weightVsizeFees: { [feePerWU: number]: number } = {}; + + memPoolArray.forEach((transaction) => { + for (let i = 0; i < logFees.length; i++) { + if ((logFees[i] === 2000 && transaction.feePerWeightUnit >= 2000) || transaction.feePerWeightUnit <= logFees[i]) { + if (weightUnitFees[logFees[i]]) { + weightUnitFees[logFees[i]] += transaction.vsize * 4; + } else { + weightUnitFees[logFees[i]] = transaction.vsize * 4; + } + break; + } + } + }); + + memPoolArray.forEach((transaction) => { + for (let i = 0; i < logFees.length; i++) { + if ((logFees[i] === 2000 && transaction.feePerVsize >= 2000) || transaction.feePerVsize <= logFees[i]) { + if (weightVsizeFees[logFees[i]]) { + weightVsizeFees[logFees[i]] += transaction.vsize; + } else { + weightVsizeFees[logFees[i]] = transaction.vsize; + } + break; + } + } + }); + + this.$create({ + added: 'NOW()', + unconfirmed_transactions: memPoolArray.length, + tx_per_second: txPerSecond, + vbytes_per_second: Math.round(vBytesPerSecond), + mempool_byte_weight: totalWeight, + total_fee: totalFee, + fee_data: JSON.stringify({ + 'wu': weightUnitFees, + 'vsize': weightVsizeFees + }), + vsize_1: weightVsizeFees['1'] || 0, + vsize_2: weightVsizeFees['2'] || 0, + vsize_3: weightVsizeFees['3'] || 0, + vsize_4: weightVsizeFees['4'] || 0, + vsize_5: weightVsizeFees['5'] || 0, + vsize_6: weightVsizeFees['6'] || 0, + vsize_8: weightVsizeFees['8'] || 0, + vsize_10: weightVsizeFees['10'] || 0, + vsize_12: weightVsizeFees['12'] || 0, + vsize_15: weightVsizeFees['15'] || 0, + vsize_20: weightVsizeFees['20'] || 0, + vsize_30: weightVsizeFees['30'] || 0, + vsize_40: weightVsizeFees['40'] || 0, + vsize_50: weightVsizeFees['50'] || 0, + vsize_60: weightVsizeFees['60'] || 0, + vsize_70: weightVsizeFees['70'] || 0, + vsize_80: weightVsizeFees['80'] || 0, + vsize_90: weightVsizeFees['90'] || 0, + vsize_100: weightVsizeFees['100'] || 0, + vsize_125: weightVsizeFees['125'] || 0, + vsize_150: weightVsizeFees['150'] || 0, + vsize_175: weightVsizeFees['175'] || 0, + vsize_200: weightVsizeFees['200'] || 0, + vsize_250: weightVsizeFees['250'] || 0, + vsize_300: weightVsizeFees['300'] || 0, + vsize_350: weightVsizeFees['350'] || 0, + vsize_400: weightVsizeFees['400'] || 0, + vsize_500: weightVsizeFees['500'] || 0, + vsize_600: weightVsizeFees['600'] || 0, + vsize_700: weightVsizeFees['700'] || 0, + vsize_800: weightVsizeFees['800'] || 0, + vsize_900: weightVsizeFees['900'] || 0, + vsize_1000: weightVsizeFees['1000'] || 0, + vsize_1200: weightVsizeFees['1200'] || 0, + vsize_1400: weightVsizeFees['1400'] || 0, + vsize_1600: weightVsizeFees['1600'] || 0, + vsize_1800: weightVsizeFees['1800'] || 0, + vsize_2000: weightVsizeFees['2000'] || 0, + }); + } + + private async $create(statistics: IMempoolStats): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = `INSERT INTO statistics( + added, + unconfirmed_transactions, + tx_per_second, + vbytes_per_second, + mempool_byte_weight, + fee_data, + total_fee, + vsize_1, + vsize_2, + vsize_3, + vsize_4, + vsize_5, + vsize_6, + vsize_8, + vsize_10, + vsize_12, + vsize_15, + vsize_20, + vsize_30, + vsize_40, + vsize_50, + vsize_60, + vsize_70, + vsize_80, + vsize_90, + vsize_100, + vsize_125, + vsize_150, + vsize_175, + vsize_200, + vsize_250, + vsize_300, + vsize_350, + vsize_400, + vsize_500, + vsize_600, + vsize_700, + vsize_800, + vsize_900, + vsize_1000, + vsize_1200, + vsize_1400, + vsize_1600, + vsize_1800, + vsize_2000 + ) + VALUES (${statistics.added}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; + + const params: (string | number)[] = [ + statistics.unconfirmed_transactions, + statistics.tx_per_second, + statistics.vbytes_per_second, + statistics.mempool_byte_weight, + statistics.fee_data, + statistics.total_fee, + statistics.vsize_1, + statistics.vsize_2, + statistics.vsize_3, + statistics.vsize_4, + statistics.vsize_5, + statistics.vsize_6, + statistics.vsize_8, + statistics.vsize_10, + statistics.vsize_12, + statistics.vsize_15, + statistics.vsize_20, + statistics.vsize_30, + statistics.vsize_40, + statistics.vsize_50, + statistics.vsize_60, + statistics.vsize_70, + statistics.vsize_80, + statistics.vsize_90, + statistics.vsize_100, + statistics.vsize_125, + statistics.vsize_150, + statistics.vsize_175, + statistics.vsize_200, + statistics.vsize_250, + statistics.vsize_300, + statistics.vsize_350, + statistics.vsize_400, + statistics.vsize_500, + statistics.vsize_600, + statistics.vsize_700, + statistics.vsize_800, + statistics.vsize_900, + statistics.vsize_1000, + statistics.vsize_1200, + statistics.vsize_1400, + statistics.vsize_1600, + statistics.vsize_1800, + statistics.vsize_2000, + ]; + await connection.query(query, params); + connection.release(); + } catch (e) { + console.log('$create() error', e); + } + } + + public async $listLatestFromId(fromId: number): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = `SELECT * FROM statistics WHERE id > ? ORDER BY id DESC`; + const [rows] = await connection.query(query, [fromId]); + connection.release(); + return rows; + } catch (e) { + console.log('$listLatestFromId() error', e); + return []; + } + } + + private getQueryForDays(days: number, groupBy: number) { + + return `SELECT id, added, unconfirmed_transactions, + AVG(tx_per_second) AS tx_per_second, + AVG(vbytes_per_second) AS vbytes_per_second, + AVG(vsize_1) AS vsize_1, + AVG(vsize_2) AS vsize_2, + AVG(vsize_3) AS vsize_3, + AVG(vsize_4) AS vsize_4, + AVG(vsize_5) AS vsize_5, + AVG(vsize_6) AS vsize_6, + AVG(vsize_8) AS vsize_8, + AVG(vsize_10) AS vsize_10, + AVG(vsize_12) AS vsize_12, + AVG(vsize_15) AS vsize_15, + AVG(vsize_20) AS vsize_20, + AVG(vsize_30) AS vsize_30, + AVG(vsize_40) AS vsize_40, + AVG(vsize_50) AS vsize_50, + AVG(vsize_60) AS vsize_60, + AVG(vsize_70) AS vsize_70, + AVG(vsize_80) AS vsize_80, + AVG(vsize_90) AS vsize_90, + AVG(vsize_100) AS vsize_100, + AVG(vsize_125) AS vsize_125, + AVG(vsize_150) AS vsize_150, + AVG(vsize_175) AS vsize_175, + AVG(vsize_200) AS vsize_200, + AVG(vsize_250) AS vsize_250, + AVG(vsize_300) AS vsize_300, + AVG(vsize_350) AS vsize_350, + AVG(vsize_400) AS vsize_400, + AVG(vsize_500) AS vsize_500, + AVG(vsize_600) AS vsize_600, + AVG(vsize_700) AS vsize_700, + AVG(vsize_800) AS vsize_800, + AVG(vsize_900) AS vsize_900, + AVG(vsize_1000) AS vsize_1000, + AVG(vsize_1200) AS vsize_1200, + AVG(vsize_1400) AS vsize_1400, + AVG(vsize_1600) AS vsize_1600, + AVG(vsize_1800) AS vsize_1800, + AVG(vsize_2000) AS vsize_2000 FROM statistics GROUP BY UNIX_TIMESTAMP(added) DIV ${groupBy} ORDER BY id DESC LIMIT ${days}`; + } + + public async $list2H(): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = `SELECT * FROM statistics ORDER BY id DESC LIMIT 120`; + const [rows] = await connection.query(query); + connection.release(); + return rows; + } catch (e) { + console.log('$list2H() error', e); + return []; + } + } + + public async $list24H(): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = this.getQueryForDays(120, 720); + const [rows] = await connection.query(query); + connection.release(); + return rows; + } catch (e) { + return []; + } + } + + public async $list1W(): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = this.getQueryForDays(120, 5040); + const [rows] = await connection.query(query); + connection.release(); + return rows; + } catch (e) { + console.log('$list1W() error', e); + return []; + } + } + + public async $list1M(): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = this.getQueryForDays(120, 20160); + const [rows] = await connection.query(query); + connection.release(); + return rows; + } catch (e) { + console.log('$list1M() error', e); + return []; + } + } + + public async $list3M(): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = this.getQueryForDays(120, 60480); + const [rows] = await connection.query(query); + connection.release(); + return rows; + } catch (e) { + console.log('$list3M() error', e); + return []; + } + } + + public async $list6M(): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = this.getQueryForDays(120, 120960); + const [rows] = await connection.query(query); + connection.release(); + return rows; + } catch (e) { + console.log('$list6M() error', e); + return []; + } + } + +} + +export default new Statistics(); diff --git a/backend/src/database.ts b/backend/src/database.ts new file mode 100644 index 000000000..a151ced49 --- /dev/null +++ b/backend/src/database.ts @@ -0,0 +1,26 @@ +const config = require('../mempool-config.json'); +import { createPool } from 'mysql2/promise'; + +export class DB { + static pool = createPool({ + host: config.DB_HOST, + port: config.DB_PORT, + database: config.DB_DATABASE, + user: config.DB_USER, + password: config.DB_PASSWORD, + connectionLimit: 10, + supportBigNumbers: true, + }); +} + +export async function checkDbConnection() { + try { + const connection = await DB.pool.getConnection(); + console.log('MySQL connection established.'); + connection.release(); + } catch (e) { + console.log('Could not connect to MySQL.'); + console.log(e); + process.exit(1); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 000000000..d92559762 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,231 @@ +const config = require('../mempool-config.json'); +import * as fs from 'fs'; +import * as express from 'express'; +import * as compression from 'compression'; +import * as http from 'http'; +import * as https from 'https'; +import * as WebSocket from 'ws'; + +import bitcoinApi from './api/bitcoin-api-wrapper'; +import diskCache from './api/disk-cache'; +import memPool from './api/mempool'; +import blocks from './api/blocks'; +import projectedBlocks from './api/projected-blocks'; +import statistics from './api/statistics'; +import { IBlock, IMempool } from './interfaces'; + +import routes from './routes'; +import fiatConversion from './api/fiat-conversion'; + +class MempoolSpace { + private wss: WebSocket.Server; + private server: https.Server | http.Server; + private app: any; + + constructor() { + this.app = express(); + this.app + .use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + next(); + }) + .use(compression()); + if (config.ENV === 'dev') { + this.server = http.createServer(this.app); + this.wss = new WebSocket.Server({ server: this.server }); + } else { + const credentials = { + cert: fs.readFileSync('/etc/letsencrypt/live/mempool.space/fullchain.pem'), + key: fs.readFileSync('/etc/letsencrypt/live/mempool.space/privkey.pem'), + }; + this.server = https.createServer(credentials, this.app); + this.wss = new WebSocket.Server({ server: this.server }); + } + + this.setUpRoutes(); + this.setUpWebsocketHandling(); + this.setUpMempoolCache(); + this.runMempoolIntervalFunctions(); + + statistics.startStatistics(); + fiatConversion.startService(); + + this.server.listen(8999, () => { + console.log(`Server started on port 8999 :)`); + }); + } + + private async runMempoolIntervalFunctions() { + await blocks.updateBlocks(); + await memPool.getMemPoolInfo(); + await memPool.updateMempool(); + setTimeout(this.runMempoolIntervalFunctions.bind(this), config.MEMPOOL_REFRESH_RATE_MS); + } + + private setUpMempoolCache() { + const cacheData = diskCache.loadData(); + if (cacheData) { + memPool.setMempool(JSON.parse(cacheData)); + } + + process.on('SIGINT', (options) => { + console.log('SIGINT'); + diskCache.saveData(JSON.stringify(memPool.getMempool())); + process.exit(2); + }); + } + + private setUpWebsocketHandling() { + this.wss.on('connection', (client: WebSocket) => { + let theBlocks = blocks.getBlocks(); + theBlocks = theBlocks.concat([]).splice(theBlocks.length - config.INITIAL_BLOCK_AMOUNT); + + const formatedBlocks = theBlocks.map((b) => blocks.formatBlock(b)); + + client.send(JSON.stringify({ + 'mempoolInfo': memPool.getMempoolInfo(), + 'blocks': formatedBlocks, + 'projectedBlocks': projectedBlocks.getProjectedBlocks(), + 'txPerSecond': memPool.getTxPerSecond(), + 'vBytesPerSecond': memPool.getVBytesPerSecond(), + 'conversions': fiatConversion.getTickers()['BTCUSD'], + })); + + client.on('message', async (message: any) => { + try { + const parsedMessage = JSON.parse(message); + if (parsedMessage.action === 'track-tx' && parsedMessage.txId && /^[a-fA-F0-9]{64}$/.test(parsedMessage.txId)) { + const tx = await memPool.getRawTransaction(parsedMessage.txId); + if (tx) { + console.log('Now tracking: ' + parsedMessage.txId); + client['trackingTx'] = true; + client['txId'] = parsedMessage.txId; + client['tx'] = tx; + + if (tx.blockhash) { + const currentBlocks = blocks.getBlocks(); + const foundBlock = currentBlocks.find((block) => block.tx && block.tx.some((i: string) => i === parsedMessage.txId)); + if (foundBlock) { + console.log('Found block by looking in local cache'); + client['blockHeight'] = foundBlock.height; + } else { + const theBlock = await bitcoinApi.getBlock(tx.blockhash); + if (theBlock) { + client['blockHeight'] = theBlock.height; + } + } + } else { + client['blockHeight'] = 0; + } + client.send(JSON.stringify({ + 'projectedBlocks': projectedBlocks.getProjectedBlocks(client['txId']), + 'track-tx': { + tracking: true, + blockHeight: client['blockHeight'], + tx: client['tx'], + } + })); + } else { + console.log('TX NOT FOUND, NOT TRACKING'); + client.send(JSON.stringify({ + 'track-tx': { + tracking: false, + blockHeight: 0, + message: 'not-found', + } + })); + } + } + if (parsedMessage.action === 'stop-tracking-tx') { + console.log('STOP TRACKING'); + client['trackingTx'] = false; + client.send(JSON.stringify({ + 'track-tx': { + tracking: false, + blockHeight: 0, + message: 'not-found', + } + })); + } + } catch (e) { + console.log(e); + } + }); + + client.on('close', () => { + client['trackingTx'] = false; + }); + }); + + blocks.setNewBlockCallback((block: IBlock) => { + const formattedBlocks = blocks.formatBlock(block); + + this.wss.clients.forEach((client) => { + if (client.readyState !== WebSocket.OPEN) { + return; + } + + if (client['trackingTx'] === true && client['blockHeight'] === 0) { + if (block.tx.some((tx) => tx === client['txId'])) { + client['blockHeight'] = block.height; + } + } + + client.send(JSON.stringify({ + 'block': formattedBlocks, + 'track-tx': { + tracking: client['trackingTx'] || false, + blockHeight: client['blockHeight'], + } + })); + }); + }); + + memPool.setMempoolChangedCallback((newMempool: IMempool) => { + projectedBlocks.updateProjectedBlocks(newMempool); + + let pBlocks = projectedBlocks.getProjectedBlocks(); + const mempoolInfo = memPool.getMempoolInfo(); + const txPerSecond = memPool.getTxPerSecond(); + const vBytesPerSecond = memPool.getVBytesPerSecond(); + + this.wss.clients.forEach((client: WebSocket) => { + if (client.readyState !== WebSocket.OPEN) { + return; + } + + if (client['trackingTx'] && client['blockHeight'] === 0) { + pBlocks = projectedBlocks.getProjectedBlocks(client['txId']); + } + + client.send(JSON.stringify({ + 'projectedBlocks': pBlocks, + 'mempoolInfo': mempoolInfo, + 'txPerSecond': txPerSecond, + 'vBytesPerSecond': vBytesPerSecond, + 'track-tx': { + tracking: client['trackingTx'] || false, + blockHeight: client['blockHeight'], + } + })); + }); + }); + } + + private setUpRoutes() { + this.app + .get(config.API_ENDPOINT + 'transactions/height/:id', routes.$getgetTransactionsForBlock) + .get(config.API_ENDPOINT + 'transactions/projected/:id', routes.getgetTransactionsForProjectedBlock) + .get(config.API_ENDPOINT + 'fees/recommended', routes.getRecommendedFees) + .get(config.API_ENDPOINT + 'statistics/live', routes.getLiveResult) + .get(config.API_ENDPOINT + 'statistics/2h', routes.get2HStatistics) + .get(config.API_ENDPOINT + 'statistics/24h', routes.get24HStatistics) + .get(config.API_ENDPOINT + 'statistics/1w', routes.get1WHStatistics) + .get(config.API_ENDPOINT + 'statistics/1m', routes.get1MStatistics) + .get(config.API_ENDPOINT + 'statistics/3m', routes.get3MStatistics) + .get(config.API_ENDPOINT + 'statistics/6m', routes.get6MStatistics) + ; + } +} + +const mempoolSpace = new MempoolSpace(); diff --git a/backend/src/interfaces.ts b/backend/src/interfaces.ts new file mode 100644 index 000000000..424f7dafa --- /dev/null +++ b/backend/src/interfaces.ts @@ -0,0 +1,151 @@ +export interface IMempoolInfo { + size: number; + bytes: number; + usage: number; + maxmempool: number; + mempoolminfee: number; + minrelaytxfee: number; +} + +export interface ITransaction { + txid: string; + hash: string; + version: number; + size: number; + vsize: number; + locktime: number; + vin: Vin[]; + vout: Vout[]; + hex: string; + fee: number; + feePerWeightUnit: number; + feePerVsize: number; + blockhash?: string; + confirmations?: number; + time?: number; + blocktime?: number; + totalOut?: number; +} + +export interface IBlock { + hash: string; + confirmations: number; + strippedsize: number; + size: number; + weight: number; + height: number; + version: number; + versionHex: string; + merkleroot: string; + tx: any; + time: number; + mediantime: number; + nonce: number; + bits: string; + difficulty: number; + chainwork: string; + nTx: number; + previousblockhash: string; + fees: number; + + minFee?: number; + maxFee?: number; + medianFee?: number; +} + +interface ScriptSig { + asm: string; + hex: string; +} + +interface Vin { + txid: string; + vout: number; + scriptSig: ScriptSig; + sequence: number; +} + +interface ScriptPubKey { + asm: string; + hex: string; + reqSigs: number; + type: string; + addresses: string[]; +} + +interface Vout { + value: number; + n: number; + scriptPubKey: ScriptPubKey; +} + +export interface IMempoolStats { + id?: number; + added: string; + unconfirmed_transactions: number; + tx_per_second: number; + vbytes_per_second: number; + total_fee: number; + mempool_byte_weight: number; + fee_data: string; + + vsize_1: number; + vsize_2: number; + vsize_3: number; + vsize_4: number; + vsize_5: number; + vsize_6: number; + vsize_8: number; + vsize_10: number; + vsize_12: number; + vsize_15: number; + vsize_20: number; + vsize_30: number; + vsize_40: number; + vsize_50: number; + vsize_60: number; + vsize_70: number; + vsize_80: number; + vsize_90: number; + vsize_100: number; + vsize_125: number; + vsize_150: number; + vsize_175: number; + vsize_200: number; + vsize_250: number; + vsize_300: number; + vsize_350: number; + vsize_400: number; + vsize_500: number; + vsize_600: number; + vsize_700: number; + vsize_800: number; + vsize_900: number; + vsize_1000: number; + vsize_1200: number; + vsize_1400: number; + vsize_1600: number; + vsize_1800: number; + vsize_2000: number; +} + +export interface IProjectedBlockInternal extends IProjectedBlock { + txIds: string[]; + txFeePerVsizes: number[]; +} + +export interface IProjectedBlock { + blockSize: number; + blockWeight: number; + maxFee: number; + maxWeightFee: number; + medianFee: number; + minFee: number; + minWeightFee: number; + nTx: number; + fees: number; + hasMyTxId?: boolean; +} + +export interface IMempool { [txid: string]: ITransaction; } + diff --git a/backend/src/routes.ts b/backend/src/routes.ts new file mode 100644 index 000000000..3c620c4a7 --- /dev/null +++ b/backend/src/routes.ts @@ -0,0 +1,63 @@ +import statistics from './api/statistics'; +import feeApi from './api/fee-api'; +import projectedBlocks from './api/projected-blocks'; + +class Routes { + constructor() {} + + public async getLiveResult(req, res) { + const result = await statistics.$listLatestFromId(req.query.lastId); + res.send(result); + } + + public async get2HStatistics(req, res) { + const result = await statistics.$list2H(); + res.send(result); + } + + public async get24HStatistics(req, res) { + const result = await statistics.$list24H(); + res.send(result); + } + + public async get1WHStatistics(req, res) { + const result = await statistics.$list1W(); + res.send(result); + } + + public async get1MStatistics(req, res) { + const result = await statistics.$list1M(); + res.send(result); + } + + public async get3MStatistics(req, res) { + const result = await statistics.$list3M(); + res.send(result); + } + + public async get6MStatistics(req, res) { + const result = await statistics.$list6M(); + res.send(result); + } + + public async getRecommendedFees(req, res) { + const result = feeApi.getRecommendedFee(); + res.send(result); + } + + public async $getgetTransactionsForBlock(req, res) { + const result = await feeApi.$getTransactionsForBlock(req.params.id); + res.send(result); + } + + public async getgetTransactionsForProjectedBlock(req, res) { + try { + const result = await projectedBlocks.getProjectedBlockFeesForBlock(req.params.id); + res.send(result); + } catch (e) { + res.status(500).send(e.message); + } + } +} + +export default new Routes(); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 000000000..fb1b48c6e --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2015", + "strict": true, + "noImplicitAny": false, + "sourceMap": false, + "outDir": "dist", + "moduleResolution": "node", + "typeRoots": [ + "node_modules/@types" + ] + }, + "include": [ + "src/**/*.ts", + ], + "exclude": [ + "dist/**" + ] +} \ No newline at end of file diff --git a/backend/tslint.json b/backend/tslint.json new file mode 100644 index 000000000..65ac58f4b --- /dev/null +++ b/backend/tslint.json @@ -0,0 +1,137 @@ +{ + "rules": { + "arrow-return-shorthand": true, + "callable-types": true, + "class-name": true, + "comment-format": [ + true, + "check-space" + ], + "curly": true, + "deprecation": { + "severity": "warn" + }, + "eofline": true, + "forin": true, + "import-blacklist": [ + true, + "rxjs", + "rxjs/Rx" + ], + "import-spacing": true, + "indent": [ + true, + "spaces" + ], + "interface-over-type-literal": true, + "label-position": true, + "max-line-length": [ + true, + 140 + ], + "member-access": false, + "member-ordering": [ + true, + { + "order": [ + "static-field", + "instance-field", + "static-method", + "instance-method" + ] + } + ], + "no-arg": true, + "no-bitwise": true, + "no-console": [ + true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-super": true, + "no-empty": false, + "no-empty-interface": true, + "no-eval": true, + "no-inferrable-types": false, + "no-misused-new": true, + "no-non-null-assertion": true, + "no-shadowed-variable": true, + "no-string-literal": false, + "no-string-throw": true, + "no-switch-case-fall-through": true, + "no-trailing-whitespace": true, + "no-unnecessary-initializer": true, + "no-unused-expression": true, + "no-use-before-declare": true, + "no-var-keyword": true, + "object-literal-sort-keys": false, + "one-line": [ + true, + "check-open-brace", + "check-catch", + "check-else", + "check-whitespace" + ], + "prefer-const": true, + "quotemark": [ + true, + "single" + ], + "radix": true, + "semicolon": [ + true, + "always" + ], + "triple-equals": [ + true, + "allow-null-check" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + } + ], + "unified-signatures": true, + "variable-name": false, + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ], + "directive-selector": [ + true, + "attribute", + "app", + "camelCase" + ], + "component-selector": [ + true, + "element", + "app", + "kebab-case" + ], + "no-output-on-prefix": true, + "use-input-property-decorator": true, + "use-output-property-decorator": true, + "use-host-property-decorator": true, + "no-input-rename": true, + "no-output-rename": true, + "use-life-cycle-interface": true, + "use-pipe-transform-interface": true, + "component-class-suffix": true, + "directive-class-suffix": true + } +} diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 000000000..6e87a003d --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,13 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..ee5c9d833 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,39 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +/dist +/tmp +/out-tsc + +# dependencies +/node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings + +# System Files +.DS_Store +Thumbs.db diff --git a/frontend/angular.json b/frontend/angular.json new file mode 100644 index 000000000..fe9ec09b0 --- /dev/null +++ b/frontend/angular.json @@ -0,0 +1,127 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "mempool": { + "root": "", + "sourceRoot": "src", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/mempool", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets", + "src/.htaccess" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "mempool:build" + }, + "configurations": { + "production": { + "browserTarget": "mempool:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "mempool:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": [ + "src/styles.scss" + ], + "scripts": [], + "assets": [ + "src/favicon.ico", + "src/assets" + ] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "src/tsconfig.app.json", + "src/tsconfig.spec.json" + ], + "exclude": [ + "**/node_modules/**" + ] + } + } + } + }, + "mempool-e2e": { + "root": "e2e/", + "projectType": "application", + "architect": { + "e2e": { + "builder": "@angular-devkit/build-angular:protractor", + "options": { + "protractorConfig": "e2e/protractor.conf.js", + "devServerTarget": "mempool:serve" + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": "e2e/tsconfig.e2e.json", + "exclude": [ + "**/node_modules/**" + ] + } + } + } + } + }, + "defaultProject": "mempool" +} \ No newline at end of file diff --git a/frontend/e2e/protractor.conf.js b/frontend/e2e/protractor.conf.js new file mode 100644 index 000000000..86776a391 --- /dev/null +++ b/frontend/e2e/protractor.conf.js @@ -0,0 +1,28 @@ +// Protractor configuration file, see link for more information +// https://github.com/angular/protractor/blob/master/lib/config.ts + +const { SpecReporter } = require('jasmine-spec-reporter'); + +exports.config = { + allScriptsTimeout: 11000, + specs: [ + './src/**/*.e2e-spec.ts' + ], + capabilities: { + 'browserName': 'chrome' + }, + directConnect: true, + baseUrl: 'http://localhost:4200/', + framework: 'jasmine', + jasmineNodeOpts: { + showColors: true, + defaultTimeoutInterval: 30000, + print: function() {} + }, + onPrepare() { + require('ts-node').register({ + project: require('path').join(__dirname, './tsconfig.e2e.json') + }); + jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); + } +}; \ No newline at end of file diff --git a/frontend/e2e/src/app.e2e-spec.ts b/frontend/e2e/src/app.e2e-spec.ts new file mode 100644 index 000000000..e42d1f965 --- /dev/null +++ b/frontend/e2e/src/app.e2e-spec.ts @@ -0,0 +1,14 @@ +import { AppPage } from './app.po'; + +describe('workspace-project App', () => { + let page: AppPage; + + beforeEach(() => { + page = new AppPage(); + }); + + it('should display welcome message', () => { + page.navigateTo(); + expect(page.getParagraphText()).toEqual('Welcome to app!'); + }); +}); diff --git a/frontend/e2e/src/app.po.ts b/frontend/e2e/src/app.po.ts new file mode 100644 index 000000000..82ea75ba5 --- /dev/null +++ b/frontend/e2e/src/app.po.ts @@ -0,0 +1,11 @@ +import { browser, by, element } from 'protractor'; + +export class AppPage { + navigateTo() { + return browser.get('/'); + } + + getParagraphText() { + return element(by.css('app-root h1')).getText(); + } +} diff --git a/frontend/e2e/tsconfig.e2e.json b/frontend/e2e/tsconfig.e2e.json new file mode 100644 index 000000000..a6dd62202 --- /dev/null +++ b/frontend/e2e/tsconfig.e2e.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/app", + "module": "commonjs", + "target": "es5", + "types": [ + "jasmine", + "jasminewd2", + "node" + ] + } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..9d8d6dfdc --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,53 @@ +{ + "name": "mempool", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve --aot --proxy-config proxy.conf.json", + "build": "ng build --prod --vendorChunk=false --build-optimizer=true", + "test": "ng test", + "lint": "ng lint", + "e2e": "ng e2e" + }, + "private": true, + "dependencies": { + "@angular/animations": "^8.0.0", + "@angular/common": "^8.0.0", + "@angular/compiler": "^8.0.0", + "@angular/core": "^8.0.0", + "@angular/forms": "^8.0.0", + "@angular/platform-browser": "^8.0.0", + "@angular/platform-browser-dynamic": "^8.0.0", + "@angular/router": "^8.0.0", + "@ng-bootstrap/ng-bootstrap": "^3.3.1", + "bootstrap": "^4.3.1", + "chartist": "^0.11.2", + "core-js": "^2.6.9", + "ng-chartist": "^2.0.0-beta.1", + "rxjs": "^6.5.2", + "tslib": "^1.9.0", + "zone.js": "~0.9.1" + }, + "devDependencies": { + "@angular-devkit/build-angular": "~0.800.0", + "@angular/cli": "~8.0.2", + "@angular/compiler-cli": "^8.0.0", + "@angular/language-service": "^8.0.0", + "@types/chartist": "^0.9.46", + "@types/jasmine": "^2.8.16", + "@types/jasminewd2": "^2.0.6", + "@types/node": "~8.9.4", + "codelyzer": "~5.1.0", + "jasmine-core": "~2.99.1", + "jasmine-spec-reporter": "~4.2.1", + "karma": "~1.7.1", + "karma-chrome-launcher": "~2.2.0", + "karma-coverage-istanbul-reporter": "~1.4.2", + "karma-jasmine": "~1.1.1", + "karma-jasmine-html-reporter": "^0.2.2", + "protractor": "~5.3.0", + "ts-node": "~7.0.0", + "tslint": "~5.15.0", + "typescript": "~3.4.3" + } +} diff --git a/frontend/proxy.conf.json b/frontend/proxy.conf.json new file mode 100644 index 000000000..8a5402c52 --- /dev/null +++ b/frontend/proxy.conf.json @@ -0,0 +1,6 @@ +{ + "/api": { + "target": "http://localhost:8999/", + "secure": false + } +} \ No newline at end of file diff --git a/frontend/src/.htaccess b/frontend/src/.htaccess new file mode 100644 index 000000000..2513ec66c --- /dev/null +++ b/frontend/src/.htaccess @@ -0,0 +1,7 @@ +RewriteEngine on +RewriteCond %{REQUEST_FILENAME} -s [OR] +RewriteCond %{REQUEST_FILENAME} -l [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^.*$ - [NC,L] + +RewriteRule ^(.*) /index.html [NC,L] \ No newline at end of file diff --git a/frontend/src/app/about/about.component.html b/frontend/src/app/about/about.component.html new file mode 100644 index 000000000..99defc554 --- /dev/null +++ b/frontend/src/app/about/about.component.html @@ -0,0 +1,41 @@ +
+ +

+ +

About

+ +

Mempool.Space is a realtime Bitcoin blockchain visualizer and statistics website focused on SegWit.

+

Created by @softcrypto (Telegram). @softcrypt0 (Twitter). +
Designed by emeraldo.io.

+ + +

Fee API

+ +
+ +
+ +
+ +

Donate

+

Segwit native

+ +
+ bc1qqrmgr60uetlmrpylhtllawyha9z5gw6hwdmk2t + +

+

Segwit compatibility

+ +
+ 3Ccig4G4u8hbExnxBJHeE5ZmxxWxvEQ65f + + +

+ +

PayNym

+ +
+

+ PM8TJZWDn1XbYmVVMR3RP9Kt1BW69VCSLTC12UB8iWUiKcEBJsxB4UUKBMJxc3LVaxtU5d524sLFrTy9kFuyPQ73QkEagGcMfCE6M38E5C67EF8KAqvS +

+
diff --git a/frontend/src/app/about/about.component.scss b/frontend/src/app/about/about.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/about/about.component.ts b/frontend/src/app/about/about.component.ts new file mode 100644 index 000000000..9a3c3894e --- /dev/null +++ b/frontend/src/app/about/about.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-about', + templateUrl: './about.component.html', + styleUrls: ['./about.component.scss'] +}) +export class AboutComponent implements OnInit { + + constructor() { } + + ngOnInit() { + } + +} diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts new file mode 100644 index 000000000..6f435c80a --- /dev/null +++ b/frontend/src/app/app-routing.module.ts @@ -0,0 +1,40 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { BlockchainComponent } from './blockchain/blockchain.component'; +import { AboutComponent } from './about/about.component'; +import { StatisticsComponent } from './statistics/statistics.component'; + +const routes: Routes = [ + { + path: '', + children: [], + component: BlockchainComponent + }, + { + path: 'tx/:id', + children: [], + component: BlockchainComponent + }, + { + path: 'about', + children: [], + component: AboutComponent + }, + { + path: 'statistics', + component: StatisticsComponent, + }, + { + path: 'graphs', + component: StatisticsComponent, + }, + { + path: '**', + redirectTo: '' + } +]; +@NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule] +}) +export class AppRoutingModule { } diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html new file mode 100644 index 000000000..aca28bbea --- /dev/null +++ b/frontend/src/app/app.component.html @@ -0,0 +1,32 @@ +
+ +
+ +
+ + \ No newline at end of file diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss new file mode 100644 index 000000000..bdaf25149 --- /dev/null +++ b/frontend/src/app/app.component.scss @@ -0,0 +1,28 @@ +li.nav-item.active { + background-color: #653b9c; +} + +li.nav-item { + padding: 10px; +} + +.navbar { + z-index: 100; +} + +@media (min-width: 768px) { + .navbar { + padding: 0rem 1rem; + } + li.nav-item { + padding: 20px; + } +} + +.logo { + margin-left: 40px; +} + +li.nav-item a { + color: #ffffff; +} diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts new file mode 100644 index 000000000..6346c837c --- /dev/null +++ b/frontend/src/app/app.component.ts @@ -0,0 +1,52 @@ +import { Component, OnInit } from '@angular/core'; +import { MemPoolService } from './services/mem-pool.service'; +import { Router } from '@angular/router'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'] +}) +export class AppComponent implements OnInit { + navCollapsed = false; + isOffline = false; + searchForm: FormGroup; + + constructor( + private memPoolService: MemPoolService, + private router: Router, + private formBuilder: FormBuilder, + ) { } + + ngOnInit() { + this.searchForm = this.formBuilder.group({ + txId: ['', Validators.pattern('^[a-fA-F0-9]{64}$')], + }); + + this.memPoolService.isOffline + .subscribe((state) => { + this.isOffline = state; + }); + } + + collapse(): void { + this.navCollapsed = !this.navCollapsed; + } + + search() { + const txId = this.searchForm.value.txId; + if (txId) { + if (window.location.pathname === '/' || window.location.pathname.substr(0, 4) === '/tx/') { + window.history.pushState({}, '', `/tx/${txId}`); + } else { + this.router.navigate(['/tx/', txId]); + } + this.memPoolService.txIdSearch.next(txId); + this.searchForm.setValue({ + txId: '', + }); + this.collapse(); + } + } +} diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts new file mode 100644 index 000000000..6330c103d --- /dev/null +++ b/frontend/src/app/app.module.ts @@ -0,0 +1,45 @@ +import { BrowserModule } from '@angular/platform-browser'; +import { NgModule } from '@angular/core'; + +import { AppComponent } from './app.component'; +import { BlockchainComponent } from './blockchain/blockchain.component'; +import { AppRoutingModule } from './app-routing.module'; +import { SharedModule } from './shared/shared.module'; +import { MemPoolService } from './services/mem-pool.service'; +import { HttpClientModule } from '@angular/common/http'; +import { FooterComponent } from './footer/footer.component'; +import { AboutComponent } from './about/about.component'; +import { TxBubbleComponent } from './tx-bubble/tx-bubble.component'; +import { ReactiveFormsModule } from '@angular/forms'; +import { BlockModalComponent } from './block-modal/block-modal.component'; +import { StatisticsComponent } from './statistics/statistics.component'; +import { ProjectedBlockModalComponent } from './projected-block-modal/projected-block-modal.component'; + +@NgModule({ + declarations: [ + AppComponent, + BlockchainComponent, + FooterComponent, + StatisticsComponent, + AboutComponent, + TxBubbleComponent, + BlockModalComponent, + ProjectedBlockModalComponent, + ], + imports: [ + ReactiveFormsModule, + BrowserModule, + HttpClientModule, + AppRoutingModule, + SharedModule, + ], + providers: [ + MemPoolService, + ], + entryComponents: [ + BlockModalComponent, + ProjectedBlockModalComponent, + ], + bootstrap: [AppComponent] +}) +export class AppModule { } diff --git a/frontend/src/app/block-modal/block-modal.component.html b/frontend/src/app/block-modal/block-modal.component.html new file mode 100644 index 000000000..f726dad7d --- /dev/null +++ b/frontend/src/app/block-modal/block-modal.component.html @@ -0,0 +1,45 @@ + + diff --git a/frontend/src/app/block-modal/block-modal.component.scss b/frontend/src/app/block-modal/block-modal.component.scss new file mode 100644 index 000000000..75d226682 --- /dev/null +++ b/frontend/src/app/block-modal/block-modal.component.scss @@ -0,0 +1,7 @@ +.yellow-color { + color: #ffd800; +} + +.green-color { + color: #3bcc49; +} diff --git a/frontend/src/app/block-modal/block-modal.component.ts b/frontend/src/app/block-modal/block-modal.component.ts new file mode 100644 index 000000000..b65c92cd9 --- /dev/null +++ b/frontend/src/app/block-modal/block-modal.component.ts @@ -0,0 +1,73 @@ +import { Component, OnInit, Input } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ApiService } from '../services/api.service'; +import { IBlock } from '../blockchain/interfaces'; +import { MemPoolService } from '../services/mem-pool.service'; +import * as Chartist from 'chartist'; + +@Component({ + selector: 'app-block-modal', + templateUrl: './block-modal.component.html', + styleUrls: ['./block-modal.component.scss'] +}) +export class BlockModalComponent implements OnInit { + @Input() block: IBlock; + + mempoolVsizeFeesData: any; + mempoolVsizeFeesOptions: any; + conversions: any; + + constructor( + public activeModal: NgbActiveModal, + private apiService: ApiService, + private memPoolService: MemPoolService, + ) { } + + ngOnInit() { + + this.mempoolVsizeFeesOptions = { + showArea: false, + showLine: false, + fullWidth: false, + showPoint: false, + low: 0, + axisX: { + position: 'start', + showLabel: false, + offset: 0, + showGrid: false, + }, + axisY: { + position: 'end', + scaleMinSpace: 40, + showGrid: false, + }, + plugins: [ + Chartist.plugins.tooltip({ + tooltipOffset: { + x: 15, + y: 250 + }, + transformTooltipTextFnc: (value: number): any => { + return Math.ceil(value) + ' sat/vB'; + }, + anchorToPoint: false, + }) + ] + }; + + this.memPoolService.conversions + .subscribe((conversions) => { + this.conversions = conversions; + }); + + this.apiService.listTransactionsForBlock$(this.block.height) + .subscribe((data) => { + this.mempoolVsizeFeesData = { + labels: data.map((x, i) => i), + series: [data.map((tx) => tx.fpv)] + }; + }); + } + +} diff --git a/frontend/src/app/blockchain/blockchain.component.html b/frontend/src/app/blockchain/blockchain.component.html new file mode 100644 index 000000000..98075f441 --- /dev/null +++ b/frontend/src/app/blockchain/blockchain.component.html @@ -0,0 +1,69 @@ +
+

Loading blocks...

+
+
+
+
+

Locating transaction...

+
+
+

Transaction not found!

+
+
+
+ +
+
+
+
+
+ ~{{ projectedBlock.medianFee | ceil }} sat/vB +
+ {{ projectedBlock.minFee | ceil }} - {{ projectedBlock.maxFee | ceil }} sat/vB +
+
{{ projectedBlock.blockSize | bytes: 2 }}
+
{{ projectedBlock.nTx }} transactions
+
In ~{{ 10 * i + 10 }} minutes
+ +
+{{ projectedBlock.blockWeight / 4000000 | ceil }} blocks
+
+
+ +
+
+
+
+ +
+
+ + + +
+
+ ~{{ block.medianFee | ceil }} sat/vB +
+ {{ block.minFee | ceil }} - {{ block.maxFee | ceil }} sat/vB +
+ +
{{ block.size | bytes: 2 }}
+
{{ block.nTx }} transactions
+

+
{{ getTimeSinceMined(block) }} ago
+
+ +
+
+ +
+ +
+
+ +
+ + + + diff --git a/frontend/src/app/blockchain/blockchain.component.scss b/frontend/src/app/blockchain/blockchain.component.scss new file mode 100644 index 000000000..1898bcbbf --- /dev/null +++ b/frontend/src/app/blockchain/blockchain.component.scss @@ -0,0 +1,195 @@ +.block-filled { + width: 100%; + background-color: #aeffb0; + position: absolute; + bottom: 0; + left: 0; +} + +.block-filled .segwit { + background-color: #16ca1a; +} + +.bitcoin-block { + width: 125px; + height: 125px; + cursor: pointer; +} + +.mined-block { + position: absolute; + top: 0px; + transition: 1s; +} + +.block-size { + font-size: 18px; + font-weight: bold; +} + +.blocks-container { + position: absolute; + top: 0px; + left: 40px; +} + +.projected-blocks-container { + position: absolute; + top: 0px; + right: 0px; + left: 0px; + + animation: opacityPulse 2s ease-out; + animation-iteration-count: infinite; + opacity: 1; +} + +.projected-block { + position: absolute; + top: 0; +} + +.block-body { + text-align: center; +} + +@keyframes opacityPulse { + 0% {opacity: 0.7;} + 50% {opacity: 1.0;} + 100% {opacity: 0.7;} +} + +.time-difference { + position: absolute; + bottom: 10px; + text-align: center; + width: 100%; + font-size: 14px; +} + +#divider { + width: 3px; + height: 3000px; + left: 0; + top: -1000px; + background-image: url('/assets/divider-new.png'); + background-repeat: repeat-y; + position: absolute; + margin-bottom: 120px; +} + +#divider > img { + position: absolute; + left: -100px; + top: -28px; +} + +.fees { + font-size: 10px; + margin-top: 10px; + margin-bottom: 2px; +} + +.btcblockmiddle { + height: 18px; +} + +.breakRow { + height: 30px; + margin-top: 20px; +} + +.yellow-color { + color: #ffd800; +} + +.transaction-count { + font-size: 12px; +} + +.blockchain-wrapper { + overflow: hidden; +} + +.position-container { + position: absolute; + left: 50%; + top: calc(50% - 60px); +} + +.block-height { + position: absolute; + font-size: 12px; + bottom: 160px; + width: 100%; + left: -12px; + text-shadow: 0px 32px 3px #111; + z-index: 100; +} + +@media (max-width: 767.98px) { + #divider { + top: -50px; + } + .position-container { + top: 100px; + } + .projected-blocks-container { + position: absolute; + left: -165px; + top: -40px; + } + .block-height { + bottom: 125px; + left: inherit; + text-shadow: inherit; + z-index: inherit; + } +} + +@media (min-width: 1920px) { + .position-container { + transform: scale(1.3); + } +} + +@media (min-width: 768px) { + .bitcoin-block::after { + content: ''; + width: 125px; + height: 24px; + position:absolute; + top: -24px; + left: -20px; + background-color: #232838; + transform:skew(40deg); + transform-origin:top; + } + + .bitcoin-block::before { + content: ''; + width: 20px; + height: 125px; + position: absolute; + top: -12px; + left: -20px; + background-color: #191c27; + + transform: skewY(50deg); + transform-origin: top; + } + + .projected-block.bitcoin-block::after { + background-color: #403834; + } + + .projected-block.bitcoin-block::before { + background-color: #2d2825; + } +} + +.black-background { + background-color: #11131f; + z-index: 100; + position: relative; +} diff --git a/frontend/src/app/blockchain/blockchain.component.ts b/frontend/src/app/blockchain/blockchain.component.ts new file mode 100644 index 000000000..5b0600aae --- /dev/null +++ b/frontend/src/app/blockchain/blockchain.component.ts @@ -0,0 +1,272 @@ +import { Component, OnInit, OnDestroy, Renderer2, HostListener } from '@angular/core'; +import { IMempoolDefaultResponse, IBlock, IProjectedBlock, ITransaction } from './interfaces'; +import { retryWhen, tap } from 'rxjs/operators'; +import { MemPoolService } from '../services/mem-pool.service'; +import { ApiService } from '../services/api.service'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { BlockModalComponent } from '../block-modal/block-modal.component'; +import { ProjectedBlockModalComponent } from '../projected-block-modal/projected-block-modal.component'; + +@Component({ + selector: 'app-blockchain', + templateUrl: './blockchain.component.html', + styleUrls: ['./blockchain.component.scss'] +}) +export class BlockchainComponent implements OnInit, OnDestroy { + blocks: IBlock[] = []; + projectedBlocks: IProjectedBlock[] = []; + subscription: any; + socket: any; + innerWidth: any; + txBubbleStyle: any = {}; + + txTrackingLoading = false; + txTrackingEnabled = false; + txTrackingTx: ITransaction | null = null; + txTrackingBlockHeight = 0; + txShowTxNotFound = false; + txBubbleArrowPosition = 'top'; + + @HostListener('window:resize', ['$event']) + onResize(event: Event) { + this.innerWidth = window.innerWidth; + this.moveTxBubbleToPosition(); + } + + constructor( + private memPoolService: MemPoolService, + private apiService: ApiService, + private renderer: Renderer2, + private route: ActivatedRoute, + private modalService: NgbModal, + ) {} + + ngOnInit() { + + this.txBubbleStyle = { + 'position': 'absolute', + 'top': '425px', + 'visibility': 'hidden', + }; + + this.innerWidth = window.innerWidth; + this.socket = this.apiService.websocketSubject; + this.subscription = this.socket + .pipe( + retryWhen((errors: any) => errors.pipe( + tap(() => this.memPoolService.isOffline.next(true)))) + ) + .subscribe((response: IMempoolDefaultResponse) => { + this.memPoolService.isOffline.next(false); + if (response.mempoolInfo && response.txPerSecond !== undefined) { + this.memPoolService.loaderSubject.next({ + memPoolInfo: response.mempoolInfo, + txPerSecond: response.txPerSecond, + vBytesPerSecond: response.vBytesPerSecond, + }); + } + if (response.blocks && response.blocks.length) { + this.blocks = response.blocks; + this.blocks.reverse(); + } + if (response.block) { + if (!this.blocks.some((block) => response.block !== undefined && response.block.height === block.height )) { + this.blocks.unshift(response.block); + if (this.blocks.length >= 8) { + this.blocks.pop(); + } + } + } + if (response.conversions) { + this.memPoolService.conversions.next(response.conversions); + } + if (response.projectedBlocks) { + this.projectedBlocks = response.projectedBlocks; + const mempoolWeight = this.projectedBlocks.map((block) => block.blockWeight).reduce((a, b) => a + b); + this.memPoolService.mempoolWeight.next(mempoolWeight); + } + if (response['track-tx']) { + if (response['track-tx'].tracking) { + this.txTrackingEnabled = true; + this.txTrackingBlockHeight = response['track-tx'].blockHeight; + if (response['track-tx'].tx) { + this.txTrackingTx = response['track-tx'].tx; + this.txTrackingLoading = false; + } + } else { + this.txTrackingEnabled = false; + this.txTrackingTx = null; + this.txTrackingBlockHeight = 0; + } + if (response['track-tx'].message && response['track-tx'].message === 'not-found') { + this.txTrackingLoading = false; + this.txShowTxNotFound = true; + setTimeout(() => { this.txShowTxNotFound = false; }, 2000); + } + setTimeout(() => { + this.moveTxBubbleToPosition(); + }); + } + }, + (err: Error) => console.log(err) + ); + this.renderer.addClass(document.body, 'disable-scroll'); + + this.route.paramMap + .subscribe((params: ParamMap) => { + const txId: string | null = params.get('id'); + if (!txId) { + return; + } + this.txTrackingLoading = true; + this.socket.next({'action': 'track-tx', 'txId': txId}); + }); + + this.memPoolService.txIdSearch + .subscribe((txId) => { + if (txId) { + this.txTrackingLoading = true; + this.socket.next({'action': 'track-tx', 'txId': txId}); + } + }); + } + + moveTxBubbleToPosition() { + let element: HTMLElement | null = null; + if (this.txTrackingBlockHeight === 0) { + const index = this.projectedBlocks.findIndex((pB) => pB.hasMytx); + if (index > -1) { + element = document.getElementById('projected-block-' + index); + } else { + return; + } + } else { + element = document.getElementById('bitcoin-block-' + this.txTrackingBlockHeight); + } + + this.txBubbleStyle['visibility'] = 'visible'; + this.txBubbleStyle['position'] = 'absolute'; + + if (!element) { + if (this.innerWidth <= 768) { + this.txBubbleArrowPosition = 'bottom'; + this.txBubbleStyle['left'] = window.innerWidth / 2 - 50 + 'px'; + this.txBubbleStyle['bottom'] = '270px'; + this.txBubbleStyle['top'] = 'inherit'; + this.txBubbleStyle['position'] = 'fixed'; + } else { + this.txBubbleStyle['left'] = window.innerWidth - 220 + 'px'; + this.txBubbleArrowPosition = 'right'; + this.txBubbleStyle['top'] = '425px'; + } + } else { + this.txBubbleArrowPosition = 'top'; + const domRect: DOMRect | ClientRect = element.getBoundingClientRect(); + this.txBubbleStyle['left'] = domRect.left - 50 + 'px'; + this.txBubbleStyle['top'] = domRect.top + 125 + window.scrollY + 'px'; + + if (domRect.left + 100 > window.innerWidth) { + this.txBubbleStyle['left'] = window.innerWidth - 220 + 'px'; + this.txBubbleArrowPosition = 'right'; + } else if (domRect.left + 220 > window.innerWidth) { + this.txBubbleStyle['left'] = window.innerWidth - 240 + 'px'; + this.txBubbleArrowPosition = 'top-right'; + } else { + this.txBubbleStyle['left'] = domRect.left + 15 + 'px'; + } + + if (domRect.left < 86) { + this.txBubbleArrowPosition = 'top-left'; + this.txBubbleStyle['left'] = 125 + 'px'; + } + } + } + + getTimeSinceMined(block: IBlock): string { + const minutes = ((new Date().getTime()) - (new Date(block.time * 1000).getTime())) / 1000 / 60; + if (minutes >= 120) { + return Math.floor(minutes / 60) + ' hours'; + } + if (minutes >= 60) { + return Math.floor(minutes / 60) + ' hour'; + } + if (minutes <= 1) { + return '< 1 minute'; + } + if (minutes === 1) { + return '1 minute'; + } + return Math.round(minutes) + ' minutes'; + } + + getStyleForBlock(block: IBlock) { + const greenBackgroundHeight = 100 - (block.weight / 4000000) * 100; + if (this.innerWidth <= 768) { + return { + 'top': 155 * this.blocks.indexOf(block) + 'px', + 'background': `repeating-linear-gradient(#2d3348, #2d3348 ${greenBackgroundHeight}%, + #9339f4 ${Math.max(greenBackgroundHeight, 0)}%, #105fb0 100%)`, + }; + } else { + return { + 'left': 155 * this.blocks.indexOf(block) + 'px', + 'background': `repeating-linear-gradient(#2d3348, #2d3348 ${greenBackgroundHeight}%, + #9339f4 ${Math.max(greenBackgroundHeight, 0)}%, #105fb0 100%)`, + }; + } + } + + getStyleForProjectedBlockAtIndex(index: number) { + const greenBackgroundHeight = 100 - (this.projectedBlocks[index].blockWeight / 4000000) * 100; + if (this.innerWidth <= 768) { + if (index === 3) { + return { + 'top': 40 + index * 155 + 'px' + }; + } + return { + 'top': 40 + index * 155 + 'px', + 'background': `repeating-linear-gradient(#554b45, #554b45 ${greenBackgroundHeight}%, + #bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`, + }; + } else { + if (index === 3) { + return { + 'right': 40 + index * 155 + 'px' + }; + } + return { + 'right': 40 + index * 155 + 'px', + 'background': `repeating-linear-gradient(#554b45, #554b45 ${greenBackgroundHeight}%, + #bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`, + }; + } + } + + trackByProjectedFn(index: number) { + return index; + } + + trackByBlocksFn(index: number, item: IBlock) { + return item.height; + } + + ngOnDestroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + this.renderer.removeClass(document.body, 'disable-scroll'); + } + + openBlockModal(block: IBlock) { + const modalRef = this.modalService.open(BlockModalComponent, { size: 'lg' }); + modalRef.componentInstance.block = block; + } + + openProjectedBlockModal(block: IBlock, index: number) { + const modalRef = this.modalService.open(ProjectedBlockModalComponent, { size: 'lg' }); + modalRef.componentInstance.block = block; + modalRef.componentInstance.index = index; + } +} diff --git a/frontend/src/app/blockchain/interfaces.ts b/frontend/src/app/blockchain/interfaces.ts new file mode 100644 index 000000000..99971d7bf --- /dev/null +++ b/frontend/src/app/blockchain/interfaces.ts @@ -0,0 +1,176 @@ +export interface IMempoolInfo { + size: number; + bytes: number; + usage: number; + maxmempool: number; + mempoolminfee: number; + minrelaytxfee: number; +} + +export interface IMempoolDefaultResponse { + mempoolInfo?: IMempoolInfo; + blocks?: IBlock[]; + block?: IBlock; + projectedBlocks?: IProjectedBlock[]; + txPerSecond?: number; + vBytesPerSecond: number; + 'track-tx'?: ITrackTx; + conversions?: any; +} + +export interface ITrackTx { + tx?: ITransaction; + blockHeight: number; + tracking: boolean; + message?: string; +} + +export interface IProjectedBlock { + blockSize: number; + blockWeight: number; + maxFee: number; + maxWeightFee: number; + medianFee: number; + minFee: number; + minWeightFee: number; + nTx: number; + hasMytx: boolean; +} + +export interface IStrippedBlock { + bits: number; + difficulty: number; + hash: string; + height: number; + nTx: number; + size: number; + strippedsize: number; + time: number; + weight: number; +} + +export interface ITransaction { + txid: string; + hash: string; + version: number; + size: number; + vsize: number; + locktime: number; + vin: Vin[]; + vout: Vout[]; + hex: string; + + fee: number; + feePerVsize: number; + feePerWeightUnit: number; +} + +export interface IBlock { + hash: string; + confirmations: number; + strippedsize: number; + size: number; + weight: number; + height: number; + version: number; + versionHex: string; + merkleroot: string; + tx: ITransaction[]; + time: number; + mediantime: number; + nonce: number; + bits: string; + difficulty: number; + chainwork: string; + nTx: number; + previousblockhash: string; + + minFee: number; + maxFee: number; + medianFee: number; + fees: number; +} + +interface ScriptSig { + asm: string; + hex: string; +} + +interface Vin { + txid: string; + vout: number; + scriptSig: ScriptSig; + sequence: number; +} + +interface ScriptPubKey { + asm: string; + hex: string; + reqSigs: number; + type: string; + addresses: string[]; +} + +interface Vout { + value: number; + n: number; + scriptPubKey: ScriptPubKey; +} + +export interface IMempoolStats { + id: number; + added: string; + unconfirmed_transactions: number; + tx_per_second: number; + vbytes_per_second: number; + mempool_byte_weight: number; + fee_data: IFeeData; + vsize_1: number; + vsize_2: number; + vsize_3: number; + vsize_4: number; + vsize_5: number; + vsize_6: number; + vsize_8: number; + vsize_10: number; + vsize_12: number; + vsize_15: number; + vsize_20: number; + vsize_30: number; + vsize_40: number; + vsize_50: number; + vsize_60: number; + vsize_70: number; + vsize_80: number; + vsize_90: number; + vsize_100: number; + vsize_125: number; + vsize_150: number; + vsize_175: number; + vsize_200: number; + vsize_250: number; + vsize_300: number; + vsize_350: number; + vsize_400: number; + vsize_500: number; + vsize_600: number; + vsize_700: number; + vsize_800: number; + vsize_900: number; + vsize_1000: number; + vsize_1200: number; + vsize_1400: number; + vsize_1600: number; + vsize_1800: number; + vsize_2000: number; +} + +export interface IBlockTransaction { + f: number; + fpv: number; +} + +interface IFeeData { + wu: { [ fee: string ]: number }; + vsize: { [ fee: string ]: number }; +} diff --git a/frontend/src/app/footer/footer.component.html b/frontend/src/app/footer/footer.component.html new file mode 100644 index 000000000..8cc174093 --- /dev/null +++ b/frontend/src/app/footer/footer.component.html @@ -0,0 +1,18 @@ +
+
+
+
+ Unconfirmed transactions: {{ memPoolInfo?.memPoolInfo?.size | number }} ({{ mempoolBlocks }} blocks) +
+ Tx per second: {{ memPoolInfo?.txPerSecond | number : '1.2-2' }} tx/s +
+ Tx weight per second:  + +
+
{{ memPoolInfo?.vBytesPerSecond | ceil | number }} vBytes/s
+
+ +
+
+
+
diff --git a/frontend/src/app/footer/footer.component.scss b/frontend/src/app/footer/footer.component.scss new file mode 100644 index 000000000..cc37ebdbe --- /dev/null +++ b/frontend/src/app/footer/footer.component.scss @@ -0,0 +1,42 @@ +.footer { + position: fixed; + bottom: 0; + width: 100%; + height: 120px; + background-color: #1d1f31; +} + +.footer > .container { + margin-top: 25px; +} + +.txPerSecond { + color: #4a9ff4; +} + +.mempoolSize { + color: #4a68b9; +} + +.unconfirmedTx { + color: #f14d80; +} + +.info-block { + float:left; +} + +.progress { + display: inline-flex; + width: 150px; + background-color: #2d3348; + height: 1.1rem; +} + +.progress-bar { + padding: 4px; +} + +.bg-warning { + background-color: #b58800 !important; +} diff --git a/frontend/src/app/footer/footer.component.ts b/frontend/src/app/footer/footer.component.ts new file mode 100644 index 000000000..898995f30 --- /dev/null +++ b/frontend/src/app/footer/footer.component.ts @@ -0,0 +1,54 @@ +import { Component, OnInit } from '@angular/core'; +import { MemPoolService, MemPoolState } from '../services/mem-pool.service'; + +@Component({ + selector: 'app-footer', + templateUrl: './footer.component.html', + styleUrls: ['./footer.component.scss'] +}) +export class FooterComponent implements OnInit { + memPoolInfo: MemPoolState | undefined; + mempoolBlocks = 0; + progressWidth = ''; + progressClass: string; + + constructor( + private memPoolService: MemPoolService + ) { } + + ngOnInit() { + this.memPoolService.loaderSubject + .subscribe((mempoolState) => { + this.memPoolInfo = mempoolState; + this.updateProgress(); + }); + this.memPoolService.mempoolWeight + .subscribe((mempoolWeight) => { + this.mempoolBlocks = Math.ceil(mempoolWeight / 4000000); + }); + } + + updateProgress() { + if (!this.memPoolInfo) { + return; + } + + const vBytesPerSecondLimit = 1667; + + let vBytesPerSecond = this.memPoolInfo.vBytesPerSecond; + if (vBytesPerSecond > 1667) { + vBytesPerSecond = 1667; + } + + const percent = Math.round((vBytesPerSecond / vBytesPerSecondLimit) * 100); + this.progressWidth = percent + '%'; + + if (percent <= 75) { + this.progressClass = 'bg-success'; + } else if (percent <= 99) { + this.progressClass = 'bg-warning'; + } else { + this.progressClass = 'bg-danger'; + } + } +} diff --git a/frontend/src/app/projected-block-modal/projected-block-modal.component.html b/frontend/src/app/projected-block-modal/projected-block-modal.component.html new file mode 100644 index 000000000..add57bbe9 --- /dev/null +++ b/frontend/src/app/projected-block-modal/projected-block-modal.component.html @@ -0,0 +1,41 @@ + + diff --git a/frontend/src/app/projected-block-modal/projected-block-modal.component.scss b/frontend/src/app/projected-block-modal/projected-block-modal.component.scss new file mode 100644 index 000000000..75d226682 --- /dev/null +++ b/frontend/src/app/projected-block-modal/projected-block-modal.component.scss @@ -0,0 +1,7 @@ +.yellow-color { + color: #ffd800; +} + +.green-color { + color: #3bcc49; +} diff --git a/frontend/src/app/projected-block-modal/projected-block-modal.component.ts b/frontend/src/app/projected-block-modal/projected-block-modal.component.ts new file mode 100644 index 000000000..b0d280aa7 --- /dev/null +++ b/frontend/src/app/projected-block-modal/projected-block-modal.component.ts @@ -0,0 +1,74 @@ +import { Component, OnInit, Input } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ApiService } from '../services/api.service'; +import { IBlock } from '../blockchain/interfaces'; +import { MemPoolService } from '../services/mem-pool.service'; +import * as Chartist from 'chartist'; + +@Component({ + selector: 'app-projected-block-modal', + templateUrl: './projected-block-modal.component.html', + styleUrls: ['./projected-block-modal.component.scss'] +}) +export class ProjectedBlockModalComponent implements OnInit { + @Input() block: IBlock; + @Input() index: number; + + mempoolVsizeFeesData: any; + mempoolVsizeFeesOptions: any; + conversions: any; + + constructor( + public activeModal: NgbActiveModal, + private apiService: ApiService, + private memPoolService: MemPoolService, + ) { } + + ngOnInit() { + + this.mempoolVsizeFeesOptions = { + showArea: false, + showLine: false, + fullWidth: false, + showPoint: false, + low: 0, + axisX: { + position: 'start', + showLabel: false, + offset: 0, + showGrid: false, + }, + axisY: { + position: 'end', + scaleMinSpace: 40, + showGrid: false, + }, + plugins: [ + Chartist.plugins.tooltip({ + tooltipOffset: { + x: 15, + y: 250 + }, + transformTooltipTextFnc: (value: number): any => { + return Math.ceil(value) + ' sat/vB'; + }, + anchorToPoint: false, + }) + ] + }; + + this.memPoolService.conversions + .subscribe((conversions) => { + this.conversions = conversions; + }); + + this.apiService.listTransactionsForProjectedBlock$(this.index) + .subscribe((data) => { + this.mempoolVsizeFeesData = { + labels: data.map((x, i) => i), + series: [data.map((tx) => tx.fpv)] + }; + }); + } + +} diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts new file mode 100644 index 000000000..44200a43d --- /dev/null +++ b/frontend/src/app/services/api.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; +import { environment } from '../../environments/environment'; +import { webSocket } from 'rxjs/webSocket'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { IMempoolDefaultResponse, IMempoolStats, IBlockTransaction } from '../blockchain/interfaces'; +import { Observable } from 'rxjs'; + + +let WEB_SOCKET_URL = 'wss://mempool.space:8999'; +let API_BASE_URL = 'https://mempool.space:8999/api/v1'; + +if (!environment.production) { + WEB_SOCKET_URL = 'ws://localhost:8999'; + API_BASE_URL = '/api/v1'; +} + +@Injectable({ + providedIn: 'root' +}) +export class ApiService { + constructor( + private httpClient: HttpClient, + ) { } + + websocketSubject = webSocket(WEB_SOCKET_URL); + + listTransactionsForBlock$(height: number): Observable { + return this.httpClient.get(API_BASE_URL + '/transactions/height/' + height); + } + + listTransactionsForProjectedBlock$(index: number): Observable { + return this.httpClient.get(API_BASE_URL + '/transactions/projected/' + index); + } + + listLiveStatistics$(lastId: number): Observable { + const params = new HttpParams() + .set('lastId', lastId.toString()); + + return this.httpClient.get(API_BASE_URL + '/statistics/live', { + params: params + }); + } + + list2HStatistics$(): Observable { + return this.httpClient.get(API_BASE_URL + '/statistics/2h'); + } + + list24HStatistics$(): Observable { + return this.httpClient.get(API_BASE_URL + '/statistics/24h'); + } + + list1WStatistics$(): Observable { + return this.httpClient.get(API_BASE_URL + '/statistics/1w'); + } + + list1MStatistics$(): Observable { + return this.httpClient.get(API_BASE_URL + '/statistics/1m'); + } + + list3MStatistics$(): Observable { + return this.httpClient.get(API_BASE_URL + '/statistics/3m'); + } + + list6MStatistics$(): Observable { + return this.httpClient.get(API_BASE_URL + '/statistics/6m'); + } + +} diff --git a/frontend/src/app/services/mem-pool.service.ts b/frontend/src/app/services/mem-pool.service.ts new file mode 100644 index 000000000..b792a6cda --- /dev/null +++ b/frontend/src/app/services/mem-pool.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { Subject, ReplaySubject } from 'rxjs'; +import { IMempoolInfo } from '../blockchain/interfaces'; + +export interface MemPoolState { + memPoolInfo: IMempoolInfo; + txPerSecond: number; + vBytesPerSecond: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class MemPoolService { + loaderSubject = new Subject(); + isOffline = new Subject(); + txIdSearch = new Subject(); + conversions = new ReplaySubject(); + mempoolWeight = new Subject(); +} diff --git a/frontend/src/app/shared/pipes/bytes-pipe/bytes.pipe.ts b/frontend/src/app/shared/pipes/bytes-pipe/bytes.pipe.ts new file mode 100644 index 000000000..b961669fc --- /dev/null +++ b/frontend/src/app/shared/pipes/bytes-pipe/bytes.pipe.ts @@ -0,0 +1,63 @@ +/* tslint:disable */ +import { Pipe, PipeTransform } from '@angular/core'; +import { isNumberFinite, isPositive, isInteger, toDecimal } from './utils'; + +export type ByteUnit = 'B' | 'kB' | 'MB' | 'GB' | 'TB'; + +@Pipe({ + name: 'bytes' +}) +export class BytesPipe implements PipeTransform { + + static formats: { [key: string]: { max: number, prev?: ByteUnit } } = { + 'B': {max: 1000}, + 'kB': {max: Math.pow(1000, 2), prev: 'B'}, + 'MB': {max: Math.pow(1000, 3), prev: 'kB'}, + 'GB': {max: Math.pow(1000, 4), prev: 'MB'}, + 'TB': {max: Number.MAX_SAFE_INTEGER, prev: 'GB'} + }; + + transform(input: any, decimal: number = 0, from: ByteUnit = 'B', to?: ByteUnit): any { + + if (!(isNumberFinite(input) && + isNumberFinite(decimal) && + isInteger(decimal) && + isPositive(decimal))) { + return input; + } + + let bytes = input; + let unit = from; + while (unit !== 'B') { + bytes *= 1024; + unit = BytesPipe.formats[unit].prev!; + } + + if (to) { + const format = BytesPipe.formats[to]; + + const result = toDecimal(BytesPipe.calculateResult(format, bytes), decimal); + + return BytesPipe.formatResult(result, to); + } + + for (const key in BytesPipe.formats) { + const format = BytesPipe.formats[key]; + if (bytes < format.max) { + + const result = toDecimal(BytesPipe.calculateResult(format, bytes), decimal); + + return BytesPipe.formatResult(result, key); + } + } + } + + static formatResult(result: number, unit: string): string { + return `${result} ${unit}`; + } + + static calculateResult(format: { max: number, prev?: ByteUnit }, bytes: number) { + const prev = format.prev ? BytesPipe.formats[format.prev] : undefined; + return prev ? bytes / prev.max : bytes; + } +} diff --git a/frontend/src/app/shared/pipes/bytes-pipe/utils.ts b/frontend/src/app/shared/pipes/bytes-pipe/utils.ts new file mode 100644 index 000000000..fc8c2b08f --- /dev/null +++ b/frontend/src/app/shared/pipes/bytes-pipe/utils.ts @@ -0,0 +1,311 @@ +/* tslint:disable */ + +export type CollectionPredicate = (item?: any, index?: number, collection?: any[]) => boolean; + +export function isUndefined(value: any): value is undefined { + + return typeof value === 'undefined'; +} + +export function isNull(value: any): value is null { + return value === null; +} + +export function isNumber(value: any): value is number { + return typeof value === 'number'; +} + +export function isNumberFinite(value: any): value is number { + return isNumber(value) && isFinite(value); +} + +// Not strict positive +export function isPositive(value: number): boolean { + return value >= 0; +} + + +export function isInteger(value: number): boolean { + // No rest, is an integer + return (value % 1) === 0; +} + +export function isNil(value: any): value is (null | undefined) { + return value === null || typeof (value) === 'undefined'; +} + +export function isString(value: any): value is string { + return typeof value === 'string'; +} + +export function isObject(value: any): boolean { + return value !== null && typeof value === 'object'; +} + +export function isArray(value: any): boolean { + return Array.isArray(value); +} + +export function isFunction(value: any): boolean { + return typeof value === 'function'; +} + +export function toDecimal(value: number, decimal: number): number { + return Math.round(value * Math.pow(10, decimal)) / Math.pow(10, decimal); +} + +export function upperFirst(value: string): string { + return value.slice(0, 1).toUpperCase() + value.slice(1); +} + +export function createRound(method: string): Function { + // Math to suppress error + const func: any = (Math)[method]; + return function (value: number, precision: number = 0) { + if (typeof value === 'string') { + throw new TypeError('Rounding method needs a number'); + } + if (typeof precision !== 'number' || isNaN(precision)) { + precision = 0; + } + if (precision) { + let pair = `${value}e`.split('e'); + const val = func(`${pair[0]}e` + (+pair[1] + precision)); + pair = `${val}e`.split('e'); + return +(pair[0] + 'e' + (+pair[1] - precision)); + } + return func(value); + }; +} + +export function leftPad(str: string, len: number = 0, ch: any = ' ') { + str = String(str); + ch = toString(ch); + let i = -1; + const length = len - str.length; + while (++i < length && (str.length + ch.length) <= len) { + str = ch + str; + } + return str; +} + +export function rightPad(str: string, len: number = 0, ch: any = ' ') { + str = String(str); + ch = toString(ch); + let i = -1; + const length = len - str.length; + while (++i < length && (str.length + ch.length) <= len) { + str += ch; + } + return str; +} + +export function toString(value: number | string) { + return `${value}`; +} + +export function pad(str: string, len: number = 0, ch: any = ' '): string { + str = String(str); + ch = toString(ch); + let i = -1; + const length = len - str.length; + + let left = true; + while (++i < length) { + const l = (str.length + ch.length <= len) ? (str.length + ch.length) : (str.length + 1); + if (left) { + str = leftPad(str, l, ch); + } else { + str = rightPad(str, l, ch); + } + left = !left; + } + return str; +} + +export function flatten(input: any[], index: number = 0): any[] { + + if (index >= input.length) { + return input; + } + + if (isArray(input[index])) { + return flatten( + input.slice(0, index).concat(input[index], input.slice(index + 1)), + index + ); + } + + return flatten(input, index + 1); + +} + + +export function getProperty(value: { [key: string]: any }, key: string): any { + + if (isNil(value) || !isObject(value)) { + return undefined; + } + + const keys: string[] = key.split('.'); + let result: any = value[keys.shift()!]; + + for (const kk of keys) { + if (isNil(result) || !isObject(result)) { + return undefined; + } + + result = result[kk]; + } + + return result; +} + +export function sum(input: Array, initial = 0): number { + + return input.reduce((previous: number, current: number) => previous + current, initial); +} + +// http://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array-in-javascript +export function shuffle(input: any): any { + + if (!isArray(input)) { + return input; + } + + const copy = [...input]; + + for (let i = copy.length; i; --i) { + const j = Math.floor(Math.random() * i); + const x = copy[i - 1]; + copy[i - 1] = copy[j]; + copy[j] = x; + } + + return copy; +} + +export function deepIndexOf(collection: any[], value: any) { + + let index = -1; + const length = collection.length; + + while (++index < length) { + if (deepEqual(value, collection[index])) { + return index; + } + } + + return -1; +} + + +export function deepEqual(a: any, b: any) { + + if (a === b) { + return true; + } + + if (!(typeof a === 'object' && typeof b === 'object')) { + return a === b; + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) { + return false; + } + + // Test for A's keys different from B. + const hasOwn = Object.prototype.hasOwnProperty; + for (let i = 0; i < keysA.length; i++) { + const key = keysA[i]; + if (!hasOwn.call(b, keysA[i]) || !deepEqual(a[key], b[key])) { + return false; + } + } + + return true; +} + +export function isDeepObject(object: any) { + + return object.__isDeepObject__; +} + +export function wrapDeep(object: any) { + + return new DeepWrapper(object); +} + +export function unwrapDeep(object: any) { + + if (isDeepObject(object)) { + return object.data; + } + + return object; +} + +export class DeepWrapper { + + public __isDeepObject__ = true; + + constructor(public data: any) { } +} + +export function count(input: any): any { + + if (!isArray(input) && !isObject(input) && !isString(input)) { + return input; + } + + if (isObject(input)) { + return Object.keys(input).map((value) => input[value]).length; + } + + return input.length; +} + +export function empty(input: any): any { + + if (!isArray(input)) { + return input; + } + + return input.length === 0; +} + +export function every(input: any, predicate: CollectionPredicate) { + + if (!isArray(input) || !predicate) { + return input; + } + + let result = true; + let i = -1; + + while (++i < input.length && result) { + result = predicate(input[i], i, input); + } + + + return result; +} + +export function takeUntil(input: any[], predicate: CollectionPredicate) { + + let i = -1; + const result: any = []; + while (++i < input.length && !predicate(input[i], i, input)) { + result[i] = input[i]; + } + + return result; +} + +export function takeWhile(input: any[], predicate: CollectionPredicate) { + return takeUntil(input, (item: any, index: number | undefined, collection: any[] | undefined) => + !predicate(item, index, collection)); +} diff --git a/frontend/src/app/shared/pipes/math-ceil/math-ceil.pipe.ts b/frontend/src/app/shared/pipes/math-ceil/math-ceil.pipe.ts new file mode 100644 index 000000000..f4babc545 --- /dev/null +++ b/frontend/src/app/shared/pipes/math-ceil/math-ceil.pipe.ts @@ -0,0 +1,8 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ name: 'ceil' }) +export class CeilPipe implements PipeTransform { + transform(nr: number) { + return Math.ceil(nr); + } +} diff --git a/frontend/src/app/shared/pipes/math-round-pipe/math-round.pipe.ts b/frontend/src/app/shared/pipes/math-round-pipe/math-round.pipe.ts new file mode 100644 index 000000000..f8b402daa --- /dev/null +++ b/frontend/src/app/shared/pipes/math-round-pipe/math-round.pipe.ts @@ -0,0 +1,8 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ name: 'round' }) +export class RoundPipe implements PipeTransform { + transform(nr: number) { + return Math.round(nr); + } +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts new file mode 100644 index 000000000..938dcf9f0 --- /dev/null +++ b/frontend/src/app/shared/shared.module.ts @@ -0,0 +1,34 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgbButtonsModule, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; + +import { BytesPipe } from './pipes/bytes-pipe/bytes.pipe'; +import { RoundPipe } from './pipes/math-round-pipe/math-round.pipe'; +import { CeilPipe } from './pipes/math-ceil/math-ceil.pipe'; +import { ChartistComponent } from '../statistics/chartist.component'; + +@NgModule({ + imports: [ + CommonModule, + NgbButtonsModule.forRoot(), + NgbModalModule.forRoot(), + ], + declarations: [ + ChartistComponent, + RoundPipe, + CeilPipe, + BytesPipe, + ], + exports: [ + RoundPipe, + CeilPipe, + BytesPipe, + NgbButtonsModule, + NgbModalModule, + ChartistComponent, + ], + providers: [ + BytesPipe + ] +}) +export class SharedModule { } diff --git a/frontend/src/app/statistics/chartist.component.scss b/frontend/src/app/statistics/chartist.component.scss new file mode 100644 index 000000000..62885776b --- /dev/null +++ b/frontend/src/app/statistics/chartist.component.scss @@ -0,0 +1,72 @@ +@import "../../styles.scss"; + +.ct-bar-label { + font-size: 20px; + font-weight: bold; + fill: #fff; +} + +.ct-target-line { + stroke: #f5f5f5; + stroke-width: 3px; + stroke-dasharray: 7px; +} + +.ct-area { + stroke: none; + fill-opacity: 0.9; +} + +.ct-label { + fill: rgba(255, 255, 255, 0.4); + color: rgba(255, 255, 255, 0.4); +} + +.ct-grid { + stroke: rgba(255, 255, 255, 0.2); +} + +/* LEGEND */ + +.ct-legend { + position: absolute; + z-index: 10; + left: 0px; + list-style: none; + font-size: 13px; + padding: 0px 0px 0px 30px; + top: 90px; + + li { + position: relative; + padding-left: 23px; + margin-bottom: 0px; + } + + li:before { + width: 12px; + height: 12px; + position: absolute; + left: 0; + content: ''; + border: 3px solid transparent; + border-radius: 2px; + } + + li.inactive:before { + background: transparent; + } + + &.ct-legend-inside { + position: absolute; + top: 0; + right: 0; + } + + @for $i from 0 to length($ct-series-colors) { + .ct-series-#{$i}:before { + background-color: nth($ct-series-colors, $i + 1); + border-color: nth($ct-series-colors, $i + 1); + } + } +} \ No newline at end of file diff --git a/frontend/src/app/statistics/chartist.component.ts b/frontend/src/app/statistics/chartist.component.ts new file mode 100644 index 000000000..d483395e6 --- /dev/null +++ b/frontend/src/app/statistics/chartist.component.ts @@ -0,0 +1,657 @@ +import { + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + ViewEncapsulation +} from '@angular/core'; + +import * as Chartist from 'chartist'; + +/** + * Possible chart types + * @type {String} + */ +export type ChartType = 'Pie' | 'Bar' | 'Line'; + +export type ChartInterfaces = + | Chartist.IChartistPieChart + | Chartist.IChartistBarChart + | Chartist.IChartistLineChart; +export type ChartOptions = + | Chartist.IBarChartOptions + | Chartist.ILineChartOptions + | Chartist.IPieChartOptions; +export type ResponsiveOptionTuple = Chartist.IResponsiveOptionTuple< + ChartOptions +>; +export type ResponsiveOptions = ResponsiveOptionTuple[]; + +/** + * Represent a chart event. + * For possible values, check the Chartist docs. + */ +export interface ChartEvent { + [eventName: string]: (data: any) => void; +} + +@Component({ + selector: 'app-chartist', + template: '', + styleUrls: ['./chartist.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class ChartistComponent implements OnInit, OnChanges, OnDestroy { + @Input() + // @ts-ignore + public data: Promise | Chartist.IChartistData; + + // @ts-ignore + @Input() public type: Promise | ChartType; + + @Input() + // @ts-ignore + public options: Promise | Chartist.IChartOptions; + + @Input() + // @ts-ignore + public responsiveOptions: Promise | ResponsiveOptions; + + // @ts-ignore + @Input() public events: ChartEvent; + + // @ts-ignore + public chart: ChartInterfaces; + + private element: HTMLElement; + + constructor(element: ElementRef) { + this.element = element.nativeElement; + } + + public ngOnInit(): Promise { + if (!this.type || !this.data) { + Promise.reject('Expected at least type and data.'); + } + + return this.renderChart().then((chart) => { + if (this.events !== undefined) { + this.bindEvents(chart); + } + + return chart; + }); + } + + public ngOnChanges(changes: SimpleChanges): void { + this.update(changes); + } + + public ngOnDestroy(): void { + if (this.chart) { + this.chart.detach(); + } + } + + public renderChart(): Promise { + const promises: any[] = [ + this.type, + this.element, + this.data, + this.options, + this.responsiveOptions + ]; + + return Promise.all(promises).then((values) => { + const [type, ...args]: any = values; + + if (!(type in Chartist)) { + throw new Error(`${type} is not a valid chart type`); + } + + this.chart = (Chartist as any)[type](...args); + + return this.chart; + }); + } + + public update(changes: SimpleChanges): void { + if (!this.chart || 'type' in changes) { + this.renderChart(); + } else { + if (changes.data) { + this.data = changes.data.currentValue; + } + + if (changes.options) { + this.options = changes.options.currentValue; + } + + (this.chart as any).update(this.data, this.options); + } + } + + public bindEvents(chart: any): void { + for (const event of Object.keys(this.events)) { + chart.on(event, this.events[event]); + } + } +} + +/** + * Chartist.js plugin to display a "target" or "goal" line across the chart. + * Only tested with bar charts. Works for horizontal and vertical bars. + */ +(function(window, document, Chartist) { + 'use strict'; + + const defaultOptions = { + // The class name so you can style the text + className: 'ct-target-line', + // The axis to draw the line. y == vertical bars, x == horizontal + axis: 'y', + // What value the target line should be drawn at + value: null + }; + + Chartist.plugins = Chartist.plugins || {}; + + Chartist.plugins.ctTargetLine = function (options: any) { + options = Chartist.extend({}, defaultOptions, options); + return function ctTargetLine (chart: any) { + + chart.on('created', function(context: any) { + const projectTarget = { + y: function (chartRect: any, bounds: any, value: any) { + const targetLineY = chartRect.y1 - (chartRect.height() / bounds.max * value); + + return { + x1: chartRect.x1, + x2: chartRect.x2, + y1: targetLineY, + y2: targetLineY + }; + }, + x: function (chartRect: any, bounds: any, value: any) { + const targetLineX = chartRect.x1 + (chartRect.width() / bounds.max * value); + + return { + x1: targetLineX, + x2: targetLineX, + y1: chartRect.y1, + y2: chartRect.y2 + }; + } + }; + // @ts-ignore + const targetLine = projectTarget[options.axis](context.chartRect, context.bounds, options.value); + + context.svg.elem('line', targetLine, options.className); + }); + }; + }; + +}(window, document, Chartist)); + + +/** + * Chartist.js plugin to display a data label on top of the points in a line chart. + * + */ +/* global Chartist */ +(function(window, document, Chartist) { + 'use strict'; + + const defaultOptions = { + labelClass: 'ct-label', + labelOffset: { + x: 0, + y: -10 + }, + textAnchor: 'middle', + align: 'center', + labelInterpolationFnc: Chartist.noop + }; + + const labelPositionCalculation = { + point: function(data: any) { + return { + x: data.x, + y: data.y + }; + }, + bar: { + left: function(data: any) { + return { + x: data.x1, + y: data.y1 + }; + }, + center: function(data: any) { + return { + x: data.x1 + (data.x2 - data.x1) / 2, + y: data.y1 + }; + }, + right: function(data: any) { + return { + x: data.x2, + y: data.y1 + }; + } + } + }; + + Chartist.plugins = Chartist.plugins || {}; + Chartist.plugins.ctPointLabels = function(options: any) { + + options = Chartist.extend({}, defaultOptions, options); + + function addLabel(position: any, data: any) { + // if x and y exist concat them otherwise output only the existing value + const value = data.value.x !== undefined && data.value.y ? + (data.value.x + ', ' + data.value.y) : + data.value.y || data.value.x; + + data.group.elem('text', { + x: position.x + options.labelOffset.x, + y: position.y + options.labelOffset.y, + style: 'text-anchor: ' + options.textAnchor + }, options.labelClass).text(options.labelInterpolationFnc(value)); + } + + return function ctPointLabels(chart: any) { + if (chart instanceof Chartist.Line || chart instanceof Chartist.Bar) { + chart.on('draw', function(data: any) { + // @ts-ignore + const positonCalculator = labelPositionCalculation[data.type] + // @ts-ignore + && labelPositionCalculation[data.type][options.align] || labelPositionCalculation[data.type]; + if (positonCalculator) { + addLabel(positonCalculator(data), data); + } + }); + } + }; + }; + +}(window, document, Chartist)); + +const defaultOptions = { + className: '', + classNames: false, + removeAll: false, + legendNames: false, + clickable: true, + onClick: null, + position: 'top' +}; + +Chartist.plugins.legend = function (options: any) { + let cachedDOMPosition; + // Catch invalid options + if (options && options.position) { + if (!(options.position === 'top' || options.position === 'bottom' || options.position instanceof HTMLElement)) { + throw Error('The position you entered is not a valid position'); + } + if (options.position instanceof HTMLElement) { + // Detatch DOM element from options object, because Chartist.extend + // currently chokes on circular references present in HTMLElements + cachedDOMPosition = options.position; + delete options.position; + } + } + + options = Chartist.extend({}, defaultOptions, options); + + if (cachedDOMPosition) { + // Reattatch the DOM Element position if it was removed before + options.position = cachedDOMPosition; + } + + return function legend(chart: any) { + + function removeLegendElement() { + const legendElement = chart.container.querySelector('.ct-legend'); + if (legendElement) { + legendElement.parentNode.removeChild(legendElement); + } + } + + // Set a unique className for each series so that when a series is removed, + // the other series still have the same color. + function setSeriesClassNames() { + chart.data.series = chart.data.series.map(function (series: any, seriesIndex: any) { + if (typeof series !== 'object') { + series = { + value: series + }; + } + series.className = series.className || chart.options.classNames.series + '-' + Chartist.alphaNumerate(seriesIndex); + return series; + }); + } + + function createLegendElement() { + const legendElement = document.createElement('ul'); + legendElement.className = 'ct-legend'; + if (chart instanceof Chartist.Pie) { + legendElement.classList.add('ct-legend-inside'); + } + if (typeof options.className === 'string' && options.className.length > 0) { + legendElement.classList.add(options.className); + } + if (chart.options.width) { + legendElement.style.cssText = 'width: ' + chart.options.width + 'px;margin: 0 auto;'; + } + return legendElement; + } + + // Get the right array to use for generating the legend. + function getLegendNames(useLabels: any) { + return options.legendNames || (useLabels ? chart.data.labels : chart.data.series); + } + + // Initialize the array that associates series with legends. + // -1 indicates that there is no legend associated with it. + function initSeriesMetadata(useLabels: any) { + const seriesMetadata = new Array(chart.data.series.length); + for (let i = 0; i < chart.data.series.length; i++) { + seriesMetadata[i] = { + data: chart.data.series[i], + label: useLabels ? chart.data.labels[i] : null, + legend: -1 + }; + } + return seriesMetadata; + } + + function createNameElement(i: any, legendText: any, classNamesViable: any) { + const li = document.createElement('li'); + li.classList.add('ct-series-' + i); + // Append specific class to a legend element, if viable classes are given + if (classNamesViable) { + li.classList.add(options.classNames[i]); + } + li.setAttribute('data-legend', i); + li.textContent = legendText; + return li; + } + + // Append the legend element to the DOM + function appendLegendToDOM(legendElement: any) { + if (!(options.position instanceof HTMLElement)) { + switch (options.position) { + case 'top': + chart.container.insertBefore(legendElement, chart.container.childNodes[0]); + break; + + case 'bottom': + chart.container.insertBefore(legendElement, null); + break; + } + } else { + // Appends the legend element as the last child of a given HTMLElement + options.position.insertBefore(legendElement, null); + } + } + + function addClickHandler(legendElement: any, legends: any, seriesMetadata: any, useLabels: any) { + legendElement.addEventListener('click', function(e: any) { + const li = e.target; + if (li.parentNode !== legendElement || !li.hasAttribute('data-legend')) + return; + e.preventDefault(); + + const legendIndex = parseInt(li.getAttribute('data-legend')); + const legend = legends[legendIndex]; + + if (!legend.active) { + legend.active = true; + li.classList.remove('inactive'); + } else { + legend.active = false; + li.classList.add('inactive'); + + const activeCount = legends.filter(function(legend: any) { return legend.active; }).length; + if (!options.removeAll && activeCount == 0) { + // If we can't disable all series at the same time, let's + // reenable all of them: + for (let i = 0; i < legends.length; i++) { + legends[i].active = true; + legendElement.childNodes[i].classList.remove('inactive'); + } + } + } + + const newSeries = []; + const newLabels = []; + + for (let i = 0; i < seriesMetadata.length; i++) { + if (seriesMetadata[i].legend !== -1 && legends[seriesMetadata[i].legend].active) { + newSeries.push(seriesMetadata[i].data); + newLabels.push(seriesMetadata[i].label); + } + } + + chart.data.series = newSeries; + if (useLabels) { + chart.data.labels = newLabels; + } + + chart.update(); + + if (options.onClick) { + options.onClick(chart, e); + } + }); + } + + removeLegendElement(); + + const legendElement = createLegendElement(); + const useLabels = chart instanceof Chartist.Pie && chart.data.labels && chart.data.labels.length; + const legendNames = getLegendNames(useLabels); + const seriesMetadata = initSeriesMetadata(useLabels); + const legends: any = []; + + // Check if given class names are viable to append to legends + const classNamesViable = Array.isArray(options.classNames) && options.classNames.length === legendNames.length; + + // Loop through all legends to set each name in a list item. + legendNames.forEach(function (legend: any, i: any) { + const legendText = legend.name || legend; + const legendSeries = legend.series || [i]; + + const li = createNameElement(i, legendText, classNamesViable); + legendElement.appendChild(li); + + legendSeries.forEach(function(seriesIndex: any) { + seriesMetadata[seriesIndex].legend = i; + }); + + legends.push({ + text: legendText, + series: legendSeries, + active: true + }); + }); + + chart.on('created', function (data: any) { + appendLegendToDOM(legendElement); + }); + + if (options.clickable) { + setSeriesClassNames(); + addClickHandler(legendElement, legends, seriesMetadata, useLabels); + } + }; +}; + +Chartist.plugins.tooltip = function (options: any) { + options = Chartist.extend({}, defaultOptions, options); + + return function tooltip(chart: any) { + let tooltipSelector = options.pointClass; + if (chart.constructor.name === Chartist.Bar.prototype.constructor.name) { + tooltipSelector = 'ct-bar'; + } else if (chart.constructor.name === Chartist.Pie.prototype.constructor.name) { + // Added support for donut graph + if (chart.options.donut) { + tooltipSelector = 'ct-slice-donut'; + } else { + tooltipSelector = 'ct-slice-pie'; + } + } + + const $chart = chart.container; + let $toolTip = $chart.querySelector('.chartist-tooltip'); + if (!$toolTip) { + $toolTip = document.createElement('div'); + $toolTip.className = (!options.class) ? 'chartist-tooltip' : 'chartist-tooltip ' + options.class; + if (!options.appendToBody) { + $chart.appendChild($toolTip); + } else { + document.body.appendChild($toolTip); + } + } + let height = $toolTip.offsetHeight; + let width = $toolTip.offsetWidth; + + hide($toolTip); + + function on(event: any, selector: any, callback: any) { + $chart.addEventListener(event, function (e: any) { + if (!selector || hasClass(e.target, selector)) { + callback(e); + } + }); + } + + on('mouseover', tooltipSelector, function (event: any) { + const $point = event.target; + let tooltipText = ''; + + const isPieChart = (chart instanceof Chartist.Pie) ? $point : $point.parentNode; + const seriesName = (isPieChart) ? $point.parentNode.getAttribute('ct:meta') || $point.parentNode.getAttribute('ct:series-name') : ''; + let meta = $point.getAttribute('ct:meta') || seriesName || ''; + const hasMeta = !!meta; + let value = $point.getAttribute('ct:value'); + + if (options.transformTooltipTextFnc && typeof options.transformTooltipTextFnc === 'function') { + value = options.transformTooltipTextFnc(value); + } + + if (options.tooltipFnc && typeof options.tooltipFnc === 'function') { + tooltipText = options.tooltipFnc(meta, value); + } else { + if (options.metaIsHTML) { + const txt = document.createElement('textarea'); + txt.innerHTML = meta; + meta = txt.value; + } + + meta = '' + meta + ''; + + if (hasMeta) { + tooltipText += meta + '
'; + } else { + // For Pie Charts also take the labels into account + // Could add support for more charts here as well! + if (chart instanceof Chartist.Pie) { + const label = next($point, 'ct-label'); + if (label) { + tooltipText += text(label) + '
'; + } + } + } + + if (value) { + if (options.currency) { + if (options.currencyFormatCallback != undefined) { + value = options.currencyFormatCallback(value, options); + } else { + value = options.currency + value.replace(/(\d)(?=(\d{3})+(?:\.\d+)?$)/g, '$1,'); + } + } + value = '' + value + ''; + tooltipText += value; + } + } + + if (tooltipText) { + $toolTip.innerHTML = tooltipText; + setPosition(event); + show($toolTip); + + // Remember height and width to avoid wrong position in IE + height = $toolTip.offsetHeight; + width = $toolTip.offsetWidth; + } + }); + + on('mouseout', tooltipSelector, function () { + hide($toolTip); + }); + + on('mousemove', null, function (event: any) { + if (false === options.anchorToPoint) { + setPosition(event); + } + }); + + function setPosition(event: any) { + height = height || $toolTip.offsetHeight; + width = width || $toolTip.offsetWidth; + const offsetX = - width / 2 + options.tooltipOffset.x + const offsetY = - height + options.tooltipOffset.y; + let anchorX, anchorY; + + if (!options.appendToBody) { + const box = $chart.getBoundingClientRect(); + const left = event.pageX - box.left - window.pageXOffset ; + const top = event.pageY - box.top - window.pageYOffset ; + + if (true === options.anchorToPoint && event.target.x2 && event.target.y2) { + anchorX = parseInt(event.target.x2.baseVal.value); + anchorY = parseInt(event.target.y2.baseVal.value); + } + + $toolTip.style.top = (anchorY || top) + offsetY + 'px'; + $toolTip.style.left = (anchorX || left) + offsetX + 'px'; + } else { + $toolTip.style.top = event.pageY + offsetY + 'px'; + $toolTip.style.left = event.pageX + offsetX + 'px'; + } + } + } +}; + +function show(element: any) { + if (!hasClass(element, 'tooltip-show')) { + element.className = element.className + ' tooltip-show'; + } +} + +function hide(element: any) { + const regex = new RegExp('tooltip-show' + '\\s*', 'gi'); + element.className = element.className.replace(regex, '').trim(); +} + +function hasClass(element: any, className: any) { + return (' ' + element.getAttribute('class') + ' ').indexOf(' ' + className + ' ') > -1; +} + +function next(element: any, className: any) { + do { + element = element.nextSibling; + } while (element && !hasClass(element, className)); + return element; +} + +function text(element: any) { + return element.innerText || element.textContent; +} diff --git a/frontend/src/app/statistics/statistics.component.html b/frontend/src/app/statistics/statistics.component.html new file mode 100644 index 000000000..5ec8ede1a --- /dev/null +++ b/frontend/src/app/statistics/statistics.component.html @@ -0,0 +1,108 @@ +
+ + +
+
+
+

Loading graphs...

+
+
+
+
+ +
+ +
+
+ Mempool by vbytes (satoshis/vbyte) + +
+
+
+ + + + + + + + +
+
+
+
+
+ + +
+
+
+
+ +
+
+
+ Transactions weight per second (vBytes/s)
+
+
+ + +
+ +
+
+
+ +
+
+
+ Transactions per second (tx/s)
+
+
+ + +
+ +
+
+
+ +
+ +
diff --git a/frontend/src/app/statistics/statistics.component.scss b/frontend/src/app/statistics/statistics.component.scss new file mode 100644 index 000000000..aef585cbe --- /dev/null +++ b/frontend/src/app/statistics/statistics.component.scss @@ -0,0 +1,16 @@ +.card-header { + border-bottom: 0; + background-color: none; + font-size: 20px; +} + +.card { + background-color: transparent; + border: 0; +} + +.bootstrap-spinner { + width: 22px; + height: 22px; + margin-right: 10px; +} diff --git a/frontend/src/app/statistics/statistics.component.ts b/frontend/src/app/statistics/statistics.component.ts new file mode 100644 index 000000000..abdea8584 --- /dev/null +++ b/frontend/src/app/statistics/statistics.component.ts @@ -0,0 +1,274 @@ +import { Component, OnInit, LOCALE_ID, Inject } from '@angular/core'; +import { ApiService } from '../services/api.service'; +import { formatDate } from '@angular/common'; +import { BytesPipe } from '../shared/pipes/bytes-pipe/bytes.pipe'; + +import * as Chartist from 'chartist'; +import { FormGroup, FormBuilder } from '@angular/forms'; +import { IMempoolStats } from '../blockchain/interfaces'; +import { Subject, of, merge} from 'rxjs'; +import { switchMap, tap } from 'rxjs/operators'; +import { ActivatedRoute } from '@angular/router'; + +@Component({ + selector: 'app-statistics', + templateUrl: './statistics.component.html', + styleUrls: ['./statistics.component.scss'] +}) +export class StatisticsComponent implements OnInit { + loading = true; + spinnerLoading = false; + + mempoolStats: IMempoolStats[] = []; + + mempoolVsizeFeesData: any; + mempoolUnconfirmedTransactionsData: any; + mempoolTransactionsPerSecondData: any; + mempoolTransactionsWeightPerSecondData: any; + + mempoolVsizeFeesOptions: any; + transactionsPerSecondOptions: any; + transactionsWeightPerSecondOptions: any; + + radioGroupForm: FormGroup; + + reloadData$: Subject = new Subject(); + + constructor( + private apiService: ApiService, + @Inject(LOCALE_ID) private locale: string, + private bytesPipe: BytesPipe, + private formBuilder: FormBuilder, + private route: ActivatedRoute, + ) { + this.radioGroupForm = this.formBuilder.group({ + 'dateSpan': '2h' + }); + } + + ngOnInit() { + const now = new Date(); + const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), + Math.floor(now.getMinutes() / 1) * 1 + 1, 0, 0); + const difference = nextInterval.getTime() - now.getTime(); + + setTimeout(() => { + setInterval(() => { + if (this.radioGroupForm.controls['dateSpan'].value === '2h') { + this.reloadData$.next(); + } + }, 60 * 1000); + }, difference + 1000); // Next whole minute + 1 second + + const labelInterpolationFnc = (value: any, index: any) => { + const nr = 6; + + switch (this.radioGroupForm.controls['dateSpan'].value) { + case '2h': + case '24h': + value = formatDate(value, 'HH:mm', this.locale); + break; + case '1w': + value = formatDate(value, 'dd/MM HH:mm', this.locale); + break; + case '1m': + case '3m': + case '6m': + value = formatDate(value, 'dd/MM', this.locale); + } + + return index % nr === 0 ? value : null; + }; + + this.mempoolVsizeFeesOptions = { + showArea: true, + showLine: false, + fullWidth: true, + showPoint: false, + low: 0, + axisX: { + labelInterpolationFnc: labelInterpolationFnc, + offset: 40 + }, + axisY: { + labelInterpolationFnc: (value: number): any => { + return this.bytesPipe.transform(value); + }, + offset: 160 + }, + plugins: [ + Chartist.plugins.ctTargetLine({ + value: 1000000 + }), + Chartist.plugins.legend({ + legendNames: [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, + 250, 300, 350, 400, 500, 600].map((sats, i, arr) => { + if (sats === 600) { + return '500+'; + } + if (i === 0) { + return '1 sat/vbyte'; + } + return arr[i - 1] + ' - ' + sats; + }) + }) + ] + }; + + this.transactionsWeightPerSecondOptions = { + showArea: false, + showLine: true, + showPoint: false, + low: 0, + axisY: { + offset: 40 + }, + axisX: { + labelInterpolationFnc: labelInterpolationFnc + }, + plugins: [ + Chartist.plugins.ctTargetLine({ + value: 1667 + }), + ] + }; + + this.transactionsPerSecondOptions = { + showArea: false, + showLine: true, + showPoint: false, + low: 0, + axisY: { + offset: 40 + }, + axisX: { + labelInterpolationFnc: labelInterpolationFnc + }, + }; + + this.route + .fragment + .subscribe((fragment) => { + if (['2h', '24h', '1w', '1m', '3m', '6m'].indexOf(fragment) > -1) { + this.radioGroupForm.controls['dateSpan'].setValue(fragment); + } + }); + + merge( + of(''), + this.reloadData$, + this.radioGroupForm.controls['dateSpan'].valueChanges + .pipe( + tap(() => { + this.mempoolStats = []; + }) + ) + ) + .pipe( + switchMap(() => { + this.spinnerLoading = true; + if (this.radioGroupForm.controls['dateSpan'].value === '6m') { + return this.apiService.list6MStatistics$(); + } + if (this.radioGroupForm.controls['dateSpan'].value === '3m') { + return this.apiService.list3MStatistics$(); + } + if (this.radioGroupForm.controls['dateSpan'].value === '1m') { + return this.apiService.list1MStatistics$(); + } + if (this.radioGroupForm.controls['dateSpan'].value === '1w') { + return this.apiService.list1WStatistics$(); + } + if (this.radioGroupForm.controls['dateSpan'].value === '24h') { + return this.apiService.list24HStatistics$(); + } + if (this.radioGroupForm.controls['dateSpan'].value === '2h' && !this.mempoolStats.length) { + return this.apiService.list2HStatistics$(); + } + const lastId = this.mempoolStats[0].id; + return this.apiService.listLiveStatistics$(lastId); + }) + ) + .subscribe((mempoolStats) => { + let hasChange = false; + if (this.radioGroupForm.controls['dateSpan'].value === '2h' && this.mempoolStats.length) { + if (mempoolStats.length) { + this.mempoolStats = mempoolStats.concat(this.mempoolStats); + this.mempoolStats = this.mempoolStats.slice(0, this.mempoolStats.length - mempoolStats.length); + hasChange = true; + } + } else { + this.mempoolStats = mempoolStats; + hasChange = true; + } + if (hasChange) { + this.handleNewMempoolData(this.mempoolStats.concat([])); + } + this.loading = false; + this.spinnerLoading = false; + }); + } + + handleNewMempoolData(mempoolStats: IMempoolStats[]) { + mempoolStats.reverse(); + const labels = mempoolStats.map(stats => stats.added); + + /** Active admins summed up */ + + this.mempoolTransactionsPerSecondData = { + labels: labels, + series: [mempoolStats.map((stats) => stats.tx_per_second)], + }; + + this.mempoolTransactionsWeightPerSecondData = { + labels: labels, + series: [mempoolStats.map((stats) => stats.vbytes_per_second)], + }; + + const finalArrayVbyte = this.generateArray(mempoolStats); + + // Remove the 0-1 fee vbyte since it's practially empty + finalArrayVbyte.shift(); + + this.mempoolVsizeFeesData = { + labels: labels, + series: finalArrayVbyte + }; + } + + getTimeToNextTenMinutes(): number { + const now = new Date(); + const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), + Math.floor(now.getMinutes() / 10) * 10 + 10, 0, 0); + return nextInterval.getTime() - now.getTime(); + } + + generateArray(mempoolStats: IMempoolStats[]) { + const logFees = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, + 250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000]; + + logFees.reverse(); + + const finalArray: number[][] = []; + let feesArray: number[] = []; + + logFees.forEach((fee) => { + feesArray = []; + mempoolStats.forEach((stats) => { + // @ts-ignore + const theFee = stats['vsize_' + fee]; + if (theFee) { + feesArray.push(parseInt(theFee, 10)); + } else { + feesArray.push(0); + } + }); + if (finalArray.length) { + feesArray = feesArray.map((value, i) => value + finalArray[finalArray.length - 1][i]); + } + finalArray.push(feesArray); + }); + finalArray.reverse(); + return finalArray; + } +} diff --git a/frontend/src/app/tx-bubble/tx-bubble.component.html b/frontend/src/app/tx-bubble/tx-bubble.component.html new file mode 100644 index 000000000..612a6de6b --- /dev/null +++ b/frontend/src/app/tx-bubble/tx-bubble.component.html @@ -0,0 +1,26 @@ +
+ + + + + + + + + + + + + + +
Transaction hash{{ txIdShort }}
Fees:{{ tx?.fee }} BTC
Fee per vByte:{{ tx?.feePerVsize | number : '1.2-2' }} sat/vB
+
+ + + + + + + +
+
\ No newline at end of file diff --git a/frontend/src/app/tx-bubble/tx-bubble.component.scss b/frontend/src/app/tx-bubble/tx-bubble.component.scss new file mode 100644 index 000000000..4c759c4c1 --- /dev/null +++ b/frontend/src/app/tx-bubble/tx-bubble.component.scss @@ -0,0 +1,65 @@ +.txBubble { + position: relative; + display: inline-block; + border-bottom: 1px dotted #000000; + z-index: 99; +} + +.txBubble .txBubbleText { + width: 300px; + background-color: #ffffff; + color: #000; + text-align: center; + border-radius: 6px; + padding: 5px 0; + position: absolute; + z-index: 1; + top: 150%; + left: 50%; + margin-left: -100px; + padding: 10px; + font-size: 14px; +} + +.txBubble .txBubbleText::after { + content: ""; + position: absolute; + bottom: 100%; + left: 50%; + margin-left: -10px; + border-width: 10px; + border-style: solid; + border-color: transparent transparent white transparent; +} + +.txBubble .arrow-right.txBubbleText::after { + top: calc(50% - 10px); + border-color: transparent transparent transparent white; + right: -20px; + left: auto; +} + +.txBubble .arrow-left.txBubbleText::after { + top: calc(50% - 10px); + left: 0; + margin-left: -20px; + border-width: 10px; + border-color: transparent white transparent transparent; +} + +.txBubble .arrow-bottom.txBubbleText::after { + bottom: -20px; + left: 50%; + margin-left: -10px; + border-width: 10px; + border-style: solid; + border-color: white transparent transparent transparent; +} + +.txBubble .arrow-top-right.txBubbleText::after { + left: 80%; +} + +.txBubble .arrow-top-left.txBubbleText::after { + left: 20%; +} diff --git a/frontend/src/app/tx-bubble/tx-bubble.component.ts b/frontend/src/app/tx-bubble/tx-bubble.component.ts new file mode 100644 index 000000000..6e5b86c42 --- /dev/null +++ b/frontend/src/app/tx-bubble/tx-bubble.component.ts @@ -0,0 +1,26 @@ +import { Component, OnInit, Input, OnChanges } from '@angular/core'; +import { ITransaction } from '../blockchain/interfaces'; + +@Component({ + selector: 'app-tx-bubble', + templateUrl: './tx-bubble.component.html', + styleUrls: ['./tx-bubble.component.scss'] +}) +export class TxBubbleComponent implements OnChanges { + @Input() tx: ITransaction | null = null; + @Input() txTrackingBlockHeight = 0; + @Input() latestBlockHeight = 0; + @Input() arrowPosition: 'top' | 'right' | 'bottom' | 'top-right' | 'top-left' = 'top'; + + txIdShort = ''; + confirmations = 0; + + constructor() { } + + ngOnChanges() { + if (this.tx) { + this.txIdShort = this.tx.txid.substring(0, 6) + '...' + this.tx.txid.substring(this.tx.txid.length - 6); + } + this.confirmations = (this.latestBlockHeight - this.txTrackingBlockHeight) + 1; + } +} diff --git a/frontend/src/assets/.gitkeep b/frontend/src/assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/assets/btc-qr-code-segwit.png b/frontend/src/assets/btc-qr-code-segwit.png new file mode 100644 index 0000000000000000000000000000000000000000..854de6e4dfa4054472e5b84c18282f36a8aee9ac GIT binary patch literal 25307 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 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/android-icon-144x144.png b/frontend/src/assets/favicon/android-icon-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..4ad06df1960bfc965fb51a3495788539df624646 GIT binary patch literal 4053 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 Z)C?(Ve_Z~Ca?}a{eQjf{HwgRC{{r{WOCSIM literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/android-icon-192x192.png b/frontend/src/assets/favicon/android-icon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..d66b5f2061d4d2c7f2315e534790fce3e6e65fe7 GIT binary patch literal 4519 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 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/android-icon-36x36.png b/frontend/src/assets/favicon/android-icon-36x36.png new file mode 100644 index 0000000000000000000000000000000000000000..3f1fc5341a2ef109c4a781a8307dd59306a6d3ee GIT binary patch literal 1588 zcmZ{kc~DbV6vi(K)CKDw9k*&6Q0sICN%BGnNkldSB!NT-L4s9ENM07RfFY2A5EEpH zE2#ts0u?P7MWvNtkxkhJx6yXS#&uK-1tbub0JV1Li_@7>XF6~0JNMl4JKuNj+&Ax7 zBr|N)iZ53H09Mhc6c!SA9uhb$~C489%8$u|U3 z_o4ALFK=oH{lJOSXGccIBuVNWxG;4NwQjcNFB`) z5q2{D0(gPDrGdL8iEv&sktfKb(iL)PceZ*&n>B1Sj9sdpFjY+$i^dKqALQxg za`WwF6>~LL=VhvU+LLz8iRt1>yXmL7i?wql=V!$kcQf@fhxJp33#JOI>{WHMKVF|T zRM`btcU6XIm0>ciaI(NOQ`umztheh;Gl|+eDW#JNqwPe^bVZZ>v{Dio2Jm>mkN&9qh`*d#IBDP2~2c@MK?nA!Y0 zuX9v2Ft+E0g=^}iwOZ)yL-e+x@b)3b%^_Ob5Zq-k^*o|Ankk(_A)OXVhb6r8ZOzN^ z8T)MMfH}O)9N7Ijpyy3M&tPEBn~2`mKRh3^zyB~dH-GcVYfax{Nv~OQ`>CYwsrLTM zTbA+pKRg4o2nyo3aPqJZ1G zoMSeV)~{brifiUf0ErGok+=wr>Zby4~c%WVR zG~BCt27O;>Xz0GR{b#zbQ8!JelPlJ3vU%naV58_*ld zvr$zSt4$^otTDQk#y#N#G3D`UwL0EOcR!NDK8Z3Iii?VhiV>GJMKR`}REA6@JFrVP z@bIBI2Ze8{Z)|L=*Bafds0wpXtZL7mJt_=R{4Ky~r;)hadHa&M!VMQADhMb~BlLx{ zAmW_AgwS2yJUuk%vT`jtk<-Q>sk3+{XIngDuf=zF{G9gU{i=3ghnSRgwOM=h#*++P-KfzWr<`mC5e|RgXKyxITArr0AyNd#eBki z^dyr-uvjQk0*HiL4HUTuvu=^jKTNEcuM|0uU$1&BLy!%N5QbO=E7Ox<@Nrj|WVtY) zZZGo_9R?er0OW&*yuGnFUy}7qeHntRUZfK(&VT_nIM{H(e+((@T~vx;(SSfKg^}Z9 zWOAi6Nr(|})5N?ac?#y=i@^{Qv0Rt{LnPP-?|5LWH^jvYh~C#m Q1Rik!v=AnxHaH>sZ&L}a(f|Me literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/android-icon-48x48.png b/frontend/src/assets/favicon/android-icon-48x48.png new file mode 100644 index 0000000000000000000000000000000000000000..00ada2b27f570a00c3eb9be2447730c14489ad98 GIT binary patch literal 1614 zcmZ{j3oz7M9LLWtmE^J4l1HyK@+j=?f33$Zt=D>$_gdo0SiApygk7=7tB?&1H;G&6 zij+%5+8!c=o5?ki%T44O>PlAfsFhNx``6r=t7h(;`JL~a^ZWe1zjNly$@TSiQ&rYe z1^}pfxYPX+C9Z_hT4WAFFJ&X5$aC~^1VDOx!!m0PvPQ-EyE%cY4>wOC3mn_s-wS|b zBLLE~0Qi7xrON<_M_gWp13*0ufOgC;cl_*-#M+2GZglWvWt6v6okVul$G8W_A?p4$ zT)QG^04TJ3&>aK#qcbHvk)g2W{ljyEsT^+38Sb!Vt>Mtd^SEOsv!O~ZE@J)J&?fC= zg%9eQm!=a;@pqgN){`xjy}Uvq3%$87?oKu|U<)6P*zVnsX3(v0<4)pH zdGln_!_F3V023vwsd+QSJGJy?8vYzQhI4&}YftIzlc?2|B^AFLO*<(u*aGq{zf2ta z($Of_Zu}sx;{+dtjN+RmzRaD)ZF>l`PPc7`+H!e6u|+jCv9rr-t}e$E>x^fpZf(&>#D9Xltfo`m@KwRJlCPCWscwQ-StZPnxl-m!Rmp_BXhGLMdiaM6iu+uL@X~kxx>ICcX!mwp>0|W^d-o!K<``m zl7gmfW4%3bSk~%7q3C^7Z0s$FqE!&*hku z*6R=Tw9NRO@2eg!v;RaZZDicTx;vML+^~x`dm8$>zvA-EO$zn5Co)>@>l}GEpu6mO zMZ76_vtp`h=z#b3r_+|1vO{Z7PcLXy3|!RBd5o#e95`UB=bOQobWaU&r!E}nj_g;l zSr^e>^oVYWDb?8LxVB^G8HPLE0W~KF`KD^lTBIsYnA>=#+u6euCK^;QJ52gcOR=Md zm@V`@8S6%bYx@s6WXM$dGNd6xCt}gNU%++>5!u7Ld8i5n=`kCIPB#BsrOf0cX3A5A z{+&4a-^irgv#49B!60=W*3K1j2p|^qB{yeFt^pFZQ(ERc0Gu z3RqHI5ht)KYM6$Z`J=;7anUn$XkC#EyO?p_u7a6oepV8@MDIiuVK8Z7l8&G4!i-M^ zH)d@X7?R1-B94P*MNouUK|#a5^_AAr(nq<`mvXZwJo@4AaQz#Zw|?c97%_AjG_ctP zy~Ep6dk0+_pY&80n~__pqjGTu`S{;KYn#;->G2&&?TZc9ta45}zP6P7HfdbNQ}|rV z$Zov$THE%mn$d*kCyZ~oAj`t1e!i|{-fZyHq3811UDvs$m4%=8%`2mx<$D(gsaebI zQ%3$8`MbY(I<@cCNUe45-i34sO1D1h9GKL^m@H*SmuQt?f$ZJK?uWyivZ01^?hF^3>? zA~^tpsE{?)l443EQi;S(yC_$_DTo$v1l*+mFW4WRp^X%{u5^eKam9(OSQvOjvv{x< zizi@5;;iu2cpGyH4rxg?Ct8|AB%B{)h2x9GF;oH}F)WiRp0IK@ZMEH*R+LKD(3?K?g9mGlyMthadzf6#T zBj&Flf7a+LA&BlO#7htji<4qt@by%L7$ML8dZ71rK3)L`1t3c@WMM%hSyN^TZ=OXE z^;JF$K{5=8PEJJ?v{y(fa8-)w?*X`iC>XguAzCPo67dLJR)T;n62=k!dl-UM1d#<> u!4L(uB(DsZXaTXvTpKclWNk&_KzO7AkDPB~swWxg06bj0=~tb?GyegWag-MT literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/android-icon-72x72.png b/frontend/src/assets/favicon/android-icon-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..1d414c558abe998319806e6db3001830c662b5d0 GIT binary patch literal 2064 zcma)+3pCW*9>=#Z@+_25#Aqe1j+yz7Va%jrkQt0TMoH(y7-I~^c#J&G=nRfmT9LdP zujG+XB%EI4k>r`TJi3zfpmIyXiTl?&Yu)Zy>#lXzUVH!6Z~s2O?|1LD*V>8pc2@E- zyJY|X@>Gh2BOEE4b1MQ~ofmFd!9jv;Ml%DTI$8Elh9sPe1v^@iK~<0X8#qBRDULJ% zBK89y{2hQ*cuV*ifDkMIZ`}bPWdVQ;5Z!SwfeQ!^8!HR2u{jFvmtKN*qys1?gW>qy z6CCaEJq&l1OLXL5eb3vi>G-u5v3Qc9W#g$E;^G4e4hav{EgAsBIxl?Za z{;xz`_j)6H*8+Pwb~-yNUDIO{c%LSx6%{rDLTd(b2`*-h{;|ETK%`FhdYQn`I zW$iDBxZiU^s*&|UCqHmul_s(M;>!ADFZ~VKwcg)Q^WFyf?&ewmEVcgcHi#tysXKQ@46{jD@#D89LmhR1K#6NGG`^})- zb)nd`vO%-1xoiHX<@YpZ<@(%qU;Virw|%0ltQ_8Wo@;sEDRlLjZoTrM(>I9AACCO9 zIrf09t=(;jh`FA9jL;oX)`czwj{i`WKsYa-pPIs@TW2SB3@bR^7aeTYx>8wod-v6 zzLLj9Zdevh__|crnChGmA+3TD<1`TCq7=>tiTNM!R@) zVBMeWZJBspdjNfPd!mP>hk*$#u~jLtZjWZU(6FQ4(`9(;ux{>zxRdyB1JI66VLP9awc5IH}yO@?6`C=JTs>>~W8C*%|vaJn_=V2f{&S zqV%Xj8HS=Qthg$fcZzH>wObHf>y=}_no?P|%$*$cpHoU=rg&u^8;FrhSlosj)=6rE zbXPLuIuW#E|GJnGVZYXoN;I>!l58bkdgML`9&I?32G<@!b)U`2#8Y%S+YGD;CS~0> z?IrQ{_!hIWlTz14hNkGrLv{%p7mmq=xYP(>EIFgtc8^q=lBKu8Df zTtHsEbz15oz3oN5@9y9&$t2x(lLEhreKr*_;i*P%x!@$_i$%>lv%dN3BP*JuVxFjF%$X@g@tnh41jT==a z9U4kF|Lfg94n#mgVia))bE29qq)cmRObV{iAFnGpeYHuiUQ>*eoMAn&{Uuv;i+lZp zdzIZ+!Ec!QSMQnDvl)avD)%Hbwh%PB6is%pA2>9Xd!*MPg>gxW)2~>O>Y_Z;&e@+b z$?}#O?fI$ezUKYzV|OaZ4Z$;d7xrxAj>nj1p|W^uD!zh zD%CtWe+&xZ$GXR7=GBNTge9c`@fLes_Zra{NqD8>puUeo?#Nowz1u!Q))4OFW1A^Z zQpzUcB4Tf}mo)rbfsHCei#;pml_;fc>2uS&S*JU{tEwh4{7AY-A@S(O#qrA9wv(5q zUyK@OmXu`6%X)7=!?I9Ht7*!aE3eQzZCzRV^+wUVMzOHx-cMd$o{fV4W^KNu3*VE$ zXOWmeEI0rh7Bbd{Abl*J4nZV{Kr$pAz+y>QtlA;s_5W(%@;o_S;s3kA$2|%OHyqt` z2*mM6%#9L%1w|K8a)xqrm_w-MEYS8}r?hMDk{F*xq~q z5pml8Mi{_G<)$L;>6L!-kK zfDG}FfdLjrATFfVUWSovKj=7dB3J-RCZ}CFJP((S|4?#vqyjIF9}B)dhU?GwPs KokbPdJ^J4ji*OhK literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/android-icon-96x96.png b/frontend/src/assets/favicon/android-icon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..260a24d449581c7a1c3fba5a60fbd9c14b4dc9ff GIT binary patch literal 2680 zcmb`IdoI&3tl2bAgOib=4z#0{j zplkvDoxnx~+(npU!bxEa)O^S#WL)Fs1G}8R#nhg@*JD)JS2skQGwf-f?!stK7o0Gf zRn|Y)pIh~H`_5`ZUzvq-<~Ghb;E@OS@3F<@ju{*K+}O(xuMCVgM(6wt-zY$KyjoyH z1^cN99Gsuk6liX{vAzCkj^Wbi^_Qg2a-XcKnnT!3;qFq$@{JyZ2T|_;tuJ~y(A>Ou z=)i0Dry*lK!_ACzdE@PnJ8znA#!SGQb+vvUABz6ARm<7gToQ%b+D+%43jNsa5bPeL z#cHn1@!r|7GTd&W{uz2rb@<4>p5ETo?OBo4q7#VfN}4ROv0q-bmcyHCqtW0Rt(J60)d z8f6O|ANvjb)`%qsL~O3eAihn%^T@uv-m!~^_T@(3s$*uR%gh{Z1Gu2 z9!?Q7oy@M+yU-NL>UXV6GN>DJ@2v2gu5q6en&1q`k&sg1(G2*&M#H)3fb2^#X1_$9 zdOQky_H5KzsO;#0OV2BztA;Y>{N0BP#1ST=QJ|9@5FFYo;rjBvj8w^c{*8djG#GXm zVbcBoOhtX1iJr2kQuwm53LJ^OEUv!On$K3qww<%!7rhWt`?T`m2EXM>Q^r$?PYgov zSWTLHc{UL$>Jcx)q$&+OnGlwpKeHGn61(g=xFEQB_2nD0#Pwp|u_)QoH~bzzM@HnT zBcCG&xE)f#y%_S1oaKF+2x1#k`c+C6fP9LwEYR)?-g9Xwcl7M7VE@ zsWznx2`Nq)<^HsIlRIG0ddNpy?PR73+bHqMABjrV^XD-~HPJ_HJkquA3zYc5*tL1u z>QtN$;^d1g|C&OT6}LWe2H4qgpUKZUVhfY0W6Z*_BXDbNS(Q>)Ux9MG#RN_RC>5_w zJS02}@vkCDC2=MloySgKAt~o+AOiVpoQt(R$D>hljGPta|wu>a1Dct^qrWYGT4pE0_)ZC#d8{FVX^8W!z*TCNUGKshX0<%4F z9)kg?p!ymZ3*5Ax&NZ!pee!aWAiNo&f?;J!U)>>fT@v6M_llvXIuitl#AZ^-TZj@j zC892!G^fb))_R!qmO3&k$WRBQy+)9$Ji1=qo z`rXp-Re`1#OPBm?2>Rvg+X^CGdt)#Y4z9^hbHndwyT7i1Y z4LeVXc8x&7_c<+lK{wXIR`R=zl$Oy#*||XZmdXBC#RB*`)Gc&E&AL2d{e1x(vD{F)0b6PXA zM!z?Ai*6!#4GJMVFyN_u5Xk#45LQH>{Z_BEw#Dm@bbsN9s22*iZ8>;qmvv}K(B{-+( z83Qj?u+Q3$9=)4_6tZ+I_tS(=2&ZXI`EgnbpSjoR5mjW2?DGt>oIG>M=!0EUh^gSa zCtb!FH!Edj?*JvzeEHsSf(P&rdx6aR7aHIWYR*-?0Zq86s@FK5e*J(&FkM<_<(sdY zbDciTL%I%G{<5-zpKt!P*JXmKyjpKb7VuMC#;pbrgwNGFft85HfmbKpvWq!|%qkW$ z^uwBTgf%NprSsPfZIi?Nw;zd}x|YCg;aG#qMAx6>jPNzouD%|1`zvj+HTz4;mlSz$ z`0dGOLGm?evahZydQbGX7_YVQvy5}OZBO*)A7~AgUpj7q&ot8V^)5>MI^Gv{XS69Z zV%GQrgUR=xzii!OQj}9dms%?C4PnqNE7$6|pAd_)^Yee~#BC{`Pc~9_orxw3>RsFq zdN(>UDLrOzh6r_b#&0gQs+?oZ+j&v0PBG?N2hX-S{Kj&*rhyqxFB)robo_XSIa6Fg z48Rq|(x${-HZgABjFi1X=={}1^K207a7w=5XPLPR#xLogcYb{7pnYmHv^*vW_U2e>bh2T*4V`?wpRCYG_h@cXyG?% z?(a({VTs`+o&(TGl%5s}rG>=cQ7A0x7*SIt`0J zFqup^Wsl|=kA_pj1II5d@B{&=Jw-e@GQU%=eyH zESW?JB+~(uKKdXKev!vW{-E=3#+BkvC+{HxWr~74 WIB!0QSX&IQ17KreZ(d>MbN*k&ua))y literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/apple-icon-114x114.png b/frontend/src/assets/favicon/apple-icon-114x114.png new file mode 100644 index 0000000000000000000000000000000000000000..c6797c37f43abc8cf277d302498c413df094ee2f GIT binary patch literal 3009 zcmchZXHZky7RQf(RFxVCO{&-Tkdy-*UH`mO(e?>QgNNwcvsJ;*P`4*T}>W| zn6&%quxluDBYnGwk*@`;K*%5@rJJ5+^Yx|2#ay!M(jR)DSCY z>WXbj^EB1kY~0t}m+#zsi)xixGnt-fg~cEj09CN|F}%(gT0R^1E6o4k$gd_OBK)tfh31Z+`uil$`h{@g+p zb&o3xlpLB^AC53Q_|RE-&81HRyH>ec@GNCJeFf)Lpony0ZJy0Gg~a zX-fr@KYu>`D|p&a=cSIJSnJT%@r{VxgiuvD8i0JC)ql3q9J@7>BC$DFwtr!*RMN{! z*SZE;_p`_r+YB`N@F)Qy5WClk2sjXP;zaz#ZHJvBRZawoT!#lnSKJG9RGtKq%T&Qk(& zZ@7&s%=CqXxC(R3a#l;SkLV|<@~IHaZ4@lx`5i^<+H!6gT;qZ;l>aZS>}sEp7`dlQ z(&V~e0`vU-VVmMlwML8&2#lxddoD5)3hC$ZJP=nda>n7t=lEMEh5E7cACx9IQl6f6 zaq!QsS3-Prk%jaLCVb4RPgszZEaqkL2~{lK^$|ZVuExu;y|Xc~zPj+aIicbH17E1H zKWRDvM4}d;hyU5FbfFB^!gDvvS^nq}UnNEwGq{| zA(xgo+dJ^A1$#XV>%hP-PuXm*^5-T|3VGS_**%-I0`xKDW`OQUhryjP!b~yZZ9AP9 zBO2^PT(LUOSOcqPVVw6o575VLugs!Ftasz7g)x39vh7YQSH7S(E~^j@^GoF9vuJ+N z8DW{LEyee`p6S&!o1|J+#8ZbcopvYMv*@bLW64#K0vyQ%maw<}EBbmgA&y1M_a*F% z8yl7hrcue|VRiQ{()lBOYpkfy@`Wh{XC;9r^!A2?pyGk^ll=v!-;I zIU_e*PSUxTm0h4Ti>rYt1~3KH5SCe@KU)J~ykDL;j{ET?>`xqe!2_-M8DzI{xo}}9 zlCXHEc6|6d(X?BM>^yy97}L@xQXogmHx)MuNrw;OR=O@IHmTf`7}g4igJ;i#s8x-j z1_`Z2%D(<(3x1~7xiU&Ek+}t`-Sg|rBG~ZD-A_`qJcRbE$6AKI`NVQ3p>K`Z{iQ2} zJeu+H2iDt!&;B`*FS7Av`&4T$eS?=Ndow;+SNo?6U|QZpMK&e5*t{pSro)arR}6gn z`K;2Ko0hJJ^MocZ$3L}fLWy4q54M2Q-Pe5J0m`JE%53eC(iLchWCoy_NckO3Uxao zX8-AgZ^gv(d5yM0*WRxwn(5`x8t!O_#l!_&C1a|+s_LJus>YRrcV|oz<^3jGOu5fo za^f{-sUEC|`7C0ORl8vkNz%AugOIhpyL!h}ojOc~2~H^Wq`G~*JUZzEGei_M&( zmM(SeM>||SpXVe$!oRwl!3c%67v_k!#EbLn@C1qOv_!5=a1}+z`v0;B%b&qfHdsHkB($zanah+|@Gc0r_!$nJM*V%4v}nRYse|UP;Wk5@lXT{h?z8ldf$wT=JluFv9nRsSkukPFa$Mlc@9Sn> zg9vBnX}ipr34Jg7X(%H%f4AlwYq{1mk;_}ddGwh^>u}HX=0v)2K=_7eR)z>ST#JF? z-epwu@ui*KpZ?m>P{R)tF8{v$<*-%O#!91&96X}R=i3 zLX#|I=*RKcBx#gSIg+G5pyyiN7!)X2G5IodXcs_@$-ijoOOs&#NFAXVpP>-kDMYMi z2$7utH6)0EfglWtvI9XZsEO6kI)+4Ikw|H6t+M|b@DC)Ay(0hjfR9_;o`cDrLujBE zCEPuP2$=i3lZZHX64}#N9u3!oV_;hHY)cdjsR0Akaug9j8W>!;qqD$n+5gr$jW-9p$bLlj^%4F76u&?c z!pr?U*)uR86!D)IgFt&A-HB)-s72I3?JXiw9dt)|VNhCXnrJlw2xm{g+2<3#Xo+Gw O0OrP4MimBb@&5u5!9`vG literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/apple-icon-120x120.png b/frontend/src/assets/favicon/apple-icon-120x120.png new file mode 100644 index 0000000000000000000000000000000000000000..734e9459e4593c5e71475e68eb60cada05b734de GIT binary patch literal 3254 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 S$WvNu1psYkWBS0@Bl=&6rO?>` literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/apple-icon-144x144.png b/frontend/src/assets/favicon/apple-icon-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..4ad06df1960bfc965fb51a3495788539df624646 GIT binary patch literal 4053 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 Z)C?(Ve_Z~Ca?}a{eQjf{HwgRC{{r{WOCSIM literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/apple-icon-152x152.png b/frontend/src/assets/favicon/apple-icon-152x152.png new file mode 100644 index 0000000000000000000000000000000000000000..455907c4378423e18425f4a48187d4e20cf7657b GIT binary patch literal 4122 zcmds)_fwNwx5tAhQY8U}2mwMzS`tJsK|p$uBB2P#F$oX?Qi3G(t_aebC>;?(QRxH( z4kDpSlOiBh>Cy!RL{x4(b7$_k@67!N?(A95n!WdDeb;)Py?=OC^i2~z4pu%^006*Y zps$Ul`}9A?%tU{JX;5LhJ5A6q)&Kyi64`#@&d}!!B($C;pnL$bN}oU-^wGuuK!5}Q z5EcOd{Gcy|eE|Tx;Q+uI763re)p$L#8_llKH<;{i=xGB^{)}hM#i{fa7EgT}65Z?n zW4iBL)#!D^8)$1-_)Tu+9FxrTo^+|t92!rN3NW zEW>7AWK5QdSvI9QhUzExIo@vt+rRyY#eUUv6IFLyomD%2K*mScQ>NxoNFhVc>txJ& z5Hk43x79bg+7T;Ikq_$}O3L-Gy^rN9dn?v43~Yc0Rn_H+!$HC^_EzI#8BIa)Ehq5aeSYPJ z&=ZDrPUVl&)6cD@@`oOI4m59U>lh?MnY&j==c6K}p5BOyw@S}ddz(>jvin{rdntaQ zi@tbT<7=N!t~4BkBavb!8vF->eRJ7(D?Ed7(W9f@^H)2;u?SMQOUEARxxzd z*ltgyw$`X}4F`zIm2dFm%A>ufi)&0fu;Cm%77 z*~WAT&mxZ{Lcjki4%!_$Yh;q3T9hpRiKhe6TYu6@zy$Tvt)pzIscderv~2;ZhTAc5J<&EwwI)ZWj{AK7h*jfSiWjTNw?04@arqxq0Ka9(N&_ zvPrTtfA=3{Ah)I)^dKs3-VU)s1MlxGboN_G&*L#KsxgR8pblv77Vmf5G8Z*RV52|F z*ZOr;``VQc10eu zG}k8G`SJH{D<&G-`M2LDJ@1a!*ZocsTH$3T%F5SNHI!A2s|hooVtsfG2QIpM8AQKj zCFA_1aZXuTx2LWD*6|}Qsi{x};#&?F8tn8b3bc>T5A*X+jqyPRnvg3cqjz7GsXGP- zKah&~;arw6hq3X$ZXNtOe`j3nEx3)&wnY_#FL63N_@>3TBK_D<2WWjeR3CC^wLoB3 z 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 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/apple-icon-180x180.png b/frontend/src/assets/favicon/apple-icon-180x180.png new file mode 100644 index 0000000000000000000000000000000000000000..05322c73963c6f3931b3b47a136ab8b5643ef149 GIT binary patch literal 5009 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 FzW{PJO0NI_ literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/apple-icon-57x57.png b/frontend/src/assets/favicon/apple-icon-57x57.png new file mode 100644 index 0000000000000000000000000000000000000000..ee8dded1897f2ba9f26f88e3e9bcf31875b141d5 GIT binary patch literal 1753 zcmZ{k2~d;Q7RRqhDMApHvIwcbEA6u{hU9|;NI(P(OMrw$f?=zMgky3008u;UX(y2lh#993;AZ4q@F>NCfAkb3c$5Y-M6eyka|O0pr;$C?nS>q z3JbPZAPs(;uFwGr*1Fdc((ixCa*XERwH? zj!)y9NlE!i<;I5Yq%?YQ%dD-+aCLk3S^S_pnvR` zxw8jYGP~+RjBcq=dyY@iuacU)DGzJ7vUBGmc

$sd3}6*QVcj)Qm0{)x<4b7&?7L z71t%{^za0eDsL0h$DLJSQf3HWTo%E;oH9ea)} zPpz!g(vQm1mV!|X_U2%P1Err$M3wJgWTv;3Ii4*T`Oj!={}EK_%el1j1Ki)ww_V$N zp@do-J2GM-mk&H&bi==lDe36=YHjz6Im65+&6C=n{@#36(7Hq4J%2C#yl!0+<-cYq zRn*A4oO`bP5!+3KFMlpTC+ytjpW>Tmams!3(%xj5e9S#BSwB*f`@&UYrzY`~hf1j} z8W){cH(&+BQFBljOTb^j#KBm1vvHGD1<&0|2{hApN@%e@Lpq9b3{#454vx@d%1)EiMDW6~Gr(h;zNq)0jcG@nk z@(Ubxdn}nE-fMxulq~!~b?^dpuqeVnu1R5@$r#>(DSedWQZMcz^CvF)c8LS65iZp3xee7BNr$*td1(M#bdj zlZWqW@1I$v9ZJxk_NmtPYXxmWo0)f-pIkm-y%u|J9`i**D7IUzzM)z3uX|gc5J_a~ibgt~gv8Z`ykZ=!P;^(g;eFQTu^ z_KeBVk09@=(2&)4=INx*-=;cYr;!KdOR+ms%wdUPGFt>A3E1Ev2P_0(@q{1({;67sBdy>gKn9t>j0YtLV2cq>i-1q~XKbie}j+nQO+^@9M zMv(0vAT+)J79Wj;!TVF;Vq>^_RKfI*bZ9{c1t1~;vbDwA*psGmYKss={{x+U{9`b{ zySZH`b$*4kfwu2!OOeCx? n7;g))2vH6Ml8wEc4F|GDCajV3872A>5C=eYr&Frk4xai0Yu4x9 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/apple-icon-60x60.png b/frontend/src/assets/favicon/apple-icon-60x60.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6e130bf03ef06428a734b3558dd8f4d65256f8 GIT binary patch literal 1807 zcmZ{k4K$Q#8^`Yz8d*(jXV3^sV^L=2VbIJ*`O5eh%P{hh7~?SpVC$eV~>$);6ONihzowvFU{boQKWJLf(3xt{A>_w~E}|NB1oIgi-a z$6agPrgZ=SEf11F_kvH@5`wnQ%gNG1X>9f3$x0HDkNsm*UyRXx(n-4(2?t}|CJmLfYEd}?qK zlBu6!cW;~{V)vtmt5ZPCquEn^JS<~lUPlr;ehSX(PNtqgJDDEWv0O)YGJa*QCTE6= zaXOkG0Y=#t*=eLG(qaVDF^AQ(w|VZW)~QBW+!2L*o705bQpqVXlg~M9>tVSUvU~2F z+lyVP9PPgvl5ZUdZi*+b!e!{}>bEmx+DPM=&N0zYQMTLuaU~FA5jC_dh!DlvGUH?Br_l zSkp#V`0nbv6F+xg9xD0wC3k?k(TQi0mhmUzr~Vc1$A5i2ak&`j=0a6*5t+HKq7N+| zZ#0syIl`QX>HGd{$7Q9LELosEgSW!t_diysc4jQCmx#kgQ^lwrYFA?FK4bBrmay8A z!70?#^STt(_+LofK^MIBa>f6WHGhz|cHchL5>gj_y4|z&+Qgxk+S+D4|EZ|ZGqpS; z{@!Bag6vnGuXHp_Ds34-z1*P21fr_>{9X8RuQx%A8%kU1??de?s54ojxnt>yhjoVh z7U|lt+3BxR+rKD3rYg>!dzocI*kZo^P7lX9&LZE%wk@T$#?-*VFsi=ex&wC8vTmzM zL80R=VQX0eHuC`g)Y>_t%8=&vs$mzGtZcN=j^1Zy{q@Jk_O0pq)3qqFH;Tr&oDph) z4wR?7+s(=*-h6v(X6xB~p?cRHU4o8!yZ4iEzs(UZy-k1ND(?}Zm8w5c+cMQ;BHMi0 z{C!`oR>f1)2&G{NL;W^O2Z8zMF;W zHQyM4EL`seTp}l2cs%;+xWIvuDz8v(M%t#f^rK74A?Ps8Q6<5AA*jAaL$O=Fc|+H+ z$%a8W*C98o#tX}Hpr}-REHGPY4Pf!8{X?g}v%0q#gK_gO*ft!;H|VMr-80Ag{_M~e zoM%%aRnbPbW;?vC%o&$pj!iyjvx%DMQK&p>~E3AiPBDO5@a}Eh!;sVR>gq z)4G=hi`Q<7d~{er%Lk3Bf)gGbnMmj4^Tsp`I(9C48-(Je&BJNdI_CJ!Z>CKRi#ao~ z_M98FaAEccU8Pe!P0|DcYtN5sm`#><)Z7ZU^=dY(YQKh_LiOEn9BIzgE4~O6h1|ZH z6s?8R`fsLR28&vLib3+RGGt!;bWda!kim={=m1s^2+Ru*{oxNxrhzVm=BsU$j5HvwY8= zk7tbDJLkt2D_sf%SDG2;bq^n83%R7h8T4`e*+F*&=-VpwUipB%yOn`iwDhB2yO`*uCnI#ETZBG819o!DX^x8k5ao#Tnb-?eW{K$i|2z z$%<%e1z8*WL3YM50s)^wAf%?I;yJ4{5(tgwC9=nIXAwc5x2hNrlgy2zP+WPeWGftqIS8Zsna*F#9!``XW)-D`kx^* z4i^>(`7ro&Dgr-&y|W?E=L;QL073!CmIT?@5UuUWvn5TZ5Cr|1jz1?I21IA)vU83~ zq!svCi$V7Q(VTb~xjuoLAc*I&3DL|H4vUwNMELt*2zHS~CTs^oWZ0Ipx?rLW#3V&; hCy}k~?X069JTiet&WAbRO+p-ihntUUopZ#G{{tHq|G5AF literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/apple-icon-72x72.png b/frontend/src/assets/favicon/apple-icon-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..1d414c558abe998319806e6db3001830c662b5d0 GIT binary patch literal 2064 zcma)+3pCW*9>=#Z@+_25#Aqe1j+yz7Va%jrkQt0TMoH(y7-I~^c#J&G=nRfmT9LdP zujG+XB%EI4k>r`TJi3zfpmIyXiTl?&Yu)Zy>#lXzUVH!6Z~s2O?|1LD*V>8pc2@E- zyJY|X@>Gh2BOEE4b1MQ~ofmFd!9jv;Ml%DTI$8Elh9sPe1v^@iK~<0X8#qBRDULJ% zBK89y{2hQ*cuV*ifDkMIZ`}bPWdVQ;5Z!SwfeQ!^8!HR2u{jFvmtKN*qys1?gW>qy z6CCaEJq&l1OLXL5eb3vi>G-u5v3Qc9W#g$E;^G4e4hav{EgAsBIxl?Za z{;xz`_j)6H*8+Pwb~-yNUDIO{c%LSx6%{rDLTd(b2`*-h{;|ETK%`FhdYQn`I zW$iDBxZiU^s*&|UCqHmul_s(M;>!ADFZ~VKwcg)Q^WFyf?&ewmEVcgcHi#tysXKQ@46{jD@#D89LmhR1K#6NGG`^})- zb)nd`vO%-1xoiHX<@YpZ<@(%qU;Virw|%0ltQ_8Wo@;sEDRlLjZoTrM(>I9AACCO9 zIrf09t=(;jh`FA9jL;oX)`czwj{i`WKsYa-pPIs@TW2SB3@bR^7aeTYx>8wod-v6 zzLLj9Zdevh__|crnChGmA+3TD<1`TCq7=>tiTNM!R@) zVBMeWZJBspdjNfPd!mP>hk*$#u~jLtZjWZU(6FQ4(`9(;ux{>zxRdyB1JI66VLP9awc5IH}yO@?6`C=JTs>>~W8C*%|vaJn_=V2f{&S zqV%Xj8HS=Qthg$fcZzH>wObHf>y=}_no?P|%$*$cpHoU=rg&u^8;FrhSlosj)=6rE zbXPLuIuW#E|GJnGVZYXoN;I>!l58bkdgML`9&I?32G<@!b)U`2#8Y%S+YGD;CS~0> z?IrQ{_!hIWlTz14hNkGrLv{%p7mmq=xYP(>EIFgtc8^q=lBKu8Df zTtHsEbz15oz3oN5@9y9&$t2x(lLEhreKr*_;i*P%x!@$_i$%>lv%dN3BP*JuVxFjF%$X@g@tnh41jT==a z9U4kF|Lfg94n#mgVia))bE29qq)cmRObV{iAFnGpeYHuiUQ>*eoMAn&{Uuv;i+lZp zdzIZ+!Ec!QSMQnDvl)avD)%Hbwh%PB6is%pA2>9Xd!*MPg>gxW)2~>O>Y_Z;&e@+b z$?}#O?fI$ezUKYzV|OaZ4Z$;d7xrxAj>nj1p|W^uD!zh zD%CtWe+&xZ$GXR7=GBNTge9c`@fLes_Zra{NqD8>puUeo?#Nowz1u!Q))4OFW1A^Z zQpzUcB4Tf}mo)rbfsHCei#;pml_;fc>2uS&S*JU{tEwh4{7AY-A@S(O#qrA9wv(5q zUyK@OmXu`6%X)7=!?I9Ht7*!aE3eQzZCzRV^+wUVMzOHx-cMd$o{fV4W^KNu3*VE$ zXOWmeEI0rh7Bbd{Abl*J4nZV{Kr$pAz+y>QtlA;s_5W(%@;o_S;s3kA$2|%OHyqt` z2*mM6%#9L%1w|K8a)xqrm_w-MEYS8}r?hMDk{F*xq~q z5pml8Mi{_G<)$L;>6L!-kK zfDG}FfdLjrATFfVUWSovKj=7dB3J-RCZ}CFJP((S|4?#vqyjIF9}B)dhU?GwPs KokbPdJ^J4ji*OhK literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/apple-icon-76x76.png b/frontend/src/assets/favicon/apple-icon-76x76.png new file mode 100644 index 0000000000000000000000000000000000000000..e6bd962f250b9432e5d3632b037a3275ed65e953 GIT binary patch literal 2199 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 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/apple-icon.png b/frontend/src/assets/favicon/apple-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fc019a5e00d8edb3b27c3ad027a3649e72ae1499 GIT binary patch literal 5008 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 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/browserconfig.xml b/frontend/src/assets/favicon/browserconfig.xml new file mode 100644 index 000000000..c55414822 --- /dev/null +++ b/frontend/src/assets/favicon/browserconfig.xml @@ -0,0 +1,2 @@ + +#ffffff \ No newline at end of file diff --git a/frontend/src/assets/favicon/favicon-16x16.png b/frontend/src/assets/favicon/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..92688b1879bf51d1a8a39f9cc6fb697a0d7f1df6 GIT binary patch literal 1137 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 WIB!0QSX&IQ17KreZ(d>MbN*k&ua))y literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/favicon.ico b/frontend/src/assets/favicon/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1e15917e8ce1264d0b541db36b8bed8ddbef8876 GIT binary patch literal 1150 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 Z)C?(Ve_Z~Ca?}a{eQjf{HwgRC{{r{WOCSIM literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/ms-icon-150x150.png b/frontend/src/assets/favicon/ms-icon-150x150.png new file mode 100644 index 0000000000000000000000000000000000000000..c51bab0456435c21177a4a89928b17a023f88d1f GIT binary patch literal 4045 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 OfU&;0UOCeF?tcLxnlj@6 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/ms-icon-310x310.png b/frontend/src/assets/favicon/ms-icon-310x310.png new file mode 100644 index 0000000000000000000000000000000000000000..1e08762278de53c6e48d94360e580e90ad89689a GIT binary patch literal 10605 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 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/favicon/ms-icon-70x70.png b/frontend/src/assets/favicon/ms-icon-70x70.png new file mode 100644 index 0000000000000000000000000000000000000000..d5d467b0b76eab2888bcbf786509167b2b8fe84a GIT binary patch literal 1997 zcmbW1c~H~W7RP@eBA^tlYyv7Iz_1AXeh?su?1nAuA*?E9AtY&JOM*eR0%`>nQ5Hdv z9T7qRv9bgLiVKvYSfq+dWr+wVP>O(+MR`A*H}ia*dH=k*bMBdQ&*yy4xpQZ3y0@pZ zyquaG008o?E;s`0oiMYs2`2Y|-R?d#;Na4f+gI6DCK{TlD!K$Gf1 z@Bn}~T>wai;Z^u3c^Uv>Kmd480sw420H{P=YxA*!6VennXB@Dxxk@{3WWy)2Q7%Co z*t>i+r(Tu~0Bp5$#n}^MU(H<`jwJdip1srbq}c6XU6JQ-S)-KpX_8`wLTn0NWFfR>#tGaHd-ld(we3aCF?IUuE`fOIIW!%-`js!HlN++uS8g}5_ z)#B06k>?Tg#Gjvsuo@Cg%lUY{DGkerFAvjw=@?GLh1wzA{GuXihO6^0s%3xtE!obS zpSU|+DB_KUH(%U=M98bxoC*pIoEXZcqd(k_Ha;fofBJ#lZo9EjUSE%Bn0xuS?1@bn zcQk~JX_pHllP_2FB#DRTGa>V38AzJBu)oTYf6m~7DDZ8c$#)9_T?)NH=cD@<`qD?$ zydUYNN@^lhk?teHo}p2V$@O^^0DLnP89mYSMqaPbuUj=@RMKnjIPgAB3ovFj~BfY6A~| zD3o2-PI2tl?^5*L=O_gQ*>vu-o6ipDI+4KuS{lgckf;_`Zh98@UTdss|moChE zu#38gub)nR6hG2bnHrykku(G}^)?mdnj zvGKKK#?^x)F zT?2Dp%>}avvcCE$buKc_X|HVr{l-0~SdzMqj`AiU_J^RNy{NhKmq_}(%Y zDr3WEFyviI2m;=b>0z?l37<+%%>}a27E|S zB6hs`Mzxj?vI0@9QlH-VbM1A*Gv4>+2Nf!SwD~b594bxq(2`cxO@oyXxiW!I$GU}l ztSq6|^6}vu^LTf>4%rdkw(`@*#|a_UxGL`qzM=&$U0t*)Tlj+i`+BADvQo~>PS5Y- zU00aJz^o_!fQf{0GO z+DT>FSjW2$vWr5$2roA^|0 z0{rE8Uq6I%SmFn5G%k4Nx=rrB26S716ssV+21DadQWBM?sI`elAGNAcIF&J5l0@Rg z(2;SS{N_3|?Im-TvyXPAvEjR1w6$$U&5o*Ex3QZBt=e1mP|DH_8UorIE83k}qgHIp za+5CPMX~0+F{KoZwOXCo5$pCBytMBH;SOZdmoo_J}nhl zuP3f5bcZdv2u1J4^fdby{AF!khE(OW)ZRg(Y@l8qH2_mGPpvs(Z&mod@D$EYk+|1! zl>TGp_~Krr6FKq8=C%)|z-2~3c3uSg!vLaXyG|JRsXMt+RBU@*YWvcpRrA}^e;d9| z010JJk1_(Swo2bU7BoJ-bRh3fD-tCs{q2!`qMXrH+xw3CrN?LH#JS@V(rxErM+T35 zvDFR=%H+Q}7YBc;I$Xygf@a8BM?P|AS`5wHOCl&AQlAGtueL# zRS+H-$_zXCzYBgKrKrFKPMaM#kzrgOnN0&+!^w1-2bs>KvNSD>EsfDe7)`h($_O+! zf=o4iAPY?fmm7sOG2!ue#>`C`HkdY!WYhonX$}?yR5ulgjF@l=7K@9d#)Q)%xLB+= zjB)_z#zwozKbWsQu~-I;NoQ~Y2xF=Mu+PAZ$~QXyW&)U@T*f9cDA1CDkv-oa9?WnW z_hb|e_&SwIR0Q4jmcQpebUcVK1wiH~$jl5hwZzP2-7bU?g>Q6xnQ=4#ba2Qix0!`g z{@PZIzD literal 0 HcmV?d00001 diff --git a/frontend/src/assets/mempool-space-logo.png b/frontend/src/assets/mempool-space-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..306475ed16008715fd4c56d3ed1a2a92f4daf996 GIT binary patch literal 3524 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 pNgTiAK71_yfBKU4b`B`F>`QBIh5{MFBJ= 0.5% +last 2 versions +Firefox ESR +not dead +# IE 9-11 \ No newline at end of file diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts new file mode 100644 index 000000000..3612073bc --- /dev/null +++ b/frontend/src/environments/environment.prod.ts @@ -0,0 +1,3 @@ +export const environment = { + production: true +}; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts new file mode 100644 index 000000000..012182efa --- /dev/null +++ b/frontend/src/environments/environment.ts @@ -0,0 +1,15 @@ +// This file can be replaced during build by using the `fileReplacements` array. +// `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`. +// The list of file replacements can be found in `angular.json`. + +export const environment = { + production: false +}; + +/* + * In development mode, to ignore zone related error stack frames such as + * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can + * import the following file, but please comment it out in production mode + * because it will have performance impact when throw error + */ +// import 'zone.js/dist/zone-error'; // Included with Angular CLI. diff --git a/frontend/src/favicon.ico b/frontend/src/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1e15917e8ce1264d0b541db36b8bed8ddbef8876 GIT binary patch literal 1150 zcmbW1T}V@57{^c0T{qH2*WIa^vvhu2Ep3`ox|L~uW?`jC+wvo^rOho;0$YXDC?iY4 zG&H282o9{&Fz7mnLJO?v+UDHE?tZ=72)daE;_!Q4&hzm5KRoB+NYWPZL`6yB+$q&< zm82b#B*h3sNmRmhaY>TsZQm9q^e&F&Z}0g!Q_137k>Hp>8<|-Ri?c;6%pB$Wd?kTj z^L(-w<8-L_;m8xH1-TJW^K<$L&bL~eZ!|1TsaTpW;qy=-u89(y!JLSk!1+pxd!~)m zk3D!lbg?{9DQX(N^q1ovsb*!k3fGJ3h^!1%;GHtzo4bSWT@S9&T3o_eu$^IbpdR;= z^SGbZMW+8OzSrIOr){h`dhtBFD4cUFS{qn<(1h1^X%iap-qYhBwX#0>n85f$R{L7< z-Z!w+eVL8B9RzN-Z_X_v8_#Y0whyu~Ho$tXnSk{w3q~V8%XQplGoDCTEVxZ#_r6E? zhllYG4&v$R!qeHw$&3`rGgDFsLAUzRv(r)sT6tWa373A*Oh5l3-mKFoo3=I@zCR31{z~Q8LS@MOi7QU`J z>~{#iFhfbcQuv}Lm+`M}Zr0PTzYy|+^YaqL4l)^GKNdqx1vlE7F*WJXWTk}WYBE#E ziI0}e2+lcvAf58OY|3&~ + + + + mempool.space - Bitcoin mempool visualizer + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/karma.conf.js b/frontend/src/karma.conf.js new file mode 100644 index 000000000..b6e00421c --- /dev/null +++ b/frontend/src/karma.conf.js @@ -0,0 +1,31 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage-istanbul-reporter'), + require('@angular-devkit/build-angular/plugins/karma') + ], + client: { + clearContext: false // leave Jasmine Spec Runner output visible in browser + }, + coverageIstanbulReporter: { + dir: require('path').join(__dirname, '../coverage'), + reports: ['html', 'lcovonly'], + fixWebpackSourcePaths: true + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + singleRun: false + }); +}; \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 000000000..91ec6da5f --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,12 @@ +import { enableProdMode } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; +import { environment } from './environments/environment'; + +if (environment.production) { + enableProdMode(); +} + +platformBrowserDynamic().bootstrapModule(AppModule) + .catch(err => console.log(err)); diff --git a/frontend/src/polyfills.ts b/frontend/src/polyfills.ts new file mode 100644 index 000000000..d310405a6 --- /dev/null +++ b/frontend/src/polyfills.ts @@ -0,0 +1,80 @@ +/** + * This file includes polyfills needed by Angular and is loaded before the app. + * You can add your own extra polyfills to this file. + * + * This file is divided into 2 sections: + * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. + * 2. Application imports. Files imported after ZoneJS that should be loaded before your main + * file. + * + * The current setup is for so-called "evergreen" browsers; the last versions of browsers that + * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), + * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. + * + * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html + */ + +/*************************************************************************************************** + * BROWSER POLYFILLS + */ + +/** IE9, IE10 and IE11 requires all of the following polyfills. **/ +// import 'core-js/es6/symbol'; +// import 'core-js/es6/object'; +// import 'core-js/es6/function'; +// import 'core-js/es6/parse-int'; +// import 'core-js/es6/parse-float'; +// import 'core-js/es6/number'; +// import 'core-js/es6/math'; +// import 'core-js/es6/string'; +// import 'core-js/es6/date'; +// import 'core-js/es6/array'; +// import 'core-js/es6/regexp'; +// import 'core-js/es6/map'; +// import 'core-js/es6/weak-map'; +// import 'core-js/es6/set'; + +/** IE10 and IE11 requires the following for NgClass support on SVG elements */ +// import 'classlist.js'; // Run `npm install --save classlist.js`. + +/** IE10 and IE11 requires the following for the Reflect API. */ +// import 'core-js/es6/reflect'; + + +/** Evergreen browsers require these. **/ +// Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. +import 'core-js/es7/reflect'; + + +/** + * Web Animations `@angular/platform-browser/animations` + * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. + * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). + **/ +// import 'web-animations-js'; // Run `npm install --save web-animations-js`. + +/** + * By default, zone.js will patch all possible macroTask and DomEvents + * user can disable parts of macroTask/DomEvents patch by setting following flags + */ + + // (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame + // (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick + // (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames + + /* + * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js + * with the following flag, it will bypass `zone.js` patch for IE/Edge + */ +// (window as any).__Zone_enable_cross_context_check = true; + +/*************************************************************************************************** + * Zone JS is required by default for Angular itself. + */ +import 'zone.js/dist/zone'; // Included with Angular CLI. + + + +/*************************************************************************************************** + * APPLICATION IMPORTS + */ diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss new file mode 100644 index 000000000..129ca1c2e --- /dev/null +++ b/frontend/src/styles.scss @@ -0,0 +1,131 @@ +/* You can add global styles to this file, and also import other style files */ + +$ct-series-names: (a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z); +$ct-series-colors: ( + #D81B60, + #8E24AA, + #5E35B1, + #3949AB, + #1E88E5, + #039BE5, + #00ACC1, + #00897B, + #43A047, + #7CB342, + #C0CA33, + #FDD835, + #FFB300, + #FB8C00, + #F4511E, + #6D4C41, + #757575, + #546E7A, + #b71c1c, + #880E4F, + #4A148C, + #311B92, + #1A237E, + #0D47A1, + #01579B, + #006064, + #004D40, + #1B5E20, + #33691E, + #827717, + #F57F17, + #FF6F00, + #E65100, + #BF360C, + #3E2723, + #212121, + #263238, + #a748ca, + #6188e2, + #a748ca, + #6188e2, + +); + + +$body-bg: #11131f; +$body-color: #fff; +$gray-800: #1d1f31; + +$primary: #2b89c7; + +$link-color: #1bd8f4; +$link-decoration: none !default; +$link-hover-color: darken($link-color, 15%) !default; +$link-hover-decoration: underline !default; + +// Required +@import "../node_modules/bootstrap/scss/bootstrap"; +@import "../node_modules/chartist/dist/scss/chartist.scss"; + +body { + margin-bottom: 60px; +} + +@media (min-width: 768px) { + body.disable-scroll { + overflow: hidden; + } +} + +.ng-invalid.ng-dirty { + border-color: #dc3545; +} + +.modal-content { + background-color: #11131f; +} + +.close { + color: #fff; +} + +.close:hover { + color: #fff; +} + + +.chartist-tooltip { + position: absolute; + display: inline-block; + opacity: 0; + min-width: 5em; + padding: .5em; + background: #F4C63D; + color: #453D3F; + font-family: Oxygen,Helvetica,Arial,sans-serif; + font-weight: 700; + text-align: center; + pointer-events: none; + z-index: 1; + -webkit-transition: opacity .2s linear; + -moz-transition: opacity .2s linear; + -o-transition: opacity .2s linear; + transition: opacity .2s linear; } + .chartist-tooltip:before { + content: ""; + position: absolute; + top: 100%; + left: 50%; + width: 0; + height: 0; + margin-left: -15px; + border: 15px solid transparent; + border-top-color: #F4C63D; } + .chartist-tooltip.tooltip-show { + opacity: 1; } + +.ct-area, .ct-line { + pointer-events: none; } + +.ct-bar { + stroke-width: 1px; +} + +hr { + border-top: 1px solid rgba(255, 255, 255, 0.1); +} \ No newline at end of file diff --git a/frontend/src/test.ts b/frontend/src/test.ts new file mode 100644 index 000000000..16317897b --- /dev/null +++ b/frontend/src/test.ts @@ -0,0 +1,20 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'zone.js/dist/zone-testing'; +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting +} from '@angular/platform-browser-dynamic/testing'; + +declare const require: any; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() +); +// Then we find all the tests. +const context = require.context('./', true, /\.spec\.ts$/); +// And load the modules. +context.keys().map(context); diff --git a/frontend/src/tsconfig.app.json b/frontend/src/tsconfig.app.json new file mode 100644 index 000000000..722c370d5 --- /dev/null +++ b/frontend/src/tsconfig.app.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/app", + "module": "es2015", + "types": [] + }, + "exclude": [ + "src/test.ts", + "**/*.spec.ts" + ] +} diff --git a/frontend/src/tsconfig.spec.json b/frontend/src/tsconfig.spec.json new file mode 100644 index 000000000..8f7cedeca --- /dev/null +++ b/frontend/src/tsconfig.spec.json @@ -0,0 +1,19 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/spec", + "module": "commonjs", + "types": [ + "jasmine", + "node" + ] + }, + "files": [ + "test.ts", + "polyfills.ts" + ], + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/frontend/src/tslint.json b/frontend/src/tslint.json new file mode 100644 index 000000000..52e2c1a5a --- /dev/null +++ b/frontend/src/tslint.json @@ -0,0 +1,17 @@ +{ + "extends": "../tslint.json", + "rules": { + "directive-selector": [ + true, + "attribute", + "app", + "camelCase" + ], + "component-selector": [ + true, + "element", + "app", + "kebab-case" + ] + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 000000000..5ef11f65f --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist/out-tsc", + "sourceMap": true, + "strict": true, + "declaration": false, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "target": "es5", + "typeRoots": [ + "node_modules/@types" + ], + "lib": [ + "es2018", + "dom" + ] + } +} diff --git a/frontend/tslint.json b/frontend/tslint.json new file mode 100644 index 000000000..3ea984c77 --- /dev/null +++ b/frontend/tslint.json @@ -0,0 +1,130 @@ +{ + "rulesDirectory": [ + "node_modules/codelyzer" + ], + "rules": { + "arrow-return-shorthand": true, + "callable-types": true, + "class-name": true, + "comment-format": [ + true, + "check-space" + ], + "curly": true, + "deprecation": { + "severity": "warn" + }, + "eofline": true, + "forin": true, + "import-blacklist": [ + true, + "rxjs/Rx" + ], + "import-spacing": true, + "indent": [ + true, + "spaces" + ], + "interface-over-type-literal": true, + "label-position": true, + "max-line-length": [ + true, + 140 + ], + "member-access": false, + "member-ordering": [ + true, + { + "order": [ + "static-field", + "instance-field", + "static-method", + "instance-method" + ] + } + ], + "no-arg": true, + "no-bitwise": true, + "no-console": [ + true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-super": true, + "no-empty": false, + "no-empty-interface": true, + "no-eval": true, + "no-inferrable-types": [ + true, + "ignore-params" + ], + "no-misused-new": true, + "no-non-null-assertion": true, + "no-shadowed-variable": true, + "no-string-literal": false, + "no-string-throw": true, + "no-switch-case-fall-through": true, + "no-trailing-whitespace": true, + "no-unnecessary-initializer": true, + "no-unused-expression": true, + "no-use-before-declare": true, + "no-var-keyword": true, + "object-literal-sort-keys": false, + "one-line": [ + true, + "check-open-brace", + "check-catch", + "check-else", + "check-whitespace" + ], + "prefer-const": true, + "quotemark": [ + true, + "single" + ], + "radix": true, + "semicolon": [ + true, + "always" + ], + "triple-equals": [ + true, + "allow-null-check" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + } + ], + "unified-signatures": true, + "variable-name": false, + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ], + "no-output-on-prefix": true, + "use-input-property-decorator": true, + "use-output-property-decorator": true, + "use-host-property-decorator": true, + "no-input-rename": true, + "no-output-rename": true, + "use-life-cycle-interface": true, + "use-pipe-transform-interface": true, + "component-class-suffix": true, + "directive-class-suffix": true + } +} diff --git a/mariadb-structure.sql b/mariadb-structure.sql new file mode 100644 index 000000000..4d567ed91 --- /dev/null +++ b/mariadb-structure.sql @@ -0,0 +1,86 @@ +SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; +SET time_zone = "+00:00"; + +CREATE TABLE `blocks` ( + `height` int(11) NOT NULL, + `hash` varchar(65) NOT NULL, + `size` int(11) NOT NULL, + `weight` int(11) NOT NULL, + `minFee` int(11) NOT NULL, + `maxFee` int(11) NOT NULL, + `time` int(11) NOT NULL, + `fees` double NOT NULL, + `nTx` int(11) NOT NULL, + `medianFee` double NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `statistics` ( + `id` int(11) NOT NULL, + `added` datetime NOT NULL, + `unconfirmed_transactions` int(11) UNSIGNED NOT NULL, + `tx_per_second` float UNSIGNED NOT NULL, + `vbytes_per_second` int(10) UNSIGNED NOT NULL, + `mempool_byte_weight` int(10) UNSIGNED NOT NULL, + `fee_data` longtext NOT NULL, + `total_fee` double UNSIGNED NOT NULL, + `vsize_1` int(11) NOT NULL, + `vsize_2` int(11) NOT NULL, + `vsize_3` int(11) NOT NULL, + `vsize_4` int(11) NOT NULL, + `vsize_5` int(11) NOT NULL, + `vsize_6` int(11) NOT NULL, + `vsize_8` int(11) NOT NULL, + `vsize_10` int(11) NOT NULL, + `vsize_12` int(11) NOT NULL, + `vsize_15` int(11) NOT NULL, + `vsize_20` int(11) NOT NULL, + `vsize_30` int(11) NOT NULL, + `vsize_40` int(11) NOT NULL, + `vsize_50` int(11) NOT NULL, + `vsize_60` int(11) NOT NULL, + `vsize_70` int(11) NOT NULL, + `vsize_80` int(11) NOT NULL, + `vsize_90` int(11) NOT NULL, + `vsize_100` int(11) NOT NULL, + `vsize_125` int(11) NOT NULL, + `vsize_150` int(11) NOT NULL, + `vsize_175` int(11) NOT NULL, + `vsize_200` int(11) NOT NULL, + `vsize_250` int(11) NOT NULL, + `vsize_300` int(11) NOT NULL, + `vsize_350` int(11) NOT NULL, + `vsize_400` int(11) NOT NULL, + `vsize_500` int(11) NOT NULL, + `vsize_600` int(11) NOT NULL, + `vsize_700` int(11) NOT NULL, + `vsize_800` int(11) NOT NULL, + `vsize_900` int(11) NOT NULL, + `vsize_1000` int(11) NOT NULL, + `vsize_1200` int(11) NOT NULL, + `vsize_1400` int(11) NOT NULL, + `vsize_1600` int(11) NOT NULL, + `vsize_1800` int(11) NOT NULL, + `vsize_2000` int(11) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `transactions` ( + `blockheight` int(11) NOT NULL, + `txid` varchar(65) NOT NULL, + `fee` double NOT NULL, + `feePerVsize` double NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + + +ALTER TABLE `blocks` + ADD PRIMARY KEY (`height`); + +ALTER TABLE `statistics` + ADD PRIMARY KEY (`id`); + +ALTER TABLE `transactions` + ADD PRIMARY KEY (`txid`), + ADD KEY `blockheight` (`blockheight`); + + +ALTER TABLE `statistics` + MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;